diff --git a/scripts/host_tool_runner.sh b/scripts/host_tool_runner.sh
index 415c6a6169..10a00d1eb1 100755
--- a/scripts/host_tool_runner.sh
+++ b/scripts/host_tool_runner.sh
@@ -484,6 +484,47 @@ launch_c4() {
run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@"
}
+pick_free_galaxy_port() {
+ "${ROOT_DIR}/.venv/bin/python3" - <<'PY'
+import socket
+
+# Desktop ZMQ hashes replay service names into ports 8023-65535. Keep Galaxy
+# below that range so its HTTP server never steals a replay service port.
+for port in range(4600, 8023):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ try:
+ sock.bind(("0.0.0.0", port))
+ except OSError:
+ continue
+ print(port)
+ raise SystemExit(0)
+
+raise SystemExit("Unable to find a free local Galaxy port.")
+PY
+}
+
+launch_galaxy() {
+ sync_worktree
+ ensure_host_python_extensions
+
+ local port
+ port="$(pick_free_galaxy_port)"
+ local galaxy_dir="${HOME}/.comma/starpilot/data/galaxy"
+
+ echo "Starting local Galaxy session on port ${port}..."
+ (
+ cd "${WORK_DIR}"
+ setup_build_env
+ export_workdir_pythonpath
+ export SP_GALAXY_DIR="${galaxy_dir}"
+ export SP_GALAXY_HOST="0.0.0.0"
+ export SP_GALAXY_PORT="${port}"
+ export SP_GALAXY_DEBUG="${SP_GALAXY_DEBUG:-1}"
+ export SP_GALAXY_RELOAD="${SP_GALAXY_RELOAD:-0}"
+ exec "${WORK_DIR}/.venv/bin/python3" -m openpilot.starpilot.system.the_galaxy.the_galaxy
+ )
+}
+
launch_onroad() {
local jobs
jobs="$(default_jobs)"
diff --git a/starpilot/system/the_galaxy/assets/components/main.css b/starpilot/system/the_galaxy/assets/components/main.css
index 7a360fa493..d93a2a886c 100644
--- a/starpilot/system/the_galaxy/assets/components/main.css
+++ b/starpilot/system/the_galaxy/assets/components/main.css
@@ -193,6 +193,19 @@ body {
padding-left: var(--padding-lg);
}
+.embedded #sidebar,
+.embedded #sidebar_shell,
+.embedded #sidebarUnderlay {
+ display: none !important;
+}
+.embedded #menu_button {
+ display: none !important;
+}
+.embedded .content {
+ margin-left: 0 !important;
+ padding-left: var(--padding-lg);
+}
+
/* ――― Headings ――― */
h1,
h2,
diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js
index bae0db76bf..8e92e4a643 100644
--- a/starpilot/system/the_galaxy/assets/components/router.js
+++ b/starpilot/system/the_galaxy/assets/components/router.js
@@ -4,8 +4,10 @@ import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-live-15"
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
+import { DoorControl } from "/assets/components/tools/doors.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
+import { TSKManager } from "/assets/components/tools/tsk_manager.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
@@ -70,12 +72,15 @@ function Root() {
let routes = [
createRoute("bluetooth", "/bluetooth", Bluetooth),
createRoute("wheel_controls", "/wheel-controls", WheelControls),
+ createRoute("doors", "/manage_doors", DoorControl),
+ createRoute("tsk", "/manage_tsk", TSKManager),
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing),
createRoute("navdestination", "/set_navigation_destination", NavDestination),
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
createRoute("root", "/", Home),
+ createRoute("classicRoot", "/classic", Home),
createRoute("routes", "/dashcam_routes", RouteRecordings),
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("sentry", "/sentry", SentryMode),
diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css
new file mode 100644
index 0000000000..0a1c0f8341
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css
@@ -0,0 +1,1310 @@
+:root {
+ color-scheme: dark;
+ --primary: #9d72ff;
+ --primary-hover: #b38fff;
+ --primary-glow: rgba(157, 114, 255, 0.35);
+ --on-primary: #ffffff;
+ --primary-container: rgba(157, 114, 255, 0.18);
+ --on-primary-container: #ede4ff;
+
+ --secondary: #4de8e8;
+ --secondary-glow: rgba(77, 232, 232, 0.3);
+ --on-secondary: #002b2b;
+
+ --accent-rose: #ff60a8;
+ --accent-amber: #ffb865;
+ --error: #ff5277;
+ --on-error: #ffffff;
+ --warning: #ffb865;
+ --success: #4de8e8;
+
+ --background: #070712;
+ --surface: rgba(18, 15, 32, 0.65);
+ --surface-container: rgba(26, 21, 46, 0.55);
+ --surface-container-high: rgba(36, 30, 62, 0.7);
+ --surface-container-low: rgba(14, 11, 26, 0.45);
+
+ --on-surface: #f3f2f8;
+ --on-surface-variant: #9d9bb8;
+ --text-muted: #8b88a8;
+ --text-color: #f3f2f8;
+
+ --glass-bg: rgba(22, 18, 38, 0.7);
+ --glass-bg-hover: rgba(35, 29, 60, 0.8);
+ --glass-active-bg: rgba(157, 114, 255, 0.22);
+ --glass-border: rgba(255, 255, 255, 0.12);
+ --glass-border-light: rgba(255, 255, 255, 0.2);
+ --glass-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.35);
+ --glass-blur: blur(4px);
+ --glass-text: #f3f2f8;
+ --glass-accent: #9d72ff;
+
+ --outline: rgba(255, 255, 255, 0.08);
+ --outline-variant: rgba(255, 255, 255, 0.05);
+
+ --radius-xs: 8px;
+ --radius-sm: 14px;
+ --radius-md: 18px;
+ --radius-lg: 24px;
+ --radius-xl: 32px;
+ --radius-full: 9999px;
+
+ --elev-1: 0 4px 12px rgba(0, 0, 0, 0.2);
+ --elev-2: 0 6px 18px rgba(0, 0, 0, 0.28);
+ --elev-3: 0 12px 32px rgba(0, 0, 0, 0.4);
+
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
+ --sp-5: 24px; --sp-6: 32px; --sp-7: 40px; --sp-8: 48px;
+
+ --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-base: 1rem;
+ --fs-lg: 1.2rem; --fs-xl: 1.45rem; --fs-xxl: 1.85rem;
+ --fw-normal: 400; --fw-medium: 500; --fw-bold: 600;
+
+ --motion-fast: 150ms cubic-bezier(0.2, 0.9, 0.3, 1);
+ --motion-base: 240ms cubic-bezier(0.2, 0.9, 0.3, 1);
+ --motion-slow: 350ms cubic-bezier(0.16, 1, 0.3, 1);
+
+ --appbar-height: 64px;
+ --bottomnav-height: 72px;
+ --touch-target: 44px;
+ --z-appbar: 100;
+ --z-drawer: 200;
+ --z-modal: 1200;
+ --z-snackbar: 1300;
+
+ --scrollbar-track: rgba(255, 255, 255, 0.03);
+ --scrollbar-thumb: rgba(157, 114, 255, 0.4);
+ --scrollbar-thumb-hover: rgba(157, 114, 255, 0.7);
+ --scrollbar-border: rgba(157, 114, 255, 0.3);
+
+ --gx-para-x: 0px;
+}
+
+[data-theme="light"] {
+ color-scheme: light;
+ --primary: #7849e8;
+ --primary-hover: #6737d9;
+ --primary-glow: rgba(120, 73, 232, 0.25);
+ --on-primary: #ffffff;
+ --primary-container: rgba(120, 73, 232, 0.12);
+ --on-primary-container: #471fa8;
+
+ --secondary: #0c9b9b;
+ --secondary-glow: rgba(12, 155, 155, 0.25);
+ --on-secondary: #ffffff;
+ --accent-rose: #e04b8f;
+ --accent-amber: #c9821f;
+ --error: #d63a62;
+ --on-error: #ffffff;
+ --warning: #c9821f;
+ --success: #0c9b9b;
+
+ --background: #f2f4fc;
+ --surface: rgba(255, 255, 255, 0.85);
+ --surface-container: rgba(255, 255, 255, 0.75);
+ --surface-container-high: rgba(255, 255, 255, 0.95);
+ --surface-container-low: rgba(240, 243, 252, 0.75);
+
+ --on-surface: #171526;
+ --on-surface-variant: #5f5b7d;
+ --text-muted: #6f6a8f;
+ --text-color: #171526;
+
+ --glass-bg: rgba(255, 255, 255, 0.82);
+ --glass-bg-hover: rgba(255, 255, 255, 0.95);
+ --glass-active-bg: rgba(120, 73, 232, 0.15);
+ --glass-border: rgba(120, 73, 232, 0.28);
+ --glass-border-light: rgba(120, 73, 232, 0.4);
+ --glass-shadow: 0 8px 24px 0 rgba(80, 60, 140, 0.08);
+ --glass-text: #171526;
+ --glass-accent: #7849e8;
+
+ --outline: rgba(120, 73, 232, 0.14);
+ --outline-variant: rgba(120, 73, 232, 0.08);
+
+ --scrollbar-track: rgba(0, 0, 0, 0.05);
+ --scrollbar-thumb: #7849e8;
+ --scrollbar-thumb-hover: #5d31ca;
+ --scrollbar-border: #471fa8;
+}
+
+* {
+ box-sizing: border-box;
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+}
+
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ border-radius: var(--radius-full);
+}
+
+*::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border: 1px solid var(--scrollbar-border);
+ border-radius: var(--radius-full);
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+}
+
+html, body {
+ background: var(--background);
+ color: var(--on-surface);
+ font-family: var(--font-body);
+ margin: 0;
+ min-height: 100dvh;
+ padding: 0;
+ -webkit-tap-highlight-color: transparent;
+ text-rendering: optimizeSpeed;
+}
+
+html { cursor: default; }
+
+body {
+ overscroll-behavior-y: contain;
+ padding-bottom: env(safe-area-inset-bottom);
+}
+
+#galaxy-bg {
+ position: fixed;
+ inset: 0;
+ overflow: hidden;
+ z-index: 0;
+ background-color: var(--background);
+ background-image:
+ radial-gradient(ellipse at 15% 20%, rgba(157, 114, 255, 0.22) 0%, transparent 55%),
+ radial-gradient(ellipse at 85% 75%, rgba(85, 45, 180, 0.18) 0%, transparent 60%),
+ radial-gradient(circle at 75% 15%, rgba(77, 232, 232, 0.12) 0%, transparent 48%);
+ pointer-events: none;
+ transform: translateZ(0);
+ will-change: transform;
+}
+
+#galaxy-bg::before {
+ content: "";
+ position: absolute;
+ top: 0; left: 0; width: 100%; height: 200%;
+ background-image:
+ radial-gradient(1px 1px at 30px 40px, #ffffff, rgba(255,255,255,0)),
+ radial-gradient(1px 1px at 110px 220px, rgba(220, 200, 255, 0.8), rgba(255,255,255,0)),
+ radial-gradient(1.5px 1.5px at 200px 90px, #ffffff, rgba(255,255,255,0)),
+ radial-gradient(1.5px 1.5px at 310px 340px, rgba(160, 240, 255, 0.9), rgba(255,255,255,0)),
+ radial-gradient(1px 1px at 410px 150px, #ffffff, rgba(255,255,255,0)),
+ radial-gradient(1.5px 1.5px at 640px 110px, #ffffff, rgba(255,255,255,0)),
+ radial-gradient(1px 1px at 890px 180px, #ffffff, rgba(255,255,255,0));
+ background-size: 800px 400px;
+ opacity: 0.5;
+ will-change: transform;
+ transform: translate3d(var(--gx-para-x), 0, 0);
+ animation: galaxy-drift-smooth 160s linear infinite;
+}
+
+#galaxy-bg::after {
+ content: "";
+ position: absolute;
+ top: 0; left: 0; width: 100%; height: 200%;
+ background-image:
+ radial-gradient(1.5px 1.5px at 80px 140px, #ffffff, rgba(255,255,255,0)),
+ radial-gradient(2px 2px at 240px 390px, rgba(190, 235, 255, 0.9), rgba(255,255,255,0)),
+ radial-gradient(1.5px 1.5px at 380px 60px, rgba(230, 195, 255, 0.85), rgba(255,255,255,0)),
+ radial-gradient(2px 2px at 560px 480px, #ffffff, rgba(255,255,255,0)),
+ radial-gradient(1.5px 1.5px at 730px 220px, rgba(255, 255, 255, 0.9), rgba(255,255,255,0));
+ background-size: 900px 450px;
+ opacity: 0.6;
+ will-change: transform;
+ transform: translate3d(var(--gx-para-x), 0, 0);
+ animation: galaxy-drift-smooth 100s linear infinite;
+}
+
+#galaxy-bg .galaxy-hero {
+ position: absolute;
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 0 6px rgba(255, 255, 255, 0.9);
+ animation: galaxy-twinkle 6s ease-in-out infinite alternate;
+}
+
+@keyframes galaxy-drift-smooth {
+ from { transform: translate3d(0, 0, 0); }
+ to { transform: translate3d(0, -400px, 0); }
+}
+
+@keyframes galaxy-twinkle {
+ 0%, 100% { opacity: 0.3; transform: scale(0.9); }
+ 50% { opacity: 1; transform: scale(1.2); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ #galaxy-bg, #galaxy-bg::before, #galaxy-bg::after, #galaxy-bg .galaxy-hero {
+ animation: none !important;
+ }
+}
+
+[data-theme="light"] #galaxy-bg {
+ background-color: #eef1fb;
+ background-image:
+ radial-gradient(ellipse at 20% 20%, rgba(180, 145, 255, 0.28) 0%, transparent 55%),
+ radial-gradient(ellipse at 80% 80%, rgba(120, 220, 235, 0.22) 0%, transparent 60%),
+ radial-gradient(circle at 70% 15%, rgba(255, 170, 210, 0.18) 0%, transparent 48%);
+}
+
+[data-theme="light"] #galaxy-bg::before,
+[data-theme="light"] #galaxy-bg::after,
+[data-theme="light"] #galaxy-bg .galaxy-hero {
+ opacity: 0.12;
+}
+
+[v-cloak] { display: none !important; }
+a { color: inherit; text-decoration: none; }
+button { font-family: var(--font-body); }
+ul { list-style: none; margin: 0; padding: 0; }
+
+:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 3px;
+}
+
+.ripple-ripple {
+ animation: ripple-fade var(--motion-base);
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.4) 10%, transparent 10%);
+ border-radius: 50%;
+ opacity: 0;
+ position: absolute;
+ transform: scale(10);
+ pointer-events: none;
+}
+
+@keyframes ripple-fade {
+ from { opacity: 0.4; transform: scale(0); }
+ to { opacity: 0; transform: scale(10); }
+}
+
+.gx-app {
+ display: flex;
+ flex-direction: column;
+ min-height: 100dvh;
+ position: relative;
+ z-index: 1;
+}
+
+.gx-appbar {
+ align-items: center;
+ display: flex;
+ gap: var(--sp-2);
+ padding: var(--sp-2) var(--sp-3);
+ padding-top: calc(env(safe-area-inset-top) + var(--sp-2));
+ position: sticky;
+ top: 0;
+ z-index: var(--z-appbar);
+}
+
+.gx-appbar__pill {
+ align-items: center;
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ box-shadow: var(--glass-shadow);
+ color: var(--glass-text);
+ display: flex;
+ flex: 1;
+ gap: var(--sp-2);
+ min-width: 0;
+ padding: 6px 12px;
+ transition: background-color var(--motion-fast), border-color var(--motion-fast);
+}
+
+.gx-appbar__back {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: 50%;
+ box-shadow: var(--glass-shadow);
+ color: var(--glass-text);
+ flex: none;
+ height: var(--touch-target);
+ width: var(--touch-target);
+ transition: transform var(--motion-fast), background-color var(--motion-fast);
+}
+
+.gx-appbar__back:active { transform: scale(0.92); }
+
+.gx-appbar__title {
+ font-size: var(--fs-lg);
+ font-weight: var(--fw-bold);
+ white-space: nowrap;
+ letter-spacing: -0.02em;
+}
+
+.gx-appbar__title .gx-logo {
+ height: 28px;
+ margin-right: var(--sp-2);
+ vertical-align: middle;
+}
+
+.gx-appbar__right {
+ align-items: center;
+ display: flex;
+ gap: var(--sp-2);
+ margin-left: auto;
+}
+
+.gx-search {
+ background: var(--surface-container);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ color: var(--on-surface);
+ font-size: var(--fs-sm);
+ height: 40px;
+ max-width: 420px;
+ outline: none;
+ padding: 0 var(--sp-4);
+ transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
+}
+
+.gx-search:focus {
+ background: var(--surface-container-high);
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px var(--primary-glow);
+}
+
+[data-theme="light"] .gx-search {
+ border-color: rgba(120, 73, 232, 0.3);
+}
+
+.gx-icon-btn {
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-full);
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ height: var(--touch-target);
+ justify-content: center;
+ min-height: var(--touch-target);
+ min-width: var(--touch-target);
+ position: relative;
+ transition: background-color var(--motion-fast), transform var(--motion-fast);
+ width: var(--touch-target);
+}
+
+.gx-icon-btn:hover { background: var(--glass-bg-hover); }
+.gx-icon-btn:active { transform: scale(0.9); }
+.gx-icon-btn i { font-size: 1.3rem; }
+.gx-back-btn { display: none; }
+.gx-menu-btn { display: inline-flex; }
+
+.gx-theme-toggle {
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: 50%;
+ box-shadow: var(--glass-shadow);
+ color: var(--glass-text);
+ flex: none;
+ height: var(--touch-target);
+ width: var(--touch-target);
+ transition: transform var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast);
+}
+
+.gx-theme-toggle:active { transform: scale(0.92); }
+
+[data-theme="light"] .gx-theme-toggle {
+ border: 1.5px solid var(--primary);
+ box-shadow: 0 2px 8px rgba(120, 73, 232, 0.2);
+}
+
+.gx-searchwrap {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ position: relative;
+}
+
+.gx-searchwrap .gx-search {
+ padding-right: var(--sp-5);
+ width: 100%;
+}
+
+.gx-search-clear {
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-radius: 50%;
+ color: var(--glass-text);
+ cursor: pointer;
+ display: inline-flex;
+ height: 28px;
+ justify-content: center;
+ position: absolute;
+ right: 4px;
+ transition: background-color var(--motion-fast);
+ width: 28px;
+}
+
+.gx-search-clear:hover { background: var(--glass-active-bg); }
+.gx-search-clear i { font-size: 1rem; }
+
+.gx-status-pill {
+ align-items: center;
+ background: var(--surface-container);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ display: inline-flex;
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-medium);
+ gap: var(--sp-2);
+ padding: 6px 12px;
+}
+
+[data-theme="light"] .gx-status-pill {
+ border: 1px solid rgba(120, 73, 232, 0.3);
+}
+
+.gx-status-dot {
+ border-radius: 50%;
+ height: 8px;
+ width: 8px;
+}
+
+.gx-status-dot.online {
+ background: var(--success);
+ box-shadow: 0 0 8px var(--success);
+}
+
+.gx-status-dot.offline {
+ background: var(--error);
+ box-shadow: 0 0 8px var(--error);
+}
+
+.gx-content {
+ contain: layout style;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ margin: 0 auto;
+ max-width: none;
+ padding: var(--sp-4) var(--sp-4) calc(var(--bottomnav-height) + var(--sp-5));
+ width: 100%;
+}
+
+.liquid-glass-nav {
+ position: fixed;
+ bottom: calc(18px + env(safe-area-inset-bottom, 0px));
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1000;
+
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+
+ background: var(--glass-bg);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+
+ border-radius: var(--radius-full);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
+
+ transition: transform var(--motion-base), background-color var(--motion-fast);
+ max-width: calc(100vw - 32px);
+}
+
+[data-theme="light"] .liquid-glass-nav {
+ border: 1.5px solid var(--primary);
+}
+
+.liquid-glass-nav .nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 16px;
+ border-radius: var(--radius-full);
+ color: var(--on-surface-variant);
+ text-decoration: none;
+ font-size: 0.75rem;
+ font-weight: var(--fw-medium);
+ transition: color var(--motion-fast), background-color var(--motion-fast);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+.liquid-glass-nav .nav-item i {
+ font-size: 1.3rem;
+ margin-bottom: 2px;
+ transition: transform var(--motion-fast);
+}
+
+.liquid-glass-nav .nav-item:hover {
+ color: var(--on-surface);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.liquid-glass-nav .nav-item:active i {
+ transform: scale(0.88);
+}
+
+.liquid-glass-nav .nav-item.active {
+ background: var(--glass-active-bg);
+ color: var(--primary);
+ font-weight: var(--fw-bold);
+}
+
+.liquid-glass-nav .nav-item.active i {
+ transform: translateY(-2px);
+}
+
+.gx-card {
+ contain: layout style;
+ background: var(--surface-container);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--elev-1);
+ margin-bottom: var(--sp-4);
+ overflow: hidden;
+ transition: transform var(--motion-fast), box-shadow var(--motion-fast);
+}
+
+[data-theme="light"] .gx-card {
+ border: 1px solid rgba(120, 73, 232, 0.22);
+}
+
+.gx-section__header {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ gap: var(--sp-3);
+ min-height: 56px;
+ padding: var(--sp-3) var(--sp-4);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.gx-section__header i {
+ color: var(--primary);
+ font-size: 1.35rem;
+}
+
+.gx-section__title {
+ flex: 1;
+ font-weight: var(--fw-bold);
+ letter-spacing: -0.01em;
+}
+
+.gx-section__count {
+ color: var(--text-muted);
+ font-size: var(--fs-xs);
+ background: rgba(255, 255, 255, 0.08);
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+}
+
+[data-theme="light"] .gx-section__count {
+ background: rgba(120, 73, 232, 0.12);
+ color: var(--primary);
+ font-weight: var(--fw-bold);
+}
+
+.gx-chevron {
+ color: var(--text-muted);
+ transition: transform var(--motion-base);
+}
+
+.gx-chevron.open { transform: rotate(180deg); }
+
+.gx-row {
+ align-items: center;
+ border-top: 1px solid var(--outline);
+ display: flex;
+ gap: var(--sp-3);
+ min-height: 64px;
+ padding: var(--sp-3) var(--sp-4);
+ transition: background-color var(--motion-fast);
+}
+
+.gx-row:hover {
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+.gx-row__info { flex: 1; min-width: 0; }
+
+.gx-row__label {
+ display: block;
+ font-weight: var(--fw-medium);
+ font-size: var(--fs-base);
+}
+
+.gx-row__desc {
+ color: var(--text-muted);
+ font-size: var(--fs-sm);
+ margin-top: 2px;
+ line-height: 1.4;
+}
+
+.gx-row__value {
+ color: var(--primary);
+ font-weight: var(--fw-bold);
+ white-space: nowrap;
+}
+
+.gx-row.disabled .gx-row__label, .gx-row.disabled .gx-row__desc { opacity: 0.45; }
+.gx-row--favorites { align-items: stretch; flex-direction: column; }
+.gx-row--favorites .gx-row__info { flex: none; }
+.gx-row--stack { align-items: stretch; flex-direction: column; }
+.gx-row--stack .gx-row__info { flex: none; }
+.gx-row--stack .gx-field,
+.gx-row--stack .gx-slider-row { width: 100%; }
+
+.gx-switch {
+ display: inline-flex;
+ position: relative;
+ cursor: pointer;
+}
+
+.gx-switch input { height: 1px; opacity: 0; position: absolute; width: 1px; }
+
+.gx-switch__track {
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ height: 30px;
+ width: 52px;
+ transition: background-color var(--motion-base), border-color var(--motion-base);
+}
+
+[data-theme="light"] .gx-switch__track {
+ background: rgba(0, 0, 0, 0.08);
+ border: 1.5px solid var(--primary);
+}
+
+.gx-switch__thumb {
+ background: #ffffff;
+ border-radius: 50%;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
+ height: 22px;
+ left: 4px;
+ position: absolute;
+ top: 4px;
+ transition: transform var(--motion-base), width var(--motion-fast);
+ width: 22px;
+}
+
+[data-theme="light"] .gx-switch__thumb {
+ border: 1px solid rgba(120, 73, 232, 0.3);
+}
+
+.gx-switch:active .gx-switch__thumb {
+ width: 26px;
+}
+
+.gx-switch input:checked + .gx-switch__track {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+.gx-switch input:checked ~ .gx-switch__thumb {
+ transform: translateX(22px);
+}
+
+.gx-switch:active input:checked ~ .gx-switch__thumb {
+ transform: translateX(18px);
+}
+
+.gx-switch input:disabled + .gx-switch__track { opacity: 0.4; }
+
+.gx-slider-row {
+ align-items: stretch;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ width: 100%;
+}
+
+.gx-slider-row .gx-row__value { text-align: left; min-width: 0; }
+.gx-slider-row .gx-slider-reset { align-self: flex-end; }
+
+input[type="range"].gx-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: var(--radius-full);
+ cursor: pointer;
+ flex: 1;
+ height: 8px;
+ outline: none;
+ width: 100%;
+}
+
+[data-theme="light"] input[type="range"].gx-slider {
+ background: rgba(120, 73, 232, 0.18);
+ border: 1px solid rgba(120, 73, 232, 0.3);
+}
+
+input[type="range"].gx-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ background: #ffffff;
+ border: 2px solid var(--primary);
+ border-radius: 50%;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
+ height: 22px;
+ width: 22px;
+ transition: transform var(--motion-fast);
+}
+
+input[type="range"].gx-slider:active::-webkit-slider-thumb {
+ transform: scale(1.15);
+}
+
+.gx-slider-reset {
+ background: var(--surface-container-high);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ color: var(--on-surface);
+ cursor: pointer;
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-medium);
+ min-height: 32px;
+ padding: 0 var(--sp-3);
+ transition: background-color var(--motion-fast);
+}
+
+.gx-slider-reset:hover { background: var(--glass-bg-hover); }
+
+[data-theme="light"] .gx-slider-reset {
+ border: 1.5px solid var(--primary);
+}
+
+/* Fields are fluid and shrinkable: never let a select/textarea blow past its
+ container (intrinsic width from options/content) or refuse to shrink inside
+ a flex row. Contexts that want a full-width field (e.g. stacked toggle rows,
+ .gx-field--full) still stretch explicitly. */
+.gx-field {
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-md);
+ box-sizing: border-box;
+ color: var(--on-surface);
+ font-size: var(--fs-base);
+ flex-shrink: 1;
+ max-width: 100%;
+ min-height: 46px;
+ min-width: 0;
+ outline: none;
+ padding: 0 var(--sp-3);
+ transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
+}
+
+.gx-field--full {
+ display: block;
+ width: 100%;
+}
+
+.gx-field:focus {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px var(--primary-glow);
+}
+
+[data-theme="light"] .gx-field {
+ border: 1px solid rgba(120, 73, 232, 0.3);
+ background: rgba(255, 255, 255, 0.9);
+}
+
+select.gx-field { background: var(--surface-container-high); }
+textarea.gx-field { padding: var(--sp-3); border-radius: var(--radius-md); }
+
+input[type="color"].gx-color {
+ border-radius: var(--radius-full);
+ border: 1px solid var(--glass-border);
+ height: 40px;
+ padding: 2px;
+ width: 40px;
+}
+
+[data-theme="light"] input[type="color"].gx-color {
+ border: 1.5px solid var(--primary);
+}
+
+.gx-btn {
+ align-items: center;
+ background: var(--primary);
+ border: none;
+ border-radius: var(--radius-full);
+ box-shadow: 0 4px 12px var(--primary-glow);
+ color: var(--on-primary);
+ cursor: pointer;
+ display: inline-flex;
+ font-weight: var(--fw-bold);
+ gap: var(--sp-2);
+ justify-content: center;
+ min-height: 44px;
+ padding: 0 var(--sp-5);
+ position: relative;
+ transition: transform var(--motion-fast), background-color var(--motion-fast);
+}
+
+.gx-btn:hover { background: var(--primary-hover); }
+.gx-btn:active { transform: scale(0.96); }
+.gx-btn:disabled { opacity: 0.4; box-shadow: none; cursor: not-allowed; }
+
+.gx-btn--text {
+ background: transparent;
+ box-shadow: none;
+ color: var(--primary);
+}
+
+.gx-btn--text:hover { background: var(--glass-active-bg); box-shadow: none; }
+
+.gx-btn--outlined {
+ background: transparent;
+ border: 1px solid var(--glass-border);
+ box-shadow: none;
+ color: var(--on-surface);
+}
+
+[data-theme="light"] .gx-btn--outlined {
+ border: 1.5px solid var(--primary);
+}
+
+.gx-btn--outlined:hover { background: var(--glass-bg-hover); }
+
+.gx-btn--tonal {
+ background: var(--primary-container);
+ box-shadow: none;
+ color: var(--primary);
+}
+
+.gx-btn--tonal:hover { background: rgba(157, 114, 255, 0.28); }
+.gx-btn--block { width: 100%; }
+
+.gx-grid {
+ display: grid;
+ gap: var(--sp-3);
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+}
+
+.gx-tile {
+ align-items: center;
+ background: var(--surface-container);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ color: var(--on-surface);
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ min-height: 100px;
+ padding: var(--sp-4);
+ text-align: center;
+ transition: transform var(--motion-fast), background-color var(--motion-fast);
+}
+
+[data-theme="light"] .gx-tile {
+ border: 1px solid rgba(120, 73, 232, 0.25);
+}
+
+.gx-tile:hover {
+ background: var(--glass-bg-hover);
+ transform: translateY(-2px);
+}
+
+.gx-tile:active { transform: scale(0.96); }
+.gx-tile i { color: var(--primary); font-size: 1.75rem; }
+.gx-tile span { font-size: var(--fs-sm); font-weight: var(--fw-medium); }
+
+.gx-chip {
+ border-radius: var(--radius-full);
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-bold);
+ padding: 3px 10px;
+}
+
+.gx-chip--advanced {
+ background: rgba(255, 184, 101, 0.18);
+ border: 1px solid var(--warning);
+ color: var(--warning);
+}
+
+.gx-chip--dev {
+ background: var(--primary-container);
+ border: 1px solid var(--primary);
+ color: var(--on-primary-container);
+}
+
+.gx-chip--lock {
+ background: rgba(255, 82, 119, 0.18);
+ border: 1px solid var(--error);
+ color: var(--error);
+}
+
+.gx-alert {
+ align-items: center;
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-left: 4px solid var(--warning);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--sp-3);
+ margin-bottom: var(--sp-4);
+ padding: var(--sp-3) var(--sp-4);
+}
+
+.gx-alert--info {
+ background: rgba(157, 114, 255, 0.12);
+ border-left-color: var(--primary);
+}
+
+.gx-alert--warn {
+ background: rgba(255, 184, 101, 0.12);
+ border-left-color: var(--warning);
+}
+
+.gx-alert__icon { font-size: 1.4rem; color: var(--primary); }
+.gx-alert__body { flex: 1 1 220px; }
+.gx-alert__body strong { display: block; font-weight: var(--fw-bold); }
+.gx-alert__body span { color: var(--text-muted); font-size: var(--fs-sm); }
+
+.gx-underlay {
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ inset: 0;
+ position: fixed;
+ z-index: var(--z-drawer);
+}
+
+.gx-drawer {
+ background: var(--surface-container-high);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border-right: 1px solid var(--glass-border);
+ box-shadow: var(--elev-3);
+ height: 100dvh;
+ left: 0;
+ max-width: 320px;
+ overflow-y: auto;
+ padding: env(safe-area-inset-top) var(--sp-3) var(--sp-5);
+ position: fixed;
+ top: 0;
+ transform: translateX(-100%);
+ transition: transform var(--motion-slow);
+ width: 84vw;
+ z-index: calc(var(--z-drawer) + 1);
+}
+
+[data-theme="light"] .gx-drawer {
+ border-right: 1.5px solid var(--primary);
+}
+
+.gx-drawer.open { transform: translateX(0); }
+
+.gx-drawer__header {
+ align-items: center;
+ display: flex;
+ gap: var(--sp-3);
+ margin-bottom: var(--sp-3);
+ padding: var(--sp-3) var(--sp-2);
+}
+
+.gx-drawer__header img { height: 44px; width: 44px; }
+
+.gx-drawer__header .gx-drawer-title {
+ font-size: var(--fs-lg);
+ font-weight: var(--fw-bold);
+ letter-spacing: -0.02em;
+}
+
+.gx-nav-section { margin-bottom: var(--sp-3); }
+
+.gx-nav-section__title {
+ color: var(--text-muted);
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-bold);
+ letter-spacing: 0.08em;
+ padding: var(--sp-2);
+ text-transform: uppercase;
+}
+
+.gx-nav-item {
+ align-items: center;
+ border-radius: var(--radius-full);
+ color: var(--on-surface);
+ cursor: pointer;
+ display: flex;
+ font-weight: var(--fw-medium);
+ gap: var(--sp-3);
+ min-height: 46px;
+ padding: 0 var(--sp-4);
+ position: relative;
+ transition: background-color var(--motion-fast);
+}
+
+.gx-nav-item:hover { background: rgba(255, 255, 255, 0.08); }
+
+.gx-nav-item.active {
+ background: var(--glass-active-bg);
+ color: var(--primary);
+ font-weight: var(--fw-bold);
+}
+
+[data-theme="light"] .gx-nav-item.active {
+ border: 1px solid rgba(120, 73, 232, 0.35);
+}
+
+.gx-nav-item i { font-size: 1.3rem; width: 24px; }
+
+.gx-scrim {
+ align-items: center;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ padding: var(--sp-4);
+ padding-top: max(var(--sp-4), calc(var(--appbar-height) + var(--sp-3)));
+ padding-bottom: max(var(--sp-4), calc(var(--bottomnav-height) + var(--sp-3)));
+ position: fixed;
+ z-index: var(--z-modal);
+}
+
+.gx-dialog, .gx-sheet {
+ background: var(--surface-container-high);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--elev-3);
+ box-sizing: border-box;
+ max-height: min(720px, calc(100dvh - var(--appbar-height) - var(--bottomnav-height)));
+ max-width: 560px;
+ overflow: auto;
+ overscroll-behavior: contain;
+ padding: var(--sp-5);
+ width: 100%;
+}
+
+[data-theme="light"] .gx-dialog, [data-theme="light"] .gx-sheet {
+ border: 1.5px solid var(--primary);
+}
+
+.gx-sheet {
+ position: relative;
+}
+
+.gx-dialog__title, .gx-sheet__title {
+ font-size: var(--fs-lg);
+ font-weight: var(--fw-bold);
+ margin: 0 0 var(--sp-3);
+}
+
+.gx-dialog__actions {
+ display: flex;
+ gap: var(--sp-2);
+ justify-content: flex-end;
+ margin-top: var(--sp-5);
+}
+
+.gx-tree-node { position: relative; }
+
+.gx-tree-node--child {
+ border-left: 2px solid var(--glass-border);
+ margin-left: calc(var(--gx-depth, 1) * var(--sp-2));
+ padding-left: var(--sp-2);
+}
+
+.gx-tree-node--child .gx-row { border-top-style: dashed; }
+
+.gx-tree-children {
+ background: var(--surface-container-low);
+ overflow: hidden;
+}
+
+.gx-manage-btn {
+ align-items: center;
+ background: var(--surface-container);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ color: var(--on-surface);
+ cursor: pointer;
+ display: inline-flex;
+ font-size: var(--fs-sm);
+ gap: var(--sp-1);
+ margin: var(--sp-1) var(--sp-4) var(--sp-3);
+ min-height: 34px;
+ padding: 0 var(--sp-3);
+ transition: background-color var(--motion-fast);
+}
+
+.gx-manage-btn:hover { background: var(--glass-bg-hover); }
+.gx-manage-btn i { font-size: 0.8rem; }
+
+[data-theme="light"] .gx-manage-btn {
+ border: 1px solid var(--primary);
+}
+
+.gx-terminal {
+ background: rgba(8, 6, 16, 0.85);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-md);
+ color: #c9c6ec;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ margin: var(--sp-3);
+ max-height: 60vh;
+ min-height: 160px;
+ overflow: auto;
+ padding: var(--sp-3);
+ white-space: pre-wrap;
+}
+
+.gx-video {
+ background: #000;
+ border-radius: var(--radius-md);
+ display: block;
+ margin: 0 auto;
+ max-height: 48dvh;
+ width: 100%;
+}
+
+.gx-view, .gx-embed {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ width: 100%;
+}
+
+.gx-embed__frame {
+ background: var(--surface);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-md);
+ display: block;
+ flex: 1;
+ height: 100%;
+ min-height: 0;
+ width: 100%;
+}
+
+.gx-loading, .gx-empty {
+ color: var(--text-muted);
+ padding: var(--sp-6);
+ text-align: center;
+}
+
+.gx-loading::before {
+ animation: gx-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
+ border: 3px solid rgba(255, 255, 255, 0.1);
+ border-radius: 50%;
+ border-top-color: var(--primary);
+ content: "";
+ display: block;
+ height: 36px;
+ margin: 0 auto var(--sp-3);
+ width: 36px;
+}
+
+@keyframes gx-spin { to { transform: rotate(360deg); } }
+
+.gx-spin {
+ animation: gx-spin 0.9s linear infinite;
+ display: inline-block;
+}
+
+#snackbar_wrapper {
+ position: fixed;
+ top: calc(var(--sp-4) + env(safe-area-inset-top, 0px));
+ left: 0; right: 0;
+ z-index: var(--z-snackbar);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ pointer-events: none;
+ padding: 0 var(--sp-3);
+}
+
+#snackbar_wrapper .snackbar {
+ pointer-events: auto;
+ max-width: 480px;
+ width: 100%;
+ background: var(--surface-container-high);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ box-shadow: var(--elev-3);
+ padding: 12px 20px;
+ opacity: 0;
+ transform: translateY(-16px) scale(0.97);
+ transition: transform var(--motion-base), opacity var(--motion-base);
+}
+
+[data-theme="light"] #snackbar_wrapper .snackbar {
+ border: 1.5px solid var(--primary);
+}
+
+#snackbar_wrapper .snackbar.show {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+
+.gx-fade-enter-active, .gx-fade-leave-active { transition: opacity var(--motion-base); }
+.gx-fade-enter-from, .gx-fade-leave-to { opacity: 0; }
+
+.gx-slide-enter-active, .gx-slide-leave-active {
+ transition: transform var(--motion-base), opacity var(--motion-base);
+}
+.gx-slide-enter-from { opacity: 0; transform: translateY(16px); }
+.gx-slide-leave-to { opacity: 0; transform: translateY(-16px); }
+
+.gx-collapse-enter-active, .gx-collapse-leave-active {
+ max-height: 999px;
+ overflow: hidden;
+ transition: max-height var(--motion-base), opacity var(--motion-base);
+}
+.gx-collapse-enter-from, .gx-collapse-leave-to { max-height: 0; opacity: 0; }
+
+@media (max-width: 767px) {
+ .liquid-glass-nav { display: flex; }
+ .gx-menu-btn { display: none; }
+ .gx-back-btn { display: inline-flex; }
+ .gx-appbar__search { flex: 1; min-width: 0; }
+ .gx-status-pill { display: none; }
+ .gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); }
+}
+
+@media (min-width: 768px) {
+ .liquid-glass-nav { display: none; }
+ .gx-menu-btn { display: inline-flex; }
+ .gx-back-btn { display: none; }
+ .gx-content { padding-bottom: var(--sp-6); }
+}
\ No newline at end of file
diff --git a/starpilot/system/the_galaxy/assets/mobile/index.html b/starpilot/system/the_galaxy/assets/mobile/index.html
new file mode 100644
index 0000000000..53c373f546
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Galaxy
+
+
+
+
+
+
+
+
+
+
+
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js
new file mode 100644
index 0000000000..715f375dfc
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js
@@ -0,0 +1,496 @@
+export const LAYOUT_URL = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
+
+async function handle(res) {
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ const err = new Error(data?.error || data?.message || res.statusText || "Request failed")
+ err.data = data
+ throw err
+ }
+ return data
+}
+
+export const api = {
+ async postAction(endpoint) {
+ const res = await fetch(endpoint, { method: "POST" })
+ return handle(res)
+ },
+
+ async getOptions(endpoint) {
+ const res = await fetch(endpoint)
+ return handle(res)
+ },
+
+ async getLayout() {
+ const res = await fetch(LAYOUT_URL, { cache: "no-store" })
+ const data = await handle(res)
+ return (data || [])
+ .map((section) => ({ ...section, params: (section.params || []).filter((p) => p.key !== "Model") }))
+ .filter((section) => (section.params || []).length > 0)
+ },
+
+ async getParams() {
+ const res = await fetch("/api/params/all")
+ return handle(res)
+ },
+
+ async getDefaults() {
+ const res = await fetch("/api/params/defaults")
+ return res.ok ? handle(res) : {}
+ },
+
+ async updateParam({ key, value, label }) {
+ const body = { key, value }
+ if (label) body.label = label
+ const res = await fetch("/api/params", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async getFlmWorkspace() {
+ const res = await fetch("/api/flm/workspace", { cache: "no-store" })
+ return res.ok ? handle(res) : null
+ },
+
+ async getFavoritesSlots() {
+ const res = await fetch("/api/favorites/slots", { cache: "no-store" })
+ return handle(res)
+ },
+
+ async saveFavoritesSlots(slots) {
+ const res = await fetch("/api/favorites/slots", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ slots }),
+ })
+ return handle(res)
+ },
+
+ async activateFavoriteAction(key) {
+ const res = await fetch("/api/favorites/action", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ key }),
+ })
+ return handle(res)
+ },
+
+ async getDeviceStatus() {
+ const res = await fetch("/api/device/status")
+ return res.ok ? handle(res) : null
+ },
+
+ async getStats() {
+ const res = await fetch("/api/stats")
+ return res.ok ? handle(res) : null
+ },
+
+
+ async getRoutesStream({ onProgress, onRoutes, signal } = {}) {
+ const res = await fetch("/api/routes", { signal })
+ if (!res.ok || !res.body) throw new Error(`Route request failed (${res.status})`)
+ const reader = res.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+ while (true) {
+ const { value, done } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const events = buffer.split(/\r?\n\r?\n/)
+ buffer = events.pop() || ""
+ for (const event of events) {
+ const lines = event.split(/\r?\n/).filter((l) => l.startsWith("data:"))
+ if (!lines.length) continue
+ try {
+ const payload = JSON.parse(lines.map((l) => l.slice(5).trimStart()).join("\n"))
+ if (Number.isFinite(payload.progress)) onProgress?.(payload.progress)
+ onRoutes?.(Array.isArray(payload.routes) ? payload.routes : [])
+ } catch (e) { }
+ }
+ }
+ },
+
+ async getRoute(name) {
+ const res = await fetch(`/api/routes/${encodeURIComponent(name)}`)
+ return handle(res)
+ },
+
+ async deleteRoute(name) {
+ const res = await fetch(`/api/routes/${encodeURIComponent(name)}`, { method: "DELETE" })
+ return handle(res)
+ },
+
+ async renameRoute(oldName, newName) {
+ const res = await fetch("/api/routes/rename", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ old: oldName, new: newName }),
+ })
+ return handle(res)
+ },
+
+ async resetRouteName(name) {
+ const res = await fetch("/api/routes/reset_name", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ })
+ return handle(res)
+ },
+
+ async setRoutePreserved(name, preserved) {
+ const res = await fetch(`/api/routes/${encodeURIComponent(name)}/preserve`, { method: preserved ? "POST" : "DELETE" })
+ return handle(res)
+ },
+
+ async deleteAllRoutes(includePreserved) {
+ const res = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" })
+ return handle(res)
+ },
+
+ async getRouteLogs(name) {
+ const res = await fetch(`/api/routes/${encodeURIComponent(name)}/logs`)
+ return handle(res)
+ },
+
+ async getScreenRecordings() {
+ const res = await fetch("/api/screen_recordings/list")
+ return handle(res)
+ },
+
+ async deleteScreenRecording(filename) {
+ const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
+ return handle(res)
+ },
+
+ async deleteAllScreenRecordings() {
+ const res = await fetch("/api/screen_recordings/delete_all", { method: "DELETE" })
+ return handle(res)
+ },
+
+ async renameScreenRecording(oldName, newName) {
+ const res = await fetch("/api/screen_recordings/rename", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ old: oldName, new: newName }),
+ })
+ return handle(res)
+ },
+
+
+ async getErrorLogs() {
+ const res = await fetch("/api/error_logs", { headers: { Accept: "application/json" } })
+ return handle(res)
+ },
+
+ async getErrorLog(filename) {
+ const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`)
+ return res.text()
+ },
+
+ async deleteErrorLog(filename) {
+ const res = await fetch(`/api/error_logs/${encodeURIComponent(filename)}`, { method: "DELETE" })
+ return res.ok
+ },
+
+ async deleteAllErrorLogs() {
+ const res = await fetch("/api/error_logs/delete_all", { method: "DELETE" })
+ return res.ok
+ },
+
+ async getTmuxLogs() {
+ const res = await fetch("/api/tmux_log/list")
+ return handle(res)
+ },
+
+ async tmuxCapture() {
+ const res = await fetch("/api/tmux_log/capture", { method: "POST" })
+ return res.ok
+ },
+
+ async tmuxSnapshot() {
+ const res = await fetch("/api/tmux_log/snapshot")
+ return handle(res)
+ },
+
+ async deleteTmuxLog(filename) {
+ const res = await fetch(`/api/tmux_log/delete/${encodeURIComponent(filename)}`, { method: "DELETE" })
+ return res.ok
+ },
+
+ async deleteAllTmuxLogs() {
+ const res = await fetch("/api/tmux_log/delete_all", { method: "DELETE" })
+ return res.ok
+ },
+
+ async renameTmuxLog(oldName, newName) {
+ const res = await fetch(`/api/tmux_log/rename/${encodeURIComponent(oldName)}/${encodeURIComponent(newName)}`, { method: "PUT" })
+ return res.ok
+ },
+
+ async runTroubleshoot() {
+ const res = await fetch("/api/troubleshoot", { method: "POST" })
+ return handle(res)
+ },
+
+ async getTroubleshoot() {
+ const res = await fetch("/api/troubleshoot")
+ return res.ok ? handle(res) : null
+ },
+
+ async resetTroubleshoot() {
+ const res = await fetch("/api/troubleshoot/reset", { method: "POST" })
+ return res.ok
+ },
+
+
+ async getWheelControlsStatus() {
+ const res = await fetch("/api/wheel-controls/status", { cache: "no-store" })
+ return handle(res)
+ },
+
+ async wheelControlsOp(operation, body = {}) {
+ const res = await fetch(`/api/wheel-controls/${operation}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async getBluetoothStatus() {
+ const res = await fetch("/api/bluetooth/status")
+ return handle(res)
+ },
+
+ async bluetoothOp(operation, body = {}) {
+ const res = await fetch(`/api/bluetooth/${operation}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async carFeaturesCheck(tool = "") {
+ const query = tool ? `?tool=${encodeURIComponent(tool)}` : ""
+ const res = await fetch(`/api/car_features_check${query}`)
+ return res.ok ? handle(res) : null
+ },
+
+
+ async lateralManeuvers(action) {
+ const res = await fetch(`/api/lateral_maneuvers/${action}`, { method: "POST" })
+ return handle(res)
+ },
+
+ async lateralManeuversStatus() {
+ const res = await fetch("/api/lateral_maneuvers/status")
+ return handle(res)
+ },
+
+ async longitudinalManeuvers(action) {
+ const res = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
+ return handle(res)
+ },
+
+ async longitudinalManeuversStatus() {
+ const res = await fetch("/api/longitudinal_maneuvers/status")
+ return handle(res)
+ },
+
+
+ async getMapsStatus() {
+ const res = await fetch("/api/maps/status")
+ return handle(res)
+ },
+
+ async getMapsCatalog() {
+ const res = await fetch("/api/maps/catalog")
+ return handle(res)
+ },
+
+ async mapsOp(operation, body = {}) {
+ const res = await fetch(`/api/maps/${operation}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async getNavigation() {
+ const res = await fetch("/api/navigation")
+ return handle(res)
+ },
+
+ async setNavigation(body) {
+ const res = await fetch("/api/navigation", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async getNavigationKeys() {
+ const res = await fetch("/api/navigation_key")
+ return handle(res)
+ },
+
+ async setNavigationKey(body) {
+ const res = await fetch("/api/navigation_key", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async navigationFavorite(body) {
+ const res = await fetch("/api/navigation/favorite", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+
+ async backupToggles() {
+ const res = await fetch("/api/toggles/backup", { method: "POST" })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data?.message || "Failed to create toggle backup.")
+ }
+ return res.blob()
+ },
+
+ async restoreToggles(data) {
+ const res = await fetch("/api/toggles/restore", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ })
+ return handle(res)
+ },
+
+ async resetTogglesDefault() {
+ const res = await fetch("/api/toggles/reset_default", { method: "POST" })
+ return handle(res)
+ },
+
+ async getUpdateBranches() {
+ const res = await fetch("/api/update/branches")
+ return handle(res)
+ },
+
+ async getUpdateBranch() {
+ const res = await fetch("/api/update/branch")
+ return handle(res)
+ },
+
+ async setUpdateBranch(branch) {
+ const res = await fetch("/api/update/branch", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ branch }),
+ })
+ return handle(res)
+ },
+
+ async updateFast() {
+ const res = await fetch("/api/update/fast", { method: "POST" })
+ return handle(res)
+ },
+
+ async getUpdateFastStatus() {
+ const res = await fetch("/api/update/fast/status")
+ return handle(res)
+ },
+
+ async updateRecover() {
+ const res = await fetch("/api/update/recover", { method: "POST" })
+ return handle(res)
+ },
+
+ async updateRollback() {
+ const res = await fetch("/api/update/rollback", { method: "POST" })
+ return handle(res)
+ },
+
+ async factoryReset() {
+ const res = await fetch("/api/update/factory_reset", { method: "POST" })
+ return handle(res)
+ },
+
+ async getAgnosStatus() {
+ const res = await fetch("/api/update/agnos_status")
+ return res.ok ? handle(res) : null
+ },
+
+
+ async getVasmConfig() {
+ const res = await fetch("/api/v_asm/config")
+ return handle(res)
+ },
+
+ async setVasmConfig(body) {
+ const res = await fetch("/api/v_asm/config", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async vasmSnapshot() {
+ const res = await fetch("/api/v_asm/snapshot")
+ return res.ok ? handle(res) : null
+ },
+
+ async getPipConfig() {
+ const res = await fetch("/api/pip_preview/config")
+ return handle(res)
+ },
+
+ async setPipConfig(body) {
+ const res = await fetch("/api/pip_preview/config", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ return handle(res)
+ },
+
+ async pipSnapshot() {
+ const res = await fetch("/api/pip_preview/snapshot")
+ return res.ok ? handle(res) : null
+ },
+}
+
+export function showSnackbar(message, level = "info") {
+ const wrapper = document.getElementById("snackbar_wrapper")
+ if (!wrapper) return
+ for (const el of Array.from(wrapper.children)) {
+ el.classList.remove("show")
+ el.remove()
+ }
+ const el = document.createElement("div")
+ el.className = "snackbar show"
+ el.style.background = level === "error" ? "var(--error)" : "var(--color-confirm, #8b6cc5)"
+ el.style.borderRadius = "var(--border-radius-base, 5px)"
+ el.style.color = "var(--text-color, #fff)"
+ el.style.margin = "0 auto var(--margin-base, 1rem)"
+ el.style.padding = "var(--padding-base, 1rem)"
+ el.style.textAlign = "center"
+ el.textContent = message
+ wrapper.appendChild(el)
+ setTimeout(() => {
+ el.classList.remove("show")
+ setTimeout(() => el.remove(), 500)
+ }, 2400)
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/app.js b/starpilot/system/the_galaxy/assets/mobile/js/app.js
new file mode 100644
index 0000000000..d1e9733f99
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/app.js
@@ -0,0 +1,85 @@
+import { createApp, h } from "vue"
+import { AppShell } from "./components/AppShell.js"
+import { Home } from "./views/Home.js"
+import { Settings } from "./views/Settings.js"
+import { Tools } from "./views/Tools.js"
+import { Recordings } from "./views/Recordings.js"
+import { Logs } from "./views/Logs.js"
+import { Tuning } from "./views/Tuning.js"
+import { Navigation } from "./views/Navigation.js"
+import { Vehicle } from "./views/Vehicle.js"
+import { SystemTools } from "./views/SystemTools.js"
+import { ToolEmbed } from "./views/ToolEmbed.js"
+import { store, initRouter, navigate } from "./store.js"
+import { showSnackbar } from "./api.js"
+
+window.__galaxyVue = { createApp, h }
+
+window.addEventListener("message", (event) => {
+ const data = event?.data
+ if (!data || data.source !== "galaxy-embed" || typeof data.path !== "string") return
+ const current = store.params.src || ""
+ const target = data.path
+ if (target === current || target === "/" + current) return
+ navigate("/embed?src=" + encodeURIComponent(target))
+})
+
+const VIEWS = {
+ "/": Home,
+ "/settings": Settings,
+ "/tools": Tools,
+ "/recordings": Recordings,
+ "/logs": Logs,
+ "/tuning": Tuning,
+ "/navigation": Navigation,
+ "/vehicle": Vehicle,
+ "/system": SystemTools,
+ "/embed": ToolEmbed,
+}
+
+function resolveView(path) {
+ if (path === "/embed" || path.startsWith("/embed/")) return ToolEmbed
+ for (const [root, view] of Object.entries(VIEWS)) {
+ if (path === root || (root !== "/" && path.startsWith(root + "/"))) return view
+ }
+ if (path === "/") return Home
+ return ToolEmbed
+}
+
+const app = createApp({
+ name: "GalaxyApp",
+ errorCaptured(err) {
+ console.error("[galaxy-ui]", err)
+ showSnackbar("Something went wrong: " + (err?.message || err), "error")
+ return false
+ },
+ computed: {
+ View() {
+ return resolveView(store.route)
+ },
+ },
+ render() {
+ return h(AppShell, null, {
+ default: () => h(this.View),
+ })
+ },
+})
+
+app.mount("#galaxy-app")
+
+initRouter()
+
+;(() => {
+ const bg = document.getElementById("galaxy-bg")
+ if (!bg) return
+ for (let i = 0; i < 14; i++) {
+ const s = document.createElement("i")
+ s.className = "galaxy-hero"
+ s.style.left = (Math.random() * 100).toFixed(2) + "%"
+ s.style.top = (Math.random() * 100).toFixed(2) + "%"
+ s.style.animationDelay = (Math.random() * 4).toFixed(2) + "s"
+ const size = Math.random() > 0.6 ? 3 : 2
+ s.style.width = s.style.height = size + "px"
+ bg.appendChild(s)
+ }
+})()
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js
new file mode 100644
index 0000000000..91e9c0a58a
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js
@@ -0,0 +1,168 @@
+import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
+import { api } from "../api.js"
+import { usePolling } from "../composables.js"
+
+const NAV = {
+ recordings: [
+ { name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
+ ],
+ tools: [
+ { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle" },
+ { name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right" },
+ { name: "Navigation & Maps", link: "/navigation", icon: "bi-map" },
+ { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" },
+ { name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box" },
+ { name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video" },
+ { name: "System Tools", link: "/system", icon: "bi-arrow-repeat" },
+ { name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
+ { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" },
+ { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
+ { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
+ { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
+ { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
+ ],
+}
+
+const BOTTOM_NAV = [
+ { name: "Home", link: "/", icon: "bi-house-fill" },
+ { name: "Settings", link: "/settings", icon: "bi-toggle-on" },
+ { name: "Tools", link: "/tools", icon: "bi-tools" },
+ { name: "Recordings", link: "/recordings", icon: "bi-camera-reels" },
+]
+
+export const AppShell = {
+ name: "AppShell",
+ data() {
+ return { store, BOTTOM_NAV, NAV }
+ },
+ computed: {
+ online() { return store.online },
+ statusLabel() { return store.online ? store.deviceStatus : "Offline" },
+ isLight() { return store.theme === "light" },
+ drawerOpen: {
+ get() { return store.drawerOpen },
+ set(v) { store.drawerOpen = v },
+ },
+ activePath() { return store.route },
+ search: {
+ get() { return store.search },
+ set(v) { store.search = v },
+ },
+ },
+ watch: {
+ "store.search"(q) {
+ if (q && store.route !== "/settings" && !store.route.startsWith("/settings/")) {
+ navigate("/settings")
+ }
+ },
+ },
+ methods: {
+ closeDrawer() { store.drawerOpen = false },
+ back() { goBack() },
+ async refreshStatus() {
+ try {
+ const payload = await api.getDeviceStatus()
+ if (!payload) throw new Error("no status")
+ store.online = true
+ store.deviceStatus = String(payload.status || "Parked")
+ } catch (e) {
+ store.online = false
+ }
+ },
+ clearSearch() {
+ store.search = ""
+ this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
+ },
+ themeToggle() { toggleTheme() },
+ navTo(link) {
+ this.closeDrawer()
+ navigate(toolHref(link))
+ },
+ bottomNavTo(item) {
+ navigate(item.link)
+ },
+ isActive(link) {
+ return this.activePath === link || (link !== "/" && this.activePath.startsWith(link))
+ },
+ },
+ created() {
+ this.statusPoll = usePolling(() => this.refreshStatus(), { interval: 5000 })
+ this.statusPoll.start()
+ },
+ beforeUnmount() {
+ this.statusPoll?.destroy()
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js b/starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js
new file mode 100644
index 0000000000..3e42d54128
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/BluetoothPanel.js
@@ -0,0 +1,134 @@
+import { api, showSnackbar } from "../api.js"
+import { usePolling } from "../composables.js"
+
+function address(device) { return String(device.address || "").toUpperCase() }
+
+export const BluetoothPanel = {
+ name: "BluetoothPanel",
+ data() {
+ return {
+ loading: true, busy: "", available: false, enabled: false, powered: false, discovering: false,
+ offroad: false, selectedAudio: "", pairingAddress: "", devices: [], prompt: null, pairValue: "", error: "",
+ }
+ },
+ created() { this.poll = usePolling(() => this.refresh(), { interval: 2000 }); this.poll.start() },
+ beforeUnmount() { this.poll?.destroy() },
+ computed: {
+ known() { return this.devices.filter((d) => d.paired || d.trusted || d.connected) },
+ availableDevices() { return this.devices.filter((d) => !d.paired && !d.trusted && !d.connected) },
+ },
+ methods: {
+ async refresh() {
+ try {
+ const p = await api.getBluetoothStatus()
+ this.available = !!p.available
+ this.enabled = !!p.enabled
+ this.powered = !!p.powered
+ this.discovering = !!p.discovering
+ this.offroad = !!p.offroad
+ this.selectedAudio = String(p.selected_audio || "")
+ this.pairingAddress = String(p.pairing_address || "")
+ this.devices = Array.isArray(p.devices) ? p.devices : []
+ this.prompt = p.prompt || null
+ this.error = p.error || ""
+ } catch (e) {
+ this.available = false
+ this.error = e?.message || "Bluetooth service unavailable"
+ } finally {
+ this.loading = false
+ }
+ },
+ async request(operation, body = {}) {
+ if (this.busy) return
+ this.busy = operation
+ try {
+ await api.bluetoothOp(operation, body)
+ this.error = ""
+ await this.refresh()
+ } catch (e) {
+ this.error = e?.message || "Bluetooth operation failed"
+ } finally {
+ this.busy = ""
+ }
+ },
+ pair(d) { this.request("pair", { address: d.address }) },
+ connect(d) { this.request(d.connected ? "disconnect" : "connect", { address: d.address }) },
+ forget(d) { this.request("forget", { address: d.address }) },
+ audio(d) { const isSel = this.selectedAudio.toUpperCase() === address(d); this.request("select_audio", { address: isSel ? "" : d.address }) },
+ testAudio(d) { this.request("test_audio", { address: d.address }) },
+ respondPairing(accepted) {
+ const prompt = this.prompt
+ if (!prompt || this.busy === "pairing_response") return
+ if (accepted && (prompt.kind === "pin" || prompt.kind === "passkey") && !this.pairValue.trim()) {
+ this.error = "Enter the value to continue pairing."
+ return
+ }
+ this.request("pairing_response", { prompt_id: prompt.id, accepted, value: this.pairValue.trim() })
+ },
+ isPairing(d) { return !!this.pairingAddress && this.pairingAddress.toUpperCase() === address(d) },
+ statusOf(d) {
+ if (this.isPairing(d)) return "Pairing…"
+ if (d.connected) {
+ const audioSel = this.selectedAudio.toUpperCase() === address(d)
+ return audioSel ? "Connected · Audio output" : "Connected"
+ }
+ return d.paired ? "Saved" : "Ready to pair"
+ },
+ offroadDisabled() { return !this.offroad || !!this.busy },
+ needsPairValue() { return this.prompt && (this.prompt.kind === "pin" || this.prompt.kind === "passkey") },
+ },
+ template: `
+
+
+
+ Bluetooth {{ enabled ? 'On' : 'Off' }}
+
+
+
Scanning, pairing, and forgetting devices are available offroad only.
+
{{ error }}
+
+
+
+
+
{{ prompt.kind === 'confirmation' ? 'Confirm the pairing request.' : prompt.kind === 'authorization' ? 'Allow this device to connect?' : prompt.kind === 'pin' ? 'Enter the PIN supplied by the device.' : 'Enter the device passkey.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
My Devices
+
No saved devices yet.
+
+
+ {{ d.name }} Connected
+ {{ d.audio && d.controller ? 'Audio · Controller' : d.audio ? 'Audio' : d.controller ? 'Controller' : 'Bluetooth' }} · {{ statusOf(d) }}
+
+
+
+
+
+
+
+
+
+
Available Devices
+
{{ discovering ? 'Searching for nearby devices…' : 'No nearby devices found.' }}
+
+
+ {{ d.name }}
+ {{ statusOf(d) }}
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js b/starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js
new file mode 100644
index 0000000000..ca365b2351
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/DevModeBanner.js
@@ -0,0 +1,25 @@
+import { navigate } from "../store.js"
+
+export const DevModeBanner = {
+ name: "DevModeBanner",
+ props: {
+ hiddenCount: { type: Number, default: 0 },
+ devModeOn: { type: Boolean, default: false },
+ },
+ computed: {
+ visible() { return !this.devModeOn && this.hiddenCount > 0 },
+ },
+ methods: {
+ unlock() { navigate("/settings/developer") },
+ },
+ template: `
+
+
+
+ {{ hiddenCount }} advanced setting{{ hiddenCount !== 1 ? "s" : "" }} hidden.
+ Advanced features are tucked away until you enable Developer Mode.
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js b/starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js
new file mode 100644
index 0000000000..3727afebd0
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/FavoritesEditor.js
@@ -0,0 +1,184 @@
+import { api, showSnackbar } from "../api.js"
+
+const FAVORITE_COUNT = 3
+const ACTION_PREFIX = "__starpilot_favorite_action__:"
+
+function sortOptions(options) {
+ return (options || []).slice().sort((a, b) =>
+ String(a?.label || a?.key || "").localeCompare(String(b?.label || b?.key || ""), undefined, { numeric: true, sensitivity: "base" })
+ )
+}
+
+function defaultSlots() {
+ return [0, 1, 2].map(() => ({ enabled: false, show_onroad: false, key: null, label: "" }))
+}
+
+function normalizeSlots(slots) {
+ const base = defaultSlots()
+ if (!Array.isArray(slots)) return base
+ slots.slice(0, FAVORITE_COUNT).forEach((slot, index) => {
+ if (!slot || typeof slot !== "object") return
+ const key = slot.key ? String(slot.key) : null
+ base[index] = {
+ enabled: !!slot.enabled,
+ show_onroad: !!slot.show_onroad,
+ key,
+ label: key ? String(slot.label || key) : "",
+ }
+ })
+ return base
+}
+
+export const FavoritesEditor = {
+ name: "FavoritesEditor",
+ data() {
+ return {
+ loading: true,
+ saving: false,
+ slots: [],
+ options: [],
+ values: {},
+ filters: ["", "", ""],
+ }
+ },
+ computed: {
+ optionByKey() { return new Map(this.options.map((o) => [o.key, o])) },
+ quickFavorites() {
+ return this.slots
+ .map((slot, index) => {
+ const opt = this.optionByKey.get(slot.key || "")
+ return { index, slot, opt, checked: !!(slot.key && opt && !!this.values[slot.key]) }
+ })
+ .filter((f) => f.slot.enabled && f.slot.key && f.opt)
+ },
+ },
+ methods: {
+ normalizeSlots,
+ filteredOptions(index) {
+ const q = (this.filters[index] || "").toLowerCase()
+ return this.options.filter((o) =>
+ !q || [o.label, o.key, o.section, o.description].some((v) => String(v || "").toLowerCase().includes(q))
+ )
+ },
+ isActionSlot(slot) {
+ const opt = this.optionByKey.get(slot.key || "")
+ return String(slot.key || "").startsWith(ACTION_PREFIX) || !!opt?.action
+ },
+ async load() {
+ this.loading = true
+ try {
+ const data = await api.getFavoritesSlots()
+ this.options = sortOptions(data?.options)
+ this.slots = normalizeSlots(data?.slots)
+ this.values = { ...this.values, ...(data?.values || {}) }
+ } catch (e) {
+ showSnackbar("Failed to load favorite slots.", "error")
+ } finally {
+ this.loading = false
+ }
+ },
+ async saveSlots() {
+ if (this.saving) return
+ this.saving = true
+ try {
+ const data = await api.saveFavoritesSlots(this.slots)
+ this.slots = normalizeSlots(data?.slots)
+ if (Array.isArray(data?.options)) this.options = sortOptions(data.options)
+ if (data?.values) this.values = { ...this.values, ...data.values }
+ showSnackbar(data?.message || "Favorite slots saved.")
+ } catch (e) {
+ showSnackbar(e?.message || "Failed to save favorite slots.", "error")
+ } finally {
+ this.saving = false
+ }
+ },
+ updateSlot(index, patch) {
+ const slots = this.slots.slice()
+ slots[index] = { ...slots[index], ...patch }
+ if (!slots[index].key) {
+ slots[index].label = ""
+ } else {
+ slots[index].label = this.optionByKey.get(slots[index].key)?.label || slots[index].key
+ }
+ this.slots = slots
+ this.saveSlots()
+ },
+ async toggleValue(key, checked) {
+ const previous = this.values[key]
+ this.values = { ...this.values, [key]: checked }
+ try {
+ const data = await api.updateParam({ key, value: checked })
+ if (data?.updated && typeof data.updated === "object") this.values = { ...this.values, ...data.updated }
+ showSnackbar(data?.message || `Parameter '${key}' updated.`)
+ } catch (e) {
+ this.values = { ...this.values, [key]: previous }
+ showSnackbar(e?.message || "Network error — is the device reachable?", "error")
+ }
+ },
+ async runAction(key) {
+ try {
+ const data = await api.activateFavoriteAction(key)
+ showSnackbar(data?.message || "Favorite action sent.")
+ } catch (e) {
+ showSnackbar(e?.message || "Failed to send favorite action.", "error")
+ }
+ },
+ },
+ async mounted() { await this.load() },
+ template: `
+
+
Loading favorite slots...
+
+
+
+
+ Favorite #{{ f.index + 1 }}
+ {{ f.opt.label || f.slot.key }}
+ {{ f.opt.section || '' }}
+
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js
new file mode 100644
index 0000000000..b3c13b949c
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyEmbed.js
@@ -0,0 +1,82 @@
+// Single source of truth for page-in-page embeds of the classic Galaxy SPA.
+// Every embed (ToolEmbed, Home dashboard, Tuning, Navigation maps/keys/speeds,
+// SystemTools toggles, Logs troubleshoot) renders through this component so the
+// classic page always gets: an `embedded=1` marker, sidebar-hide styles, and
+// optional navigation forwarding back to the mobile app.
+export const GalaxyEmbed = {
+ name: "GalaxyEmbed",
+ props: {
+ src: { type: String, required: true },
+ title: { type: String, default: "Tool" },
+ // When true, injects a bridge that forwards the classic page's internal
+ // navigation to the mobile app (which opens it as a proper ToolEmbed page).
+ forwardNav: { type: Boolean, default: false },
+ },
+ data() {
+ return {
+ embedStyle: `
+ #sidebar, #sidebar_shell, #sidebarUnderlay { display: none !important; }
+ #menu_button { display: none !important; }
+ .content { margin-left: 0 !important; }
+ body { padding-left: 0 !important; }
+ `,
+ }
+ },
+ computed: {
+ frameSrc() {
+ const base = this.src
+ return base + (base.includes("?") ? "&" : "?") + "embedded=1"
+ },
+ },
+ methods: {
+ injectEmbedStyles() {
+ const frame = this.$refs.frame
+ if (!frame) return
+ try {
+ const doc = frame.contentDocument || frame.contentWindow?.document
+ if (!doc || !doc.head) return
+ let style = doc.getElementById("gx-embed-hide-sidebar")
+ if (!style) {
+ style = doc.createElement("style")
+ style.id = "gx-embed-hide-sidebar"
+ doc.head.appendChild(style)
+ }
+ style.textContent = this.embedStyle
+
+ if (!this.forwardNav) return
+ let bridge = doc.getElementById("gx-embed-nav-bridge")
+ if (!bridge) {
+ bridge = doc.createElement("script")
+ bridge.id = "gx-embed-nav-bridge"
+ bridge.textContent = `(() => {
+ const post = () => {
+ if (window.self === window.top) return
+ const params = new URLSearchParams(window.location.search)
+ params.delete("embedded")
+ const qs = params.toString()
+ window.parent.postMessage({ source: "galaxy-embed", path: window.location.pathname + (qs ? "?" + qs : "") }, "*")
+ }
+ const patch = (type) => {
+ const orig = history[type]
+ history[type] = function () { const r = orig.apply(this, arguments); post(); return r }
+ }
+ patch("pushState")
+ patch("replaceState")
+ window.addEventListener("popstate", post)
+ })()`
+ doc.head.appendChild(bridge)
+ }
+ } catch (e) {
+ }
+ },
+ },
+ mounted() {
+ this.$refs.frame?.addEventListener("load", () => this.injectEmbedStyles())
+ },
+ template: `
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js
new file mode 100644
index 0000000000..368dfce1b6
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyModal.js
@@ -0,0 +1,63 @@
+export const GalaxyModal = {
+ name: "GalaxyModal",
+ props: {
+ modelValue: { type: Boolean, default: false },
+ title: { type: String, default: "Are you sure?" },
+ message: { type: String, default: "" },
+ confirmLabel: { type: String, default: "Confirm" },
+ cancelLabel: { type: String, default: "Cancel" },
+ danger: { type: Boolean, default: false },
+ sheet: { type: Boolean, default: true },
+ },
+ emits: ["update:modelValue", "confirm", "cancel"],
+ methods: {
+ close() { this.$emit("update:modelValue", false) },
+ cancel() { this.close(); this.$emit("cancel") },
+ confirm() { this.$emit("confirm"); this.close() },
+ },
+ template: `
+
+
+
+
+
{{ title }}
+
{{ message }}
+
+
+
+
+
+
+
+
+ `,
+}
+
+export function GalaxyConfirm({ title, message, confirmLabel = "Confirm", danger = false } = {}) {
+ return new Promise((resolve) => {
+ const host = document.createElement("div")
+ document.body.appendChild(host)
+ const { createApp, h } = window.__galaxyVue
+ let instance
+ const app = createApp({
+ render() {
+ return h(GalaxyModal, {
+ modelValue: true,
+ title,
+ message,
+ confirmLabel,
+ danger,
+ "onUpdate:modelValue": (v) => { if (!v) teardown() },
+ onConfirm: () => { teardown(); resolve(true) },
+ onCancel: () => { teardown(); resolve(false) },
+ })
+ },
+ })
+ const teardown = () => {
+ app.unmount()
+ host.remove()
+ resolve(false)
+ }
+ instance = app.mount(host)
+ })
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js
new file mode 100644
index 0000000000..815f29450a
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxySection.js
@@ -0,0 +1,25 @@
+export const GalaxySection = {
+ name: "GalaxySection",
+ props: {
+ title: { type: String, required: true },
+ icon: { type: String, default: "bi-toggles" },
+ count: { type: [Number, String], default: "" },
+ defaultOpen: { type: Boolean, default: true },
+ },
+ data() { return { open: this.defaultOpen } },
+ template: `
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js
new file mode 100644
index 0000000000..5fd48954f6
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js
@@ -0,0 +1,226 @@
+import { api, showSnackbar } from "../api.js"
+import {
+ coerceValueByType, formatSliderValue, formatReadoutValue, getColorDefault,
+ normalizeHexColor, numericBounds, numericEpsilon, snapNumericToBoundsAndStep,
+ stepPrecision,
+} from "../params.js"
+import { FavoritesEditor } from "./FavoritesEditor.js"
+
+export const GalaxyToggleCard = {
+ name: "GalaxyToggleCard",
+ components: { FavoritesEditor },
+ props: {
+ param: { type: Object, required: true },
+ value: { default: undefined },
+ locked: { type: Boolean, default: false },
+ manageable: { type: Boolean, default: false },
+ manageOpen: { type: Boolean, default: false },
+ },
+ emits: ["change", "manage"],
+ data() {
+ return {
+ updating: false,
+ endpointOptions: null,
+ optionsLoaded: false,
+ endpointLoading: false,
+ preview: undefined,
+ interacting: false,
+ }
+ },
+ computed: {
+ bounds() { return numericBounds(this.param, {}) },
+ precision() { return stepPrecision(this.bounds.step, this.param.precision) },
+ epsilon() { return numericEpsilon(this.precision) },
+ isSlider() { return this.isNumeric },
+ isNumeric() { return this.param.ui_type === "numeric" },
+ isReadout() { return this.param.ui_type === "readout" },
+ isGroup() { return this.param.ui_type === "group" },
+ currentValue() { return this.preview !== undefined ? this.preview : this.value },
+ displayValue() {
+ if (this.isColor) return normalizeHexColor(this.value) ? normalizeHexColor(this.value).toUpperCase() : "Stock"
+ if (this.isReadout) return formatReadoutValue(this.param, this.value)
+ return this.value !== undefined && this.value !== null ? formatSliderValue(this.value, String(this.bounds.step), this.param.precision, this.param.key) : ".."
+ },
+ sliderDisplay() {
+ return this.value !== undefined ? formatSliderValue(this.currentValue, String(this.bounds.step), this.param.precision, this.param.key) : ".."
+ },
+ isColor() { return this.param.ui_type === "color" },
+ isAction() { return this.param.ui_type === "action" },
+ isFavorites() { return this.param.ui_type === "favorites" },
+ isText() { return this.param.ui_type === "text" },
+ isSelect() { return this.param.ui_type === "dropdown" },
+ isSwitch() { return !this.isNumeric && !this.isColor && !this.isAction && !this.isFavorites && !this.isGroup && !this.isReadout && !this.isSelect && !this.isText },
+ selectOptions() {
+ return this.param.options || this.endpointOptions || []
+ },
+ optionsLoading() {
+ return Boolean(this.param.options_endpoint) && this.endpointLoading
+ },
+ },
+ methods: {
+ normalizeHexColor,
+ getColorDefault,
+ coerce(v) { return coerceValueByType(v, this.param.data_type) },
+ labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" },
+ rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) },
+ async commit(nextValue) {
+ const prev = this.value
+ const label = this.lastLabel || ""
+ this.$emit("change", { key: this.param.key, value: nextValue })
+ this.updating = true
+ try {
+ const data = await api.updateParam({ key: this.param.key, value: nextValue, label })
+ const updated = data?.updated && typeof data.updated === "object" ? data.updated : {}
+ if (Object.prototype.hasOwnProperty.call(updated, this.param.key)) {
+ this.$emit("change", { key: this.param.key, value: updated[this.param.key], ...updated })
+ }
+ showSnackbar(data?.message || `Parameter '${this.param.key}' updated.`)
+ } catch (err) {
+ this.rollback(prev)
+ showSnackbar(err?.message || "Network error — is the device reachable?", "error")
+ } finally {
+ this.updating = false
+ }
+ },
+ onSwitch(e) {
+ if (!this.locked) this.commit(!!e.target.checked)
+ else e.target.checked = !!this.value
+ },
+ onSelect(e) {
+ if (this.locked) { e.target.value = String(this.value ?? "") ; return }
+ this.lastLabel = e.target.options?.[e.target.selectedIndex]?.textContent || ""
+ this.commit(this.coerce(e.target.value))
+ },
+ onText(e) {
+ if (!this.locked) this.commit(this.coerce(e.target.value))
+ },
+ onColor(e) {
+ if (this.locked) return
+ this.commit(normalizeHexColor(e.target.value) || getColorDefault(this.param))
+ },
+ beginInteract() { this.interacting = true },
+ flushSlider(rawValue) {
+ const next = snapNumericToBoundsAndStep(rawValue, this.bounds, this.precision)
+ this.preview = undefined
+ if (next === null) return
+ const current = this.snap(this.value)
+ if (Math.abs(next - current) <= this.epsilon) return
+ this.commit(next)
+ },
+ onSliderInput(e) {
+ this.beginInteract()
+ this.preview = Number(e.target.value)
+ },
+ onSliderCommit(e) {
+ this.interacting = false
+ this.flushSlider(e.target.value)
+ },
+ onSliderBlur(e) {
+ if (this.interacting) this.onSliderCommit(e)
+ },
+ snap(raw) {
+ return snapNumericToBoundsAndStep(raw, this.bounds, this.precision)
+ },
+ async resetToDefault() {
+ const defaults = await api.getDefaults()
+ const stockKey = `${this.param.key}Stock`
+ const stock = defaults?.[stockKey]
+ const raw = stock !== undefined && stock !== null ? stock : defaults?.[this.param.key]
+ const next = this.snap(raw)
+ if (next === null) { showSnackbar("No default value available for this setting.", "error"); return }
+ if (Math.abs(next - (this.snap(this.value) ?? 0)) <= this.epsilon) return
+ this.commit(next)
+ },
+ resetColor() {
+ if (normalizeHexColor(this.value) === "") return
+ this.commit("stock")
+ },
+ runAction() {
+ if (this.locked || this.updating) return
+ this.updating = true
+ api.postAction(String(this.param.action_endpoint || ""))
+ .then((data) => {
+ if (!data?.error) {
+ showSnackbar(data?.message || `${this.param.label || this.param.key} completed.`)
+ if (data?.updated && typeof data.updated === "object") this.$emit("change", data.updated)
+ } else {
+ showSnackbar(data.error, "error")
+ }
+ })
+ .catch(() => showSnackbar(`${this.param.label || this.param.key} failed.`, "error"))
+ .finally(() => { this.updating = false })
+ },
+ loadEndpointOptions() {
+ if (!this.param.options_endpoint || this.optionsLoaded) return
+ this.optionsLoaded = true
+ this.endpointLoading = true
+ api.getOptions(this.param.options_endpoint)
+ .then((opts) => { this.endpointOptions = opts })
+ .catch(() => { this.endpointOptions = [] })
+ .finally(() => { this.endpointLoading = false })
+ },
+ },
+ mounted() {
+ if (this.param.options_endpoint) this.loadEndpointOptions()
+ },
+ template: `
+
+
+
+
{{ param.label }}
+ Advanced
+
+
{{ param.description }}
+
Locked: This setting can only be changed while parked.
+
+
+
+
+
+
+
+
+
+ {{ sliderDisplay }}
+
+
+
+
+
+
+
+
+
+ {{ displayValue }}
+
+
+
+
+
{{ displayValue }}
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js b/starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js
new file mode 100644
index 0000000000..235d47fb7e
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/ManeuverCard.js
@@ -0,0 +1,89 @@
+import { usePolling, formatAgeSeconds } from "../composables.js"
+import { showSnackbar } from "../api.js"
+
+function safeNumber(value, fallback = 0) {
+ const n = Number(value)
+ return Number.isFinite(n) ? n : fallback
+}
+
+export const ManeuverCard = {
+ name: "ManeuverCard",
+ props: {
+ title: { type: String, required: true },
+ icon: { type: String, default: "bi-sign-turn-right" },
+ intro: { type: String, default: "" },
+ start: { type: Function, required: true },
+ stop: { type: Function, required: true },
+ status: { type: Function, required: true },
+ interval: { type: Number, default: 3000 },
+ },
+ data() {
+ return { loading: true, busy: false, data: null }
+ },
+ created() {
+ this.poll = usePolling(() => this.refreshStatus(), { interval: this.interval })
+ this.poll.start()
+ },
+ beforeUnmount() { this.poll?.destroy() },
+ methods: {
+ formatAgeSeconds,
+ safeNumber,
+ async refreshStatus() {
+ try {
+ const payload = await this.status()
+ this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : null
+ this.loading = false
+ } catch (e) {
+ this.loading = false
+ throw e
+ }
+ },
+ async run(action) {
+ if (this.busy) return
+ this.busy = true
+ try {
+ const fn = action === "start" ? this.start : this.stop
+ const payload = await fn()
+ this.data = payload && typeof payload === "object" ? { ...payload, history: Array.isArray(payload.history) ? payload.history : [] } : this.data
+ showSnackbar(payload?.message || "Action complete.")
+ } catch (e) {
+ showSnackbar(e?.message || "Action failed.", "error")
+ } finally {
+ this.busy = false
+ }
+ },
+ },
+ template: `
+
+
+
+
{{ intro }}
+
+
+
+
+
Loading status...
+
+ Mode{{ data.modeEnabled ? 'Yes' : 'No' }}
+ State{{ data.state || 'idle' }}
+ Onroad{{ data.isOnroad ? 'Yes' : 'No' }}
+ Engaged{{ data.isEngaged ? 'Yes' : 'No' }}
+ Phase{{ data.phase || 'n/a' }}
+ Step{{ safeNumber(data.stepIndex, 0) }}/{{ safeNumber(data.stepTotal, 0) }}
+ Run{{ safeNumber(data.runIndex, 0) }}/{{ safeNumber(data.runTotal, 0) }}
+ Updated{{ formatAgeSeconds(data.updatedAgeSec) }}
+ Current{{ data.maneuver || 'n/a' }}
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js b/starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js
new file mode 100644
index 0000000000..d94b8b2fcd
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/ParamSections.js
@@ -0,0 +1,66 @@
+import { api } from "../api.js"
+import { isSettingVisible, slugifySectionName, applyParamChange } from "../params.js"
+import { SettingTree } from "./SettingTree.js"
+import { GalaxySection } from "./GalaxySection.js"
+
+export const ParamSections = {
+ name: "ParamSections",
+ components: { SettingTree, GalaxySection },
+ props: {
+ sectionNames: { type: Array, required: true },
+ search: { type: String, default: "" },
+ },
+ data() {
+ return {
+ layout: [],
+ values: {},
+ expanded: {},
+ loading: true,
+ error: "",
+ }
+ },
+ computed: {
+ sections() {
+ return this.layout
+ .filter((s) => this.sectionNames.includes(s.name))
+ .map((s) => ({
+ ...s,
+ params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values) && this.matches(p)),
+ slug: slugifySectionName(s.name),
+ }))
+ .filter((s) => s.params.length > 0)
+ },
+ },
+ methods: {
+ matches(p) {
+ if (!this.search) return true
+ const q = this.search.toLowerCase()
+ return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
+ },
+ async load() {
+ try {
+ const [layout, values] = await Promise.all([api.getLayout(), api.getParams()])
+ this.layout = layout
+ this.values = values || {}
+ } catch (e) {
+ this.error = e?.message || "Failed to load settings."
+ } finally {
+ this.loading = false
+ }
+ },
+ onParamChange(patch) { this.values = applyParamChange(this.values, patch) },
+ toggleManage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } },
+ },
+ async mounted() { await this.load() },
+ template: `
+
+
Loading configuration...
+
{{ error }}
+
+
+ No settings in this section.
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js b/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js
new file mode 100644
index 0000000000..7d32b8915c
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/SettingTree.js
@@ -0,0 +1,46 @@
+import { GalaxyToggleCard } from "./GalaxyToggleCard.js"
+import { hasChildParams, isGroupParam, isParamEnabledForChildren } from "../params.js"
+
+export const SettingTree = {
+ name: "SettingTree",
+ components: { GalaxyToggleCard },
+ props: {
+ params: { type: Array, required: true },
+ parentKey: { default: null },
+ depth: { type: Number, default: 0 },
+ values: { type: Object, required: true },
+ expanded: { type: Object, default: () => ({}) },
+ lockReason: { type: Function, default: () => "" },
+ },
+ emits: ["change", "manage"],
+ computed: {
+ children() {
+ return this.params.filter((p) => (p.parent_key || null) === this.parentKey)
+ },
+ },
+ methods: {
+ enabledForChildren(p) { return isParamEnabledForChildren(p, this.values) },
+ isParent(p) { return hasChildParams(this.params, p.key) },
+ isGroup(p) { return isGroupParam(p) },
+ isExpanded(p) { return !!this.expanded[p.key] },
+ showChildren(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
+ manageable(p) { return this.isParent(p) && this.enabledForChildren(p) },
+ manageOpen(p) { return this.isParent(p) && this.enabledForChildren(p) && this.isExpanded(p) },
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js b/starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js
new file mode 100644
index 0000000000..a1a063b603
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/WheelControls.js
@@ -0,0 +1,165 @@
+import { api } from "../api.js"
+import { usePolling } from "../composables.js"
+
+const FAVORITE_SLOT_COUNT = 3
+
+export const WheelControls = {
+ name: "WheelControls",
+ data() {
+ return {
+ loading: true, busy: "", available: false, offroad: false, learning: false,
+ devices: [], mappings: [], slots: [], controllerSlots: [], controllerOptions: [],
+ joystickDevice: "", learningSlot: null, remainingSeconds: 0, testing: false,
+ lastTested: null, speedUnit: "mph", speedMinimum: 0, speedMaximum: 0, error: "",
+ }
+ },
+ created() { this.poll = usePolling(() => this.refresh(), { interval: 750 }); this.poll.start() },
+ beforeUnmount() { this.poll?.destroy() },
+ methods: {
+ async refresh() {
+ try {
+ const p = await api.getWheelControlsStatus()
+ this.available = !!p.available
+ this.offroad = !!p.offroad
+ this.learning = !!p.learning
+ this.devices = Array.isArray(p.devices) ? p.devices : []
+ this.mappings = Array.isArray(p.mappings) ? p.mappings : []
+ this.slots = Array.isArray(p.slots) ? p.slots : []
+ this.controllerSlots = Array.isArray(p.controller_slots) ? p.controller_slots : []
+ this.controllerOptions = Array.isArray(p.controller_options) ? p.controller_options : []
+ this.joystickDevice = typeof p.joystick_device === "string" ? p.joystick_device : ""
+ this.learningSlot = Number.isInteger(p.learning_slot) ? p.learning_slot : null
+ this.remainingSeconds = Number.isFinite(Number(p.remaining_seconds)) ? Number(p.remaining_seconds) : 0
+ this.testing = !!p.testing
+ this.lastTested = p.last_tested && typeof p.last_tested === "object" ? p.last_tested : null
+ this.speedUnit = String(p.speed_unit || "mph")
+ this.speedMinimum = Number.isFinite(Number(p.speed_minimum)) ? Number(p.speed_minimum) : 0
+ this.speedMaximum = Number.isFinite(Number(p.speed_maximum)) ? Number(p.speed_maximum) : 0
+ this.error = ""
+ } catch (e) {
+ this.available = false
+ this.error = e?.message || "Wheel controls are unavailable"
+ } finally {
+ this.loading = false
+ }
+ },
+ async request(operation, body = {}) {
+ if (this.busy) return
+ this.busy = operation
+ try {
+ await api.wheelControlsOp(operation, body)
+ this.error = ""
+ await this.refresh()
+ } catch (e) {
+ this.error = e?.message || "Wheel control operation failed"
+ } finally {
+ this.busy = ""
+ }
+ },
+ mappingsOf(slot) { return this.mappings.filter((m) => m.slot === slot) },
+ actionSlotIndex(i) { return FAVORITE_SLOT_COUNT + i },
+ learn(slot) { this.request(this.learningAt(slot) ? "cancel" : "learn", { slot }) },
+ learningAt(slot) { return !!this.learning && this.learningSlot === slot },
+ disabled() { return !this.offroad || !!this.busy },
+ configured(slot) { return !!slot?.enabled && !!slot?.key },
+ optionByKey(key) { return this.controllerOptions.find((o) => o.key === key) || null },
+ isSpeedSlot(slot) { return this.optionByKey(slot?.key)?.value_type === "speed" },
+ onActionSelect(i, e) {
+ if (this.disabled()) return
+ const key = String(e.target.value || "")
+ const option = this.optionByKey(key)
+ const value = option?.value_type === "speed"
+ ? Number(this.controllerSlots[i]?.value ?? option.default_value ?? 30)
+ : null
+ this.request("action", { slot: i, key, value })
+ },
+ onSpeedChange(i, e) {
+ if (this.disabled()) return
+ const key = String(this.controllerSlots[i]?.key || "")
+ const value = Number(e.target.value)
+ if (!Number.isFinite(value)) return
+ this.request("action", { slot: i, key, value })
+ },
+ listenLabel(slot) {
+ if (!this.learningAt(slot)) return "Learn Button"
+ const seconds = Math.max(0, Math.ceil(this.remainingSeconds))
+ return seconds > 0 ? `Listening (${seconds}s)` : "Listening..."
+ },
+ },
+ template: `
+
+
+
Mappings can only be changed while offroad. Mapped buttons continue working onroad.
+
{{ error }}
+
The wheel control service is starting.
+
+
+
+
+
+
{{ lastTested.mapped ? 'Successful' : 'Not mapped' }}
+
{{ lastTested.event_name || ('Button ' + lastTested.event_code) }} on {{ lastTested.device_name || 'External input' }} {{ lastTested.mapped ? 'is mapped to slot ' + lastTested.slot : 'has no mapping' }}.
+
+
+
Connected input devices
+
Favorite buttons are the default, with controller-only actions below. Only the selected gamepad controls Joystick Mode.
+
+
+
+ {{ d.name }}
+ {{ d.joystick_capable ? 'Buttons and joystick axes' : 'Buttons only' }}
+
+
+
+
+
Connect or pair a controller, macropad, or keyboard.
+
+
On-screen Favorites
+
+
+
+ Favorite #{{ i + 1 }}
+ {{ configured(slot) ? (slot.label || slot.key) : 'Not configured' }}
+
+
+
{{ mappingsOf(i).map(m => m.event_name).join(', ') }}
+
+
+
Choose and enable these slots in Toggles to map buttons to them.
+
+
Controller-only Actions
+
Ten additional actions for physical buttons. These never appear as on-screen Favorites.
+
+
+
+
+ Controller Action #{{ i + 1 }}
+ {{ slot.enabled ? (slot.label || 'Configured') : 'Not configured' }}
+
+
+
+
+
+
+ Set speed ({{ speedUnit }})
+
+
+
+
Press one button on your controller, macropad, or keyboard.
+
+ {{ m.event_name || ('Button ' + m.event_code) }}
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/composables.js b/starpilot/system/the_galaxy/assets/mobile/js/composables.js
new file mode 100644
index 0000000000..648d51a09b
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/composables.js
@@ -0,0 +1,100 @@
+import { computed, reactive } from "vue"
+import { showSnackbar } from "./api.js"
+import { navigate, store } from "./store.js"
+
+export function useTabRouting(basePath, tabs) {
+ const tab = computed(() => {
+ const prefix = basePath + "/"
+ const slug = store.route.startsWith(prefix)
+ ? store.route.slice(prefix.length).split("/")[0]
+ : ""
+ for (const [key, s] of Object.entries(tabs)) {
+ if (s === slug) return key
+ }
+ return Object.keys(tabs)[0]
+ })
+
+ function selectTab(key) {
+ const slug = tabs[key]
+ if (slug === undefined) return
+ const href = slug ? `${basePath}/${slug}` : basePath
+ if (href !== store.route) navigate(href)
+ }
+
+ return { tab, selectTab }
+}
+
+export function usePolling(fn, { interval = 3000, enabled = () => true } = {}) {
+ const state = reactive({ running: false, lastError: "", lastErrorAt: 0 })
+ let timer = null
+ let destroyed = false
+
+ const stop = () => { if (timer) { clearTimeout(timer); timer = null } }
+ const tick = async () => {
+ if (destroyed || !enabled() || document.visibilityState !== "visible") {
+ timer = setTimeout(tick, interval)
+ return
+ }
+ try {
+ await fn()
+ state.lastError = ""
+ } catch (e) {
+ state.lastError = e?.message || String(e)
+ state.lastErrorAt = Date.now()
+ }
+ if (!destroyed) timer = setTimeout(tick, interval)
+ }
+ const start = () => { stop(); timer = setTimeout(tick, 0) }
+ const destroy = () => { destroyed = true; stop() }
+ return { state, start, stop, destroy }
+}
+
+export function useLogStream({ endpoint, snapshotFn, interval = 2000 } = {}) {
+ const state = reactive({ log: "", latest: "", paused: false, transport: "idle" })
+ let es = null
+ let timer = null
+ let destroyed = false
+
+ const apply = (data) => {
+ state.latest = data || ""
+ if (!state.paused) state.log = state.latest
+ }
+ const snapshotFetch = async () => {
+ if (!snapshotFn) return
+ try { apply((await snapshotFn())?.data || "") } catch (e) { }
+ }
+ const stopStream = () => { if (es) { es.close(); es = null } }
+ const stopPolling = () => { if (timer) { clearInterval(timer); timer = null } }
+ const startPolling = () => {
+ stopStream()
+ state.transport = "polling"
+ snapshotFetch()
+ timer = setInterval(() => { if (!destroyed) snapshotFetch() }, interval)
+ }
+ const startStream = () => {
+ stopPolling()
+ if (!endpoint) return startPolling()
+ state.transport = "streaming"
+ es = new EventSource(endpoint)
+ es.onmessage = (e) => apply(e.data)
+ es.onerror = () => { if (snapshotFn) startPolling() }
+ }
+ const start = () => (snapshotFn ? startPolling() : startStream())
+ const destroy = () => { destroyed = true; stopStream(); stopPolling() }
+ const togglePause = () => {
+ state.paused = !state.paused
+ if (!state.paused) state.log = state.latest
+ }
+ const notify = (message, level) => showSnackbar(message, level)
+ return { state, start, destroy, togglePause, notify }
+}
+
+export function formatAgeSeconds(value) {
+ const sec = Number(value)
+ if (!Number.isFinite(sec) || sec < 0) return "unknown"
+ if (sec < 1) return "just now"
+ if (sec < 60) return `${Math.round(sec)}s ago`
+ const min = sec / 60
+ if (min < 60) return `${Math.round(min)}m ago`
+ return `${Math.round(min / 60)}h ago`
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/params.js b/starpilot/system/the_galaxy/assets/mobile/js/params.js
new file mode 100644
index 0000000000..0c76e85909
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/params.js
@@ -0,0 +1,269 @@
+export const GALAXY_DEVELOPER_MODE_KEY = "GalaxyDeveloperMode"
+
+const HIDDEN_SETTING_KEYS = new Set(["HumanAcceleration"])
+const RADAR_REQUIRED_KEYS = new Set(["HumanLaneChanges", "RadarTakeoffs"])
+const VEHICLE_SETTING_MAKES = {
+ RivianAngleControl: ["Rivian"],
+ TeslaCoopSteering: ["Tesla"],
+ NAPRadarEnabled: ["Tesla"],
+ NAPRadarBehindNosecone: ["Tesla"],
+ NAPRadarOffset: ["Tesla"],
+ NAPPedalEnabled: ["Tesla"],
+ NAPPedalCanBus: ["Tesla"],
+ NAPAdaptiveAccel: ["Tesla"],
+ NAPPedalCalibDone: ["Tesla"],
+ NAPPedalCalibFactor: ["Tesla"],
+ NAPPedalCalibZero: ["Tesla"],
+ GMPedalLongitudinal: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
+ GMDashSpoofOffsets: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
+ IgnoreIgnitionLine: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
+ LongPitch: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
+ RemoteStartBootsComma: ["Buick", "Cadillac", "Chevrolet", "GMC", "Holden"],
+ HKGRemoteStartBootsComma: ["Genesis", "Hyundai", "Kia"],
+ VoltSNG: ["Chevrolet", "Holden"],
+ GMAutoHold: ["Chevrolet", "Holden"],
+ VoltOnePedalMode: ["Chevrolet", "Holden"],
+ RemapCancelToDistance: ["Chevrolet", "Holden"],
+ JeepBrakeHold: ["Jeep"],
+ SubaruSNG: ["Subaru"],
+ SubaruSNGManualParkingBrake: ["Subaru"],
+ SubaruStopStartOff: ["Subaru"],
+ SubaruAvhOnAtStartup: ["Subaru"],
+ ClusterOffset: ["Lexus", "Toyota"],
+ SNGHack: ["Lexus", "Toyota"],
+ ToyotaAutoHold: ["Lexus", "Toyota"],
+}
+
+export function normalizeVehicleMake(value) {
+ return String(value ?? "").trim().toLowerCase()
+}
+
+export function isVehicleSettingVisible(section, param, values) {
+ const allowedMakes = param.vehicle_makes || (section.name === "Vehicle" ? VEHICLE_SETTING_MAKES[param.key] : null)
+ if (!allowedMakes) return true
+ const selectedMake = normalizeVehicleMake(values.CarMake)
+ return allowedMakes.some((make) => normalizeVehicleMake(make) === selectedMake)
+}
+
+function toSelectValue(value) {
+ return value === null || value === undefined ? "" : String(value)
+}
+
+export function matchesSettingValueCondition(param, values) {
+ if (!param.visible_when_key) return true
+ const allowedValues = Array.isArray(param.visible_when_values) ? param.visible_when_values : []
+ const currentValue = toSelectValue(values[param.visible_when_key])
+ return allowedValues.some((value) => toSelectValue(value) === currentValue)
+}
+
+export function isSettingVisible(section, param, values) {
+ if (HIDDEN_SETTING_KEYS.has(param.key) || !isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
+ if (param.requires_capability && !values[param.requires_capability]) return false
+ if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
+ if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
+ if (values[GALAXY_DEVELOPER_MODE_KEY]) return true
+ return section.name === "Favorites" || param.settings_tier === "simple"
+}
+
+export function isAdvancedHiddenByDeveloperMode(section, param, values) {
+ if (param.settings_tier !== "advanced") return false
+ if (HIDDEN_SETTING_KEYS.has(param.key)) return false
+ if (!isVehicleSettingVisible(section, param, values) || !matchesSettingValueCondition(param, values)) return false
+ if (param.requires_capability && !values[param.requires_capability]) return false
+ if (RADAR_REQUIRED_KEYS.has(param.key) && !values.HasRadar) return false
+ if (param.key === "AlphaLongitudinalEnabled" && !values.AlphaLongitudinalAvailable) return false
+ return true
+}
+
+export function countAdvancedHiddenByDeveloperMode(layout, values) {
+ if (values[GALAXY_DEVELOPER_MODE_KEY]) return 0
+ let count = 0
+ for (const section of layout) {
+ if (section.name === "Favorites") continue
+ for (const param of section.params || []) {
+ if (isAdvancedHiddenByDeveloperMode(section, param, values)) count++
+ }
+ }
+ return count
+}
+
+export function numericBounds(param, values) {
+ const defaultBounds = {
+ min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
+ max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
+ step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1),
+ }
+ const toFinite = (value) => {
+ const n = Number(value)
+ return Number.isFinite(n) ? n : null
+ }
+ if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") {
+ return { min: 1, max: 101, step: 1 }
+ }
+ if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
+ return { min: 25, max: 200, step: 1 }
+ }
+ if (param.key === "SteerKP") {
+ const base = toFinite(values?.SteerKPStock) || toFinite(values?.SteerKP) || 0.6
+ return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
+ }
+ if (param.key === "SteerLatAccel") {
+ const base = toFinite(values?.SteerLatAccelStock) || toFinite(values?.SteerLatAccel) || 2.0
+ return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 }
+ }
+ if (param.key === "SteerRatio") {
+ const base = toFinite(values?.SteerRatioStock) || toFinite(values?.SteerRatio) || 15.0
+ return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
+ }
+ return defaultBounds
+}
+
+export function stepPrecision(step, explicitPrecision) {
+ if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
+ const parsed = Number.parseInt(explicitPrecision, 10)
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed
+ }
+ const stepStr = String(step ?? "")
+ if (!stepStr.includes(".")) return 0
+ return stepStr.split(".")[1].length
+}
+
+export function numericEpsilon(precision) {
+ return Math.pow(10, -(precision + 2))
+}
+
+export function clampNumeric(value, min, max) {
+ return Math.min(max, Math.max(min, value))
+}
+
+export function snapNumericToBoundsAndStep(rawValue, bounds, precision) {
+ const min = Number(bounds.min)
+ const max = Number(bounds.max)
+ const step = Number(bounds.step)
+ const value = Number(rawValue)
+ if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null
+ const clamped = clampNumeric(value, min, max)
+ if (!Number.isFinite(step) || step <= 0) {
+ return clampNumeric(Number(clamped.toFixed(precision)), min, max)
+ }
+ const snapped = min + Math.round((clamped - min) / step) * step
+ return clampNumeric(Number(snapped.toFixed(precision)), min, max)
+}
+
+export function coerceValueByType(rawValue, dataType) {
+ if (dataType === "int") {
+ const n = Number.parseInt(rawValue, 10)
+ return Number.isFinite(n) ? n : rawValue
+ }
+ if (dataType === "float") {
+ const n = Number.parseFloat(rawValue)
+ return Number.isFinite(n) ? n : rawValue
+ }
+ return rawValue
+}
+
+export function formatSliderValue(val, stepStr, precisionInt, key) {
+ if (val === null || val === undefined) return "--"
+ const v = parseFloat(val)
+ if (Number.isNaN(v)) return val
+ if (key === "SwitchbackModeCooldown") {
+ if (v === 0) return "Off"
+ return v === 1 ? "1 min" : `${v} min`
+ }
+ if (key === "DeviceShutdown") {
+ return v === 1 ? "1 hour" : `${v} hours`
+ }
+ const volumeKeys = [
+ "BelowSteerSpeedVolume", "DisengageVolume", "EngageVolume", "PromptVolume",
+ "PromptDistractedVolume", "RefuseVolume", "WarningImmediateVolume", "WarningSoftVolume",
+ ]
+ if (key && volumeKeys.includes(key)) {
+ if (v === 0) return "Muted"
+ if (v === 101) return "Auto"
+ return `${v}%`
+ }
+ if (precisionInt !== undefined && precisionInt !== null) {
+ return Number(v.toFixed(precisionInt)).toString()
+ }
+ if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
+ const dec = stepStr.split(".")[1].length
+ return Number(v.toFixed(dec)).toString()
+}
+
+export function formatReadoutValue(p, value) {
+ const raw = value
+ const parsed = parseFloat(raw)
+ if (raw === undefined || raw === null || Number.isNaN(parsed)) return "--"
+ const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2
+ const formatted = Number(parsed.toFixed(Math.max(0, precision))).toString()
+ return p.unit ? `${formatted}${p.unit}` : formatted
+}
+
+export function normalizeHexColor(rawValue) {
+ const value = String(rawValue ?? "").trim()
+ if (!value || value.toLowerCase() === "stock") return ""
+ const stripped = value.startsWith("#") ? value.slice(1) : value
+ if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(stripped)) return ""
+ return `#${stripped.slice(0, 6).toLowerCase()}`
+}
+
+export function getColorDefault(param) {
+ const candidate = normalizeHexColor(param?.default_color)
+ if (candidate) return candidate
+ return { LaneLinesColor: "#00ff00", PathEdgesColor: "#00ff00", PathColor: "#30ff9c" }[param?.key] || "#ffffff"
+}
+
+export function slugifySectionName(name) {
+ return String(name || "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+}
+
+export function isGroupParam(param) {
+ return !!param && param.ui_type === "group"
+}
+
+export function applyParamChange(values, patch) {
+ const next = { ...(values || {}) }
+ if (!patch || typeof patch !== "object") return next
+ if ("key" in patch && "value" in patch) {
+ next[patch.key] = patch.value
+ for (const [k, v] of Object.entries(patch)) {
+ if (k !== "key" && k !== "value") next[k] = v
+ }
+ } else {
+ Object.assign(next, patch)
+ }
+ return next
+}
+
+export function isParamEnabledForChildren(paramOrKey, values) {
+ const param = typeof paramOrKey === "string" ? { key: paramOrKey } : paramOrKey
+ if (isGroupParam(param)) return true
+ return !!(param && param.key && values[param.key])
+}
+
+export function hasChildParams(paramsList, key) {
+ return (paramsList || []).some((param) => (param.parent_key || null) === key)
+}
+
+export function buildRenderTree(paramsList, values, expanded, isVisible) {
+ const out = []
+ const list = paramsList || []
+ const visible = isVisible || (() => true)
+
+ function walk(parentKey, depth) {
+ for (const param of list) {
+ if ((param.parent_key || null) !== parentKey) continue
+ if (!visible(param)) continue
+ out.push({ param, depth })
+ if (hasChildParams(list, param.key) && isParamEnabledForChildren(param, values) && expanded[param.key]) {
+ walk(param.key, depth + 1)
+ }
+ }
+ }
+
+ walk(null, 0)
+ return out
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/store.js b/starpilot/system/the_galaxy/assets/mobile/js/store.js
new file mode 100644
index 0000000000..1925ade368
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/store.js
@@ -0,0 +1,122 @@
+import { reactive } from "vue"
+
+const THEME_KEY = "galaxy-theme"
+
+function initialTheme() {
+ return localStorage.getItem(THEME_KEY) || "dark"
+}
+
+export const store = reactive({
+ route: "/",
+ params: {},
+ drawerOpen: false,
+ search: "",
+ snackbar: null,
+ online: false,
+ deviceStatus: "Parked",
+ history: ["/"],
+ theme: initialTheme(),
+})
+
+export function setTheme(theme) {
+ const next = theme === "light" ? "light" : "dark"
+ store.theme = next
+ document.documentElement.setAttribute("data-theme", next)
+ try { localStorage.setItem(THEME_KEY, next) } catch (e) {}
+}
+
+export function toggleTheme() {
+ setTheme(store.theme === "dark" ? "light" : "dark")
+}
+
+export function parseHash(hash) {
+ const raw = hash.replace(/^#/, "") || "/"
+ const [pathname, queryString] = raw.split("?")
+ const params = {}
+ if (queryString) {
+ for (const pair of queryString.split("&")) {
+ const [k, v] = pair.split("=")
+ if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || "")
+ }
+ }
+ return { path: pathname, params }
+}
+
+// Rebuild the canonical hash string for a route, preserving its query params.
+export function toHash(route) {
+ const { path, params } = parseHash(route)
+ const qs = Object.keys(params).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join("&")
+ return qs ? `${path}?${qs}` : path
+}
+
+function currentPath() {
+ return parseHash(window.location.hash).path
+}
+
+function pushIfNew(route) {
+ const last = store.history[store.history.length - 1]
+ if (last !== route) store.history.push(route)
+}
+
+function applyRoute(route, { scrollToTop = false } = {}) {
+ const { path, params } = parseHash(route)
+ const pathChanged = path !== store.route
+ store.route = path
+ store.params = params
+ store.drawerOpen = false
+ // Only jump to the top for real view changes. In-place hash updates (a Manage
+ // panel opening under the same section, an embed switching src) must not yank
+ // the reader back to the top of the page.
+ if (scrollToTop || pathChanged) window.scrollTo(0, 0)
+}
+
+export function navigate(target) {
+ const { path } = parseHash(target)
+ if (path === currentPath()) {
+ applyRoute(target)
+ window.location.hash = toHash(target)
+ return
+ }
+ pushIfNew(path)
+ applyRoute(target)
+ window.location.hash = toHash(target)
+}
+
+export function goHome() {
+ navigate("/")
+}
+
+export function goBack() {
+ const current = currentPath()
+ if (store.history[store.history.length - 1] === current && store.history.length > 1) {
+ store.history.pop()
+ }
+ const prev = store.history[store.history.length - 1] || "/"
+ applyRoute(prev, { scrollToTop: true })
+ window.location.hash = prev
+}
+
+const NATIVE_ROOTS = new Set(["/", "/settings", "/tools", "/recordings", "/logs", "/tuning", "/navigation", "/vehicle", "/system", "/embed"])
+
+export function toolHref(link) {
+ const path = link.split("?")[0]
+ if (NATIVE_ROOTS.has(path) || path.startsWith("/settings/") || path.startsWith("/embed")) return link
+ return "/embed?src=" + encodeURIComponent(path)
+}
+
+export function initRouter() {
+ const apply = () => {
+ const route = (window.location.hash || "").replace(/^#/, "") || "/"
+ const { path, params } = parseHash(route)
+ const pathChanged = path !== store.route
+ store.route = path
+ store.params = params
+ store.drawerOpen = false
+ pushIfNew(route)
+ if (pathChanged) window.scrollTo(0, 0)
+ }
+ window.addEventListener("hashchange", apply)
+ store.history = ["/"]
+ apply()
+ setTheme(store.theme)
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Home.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Home.js
new file mode 100644
index 0000000000..126cbe94fe
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Home.js
@@ -0,0 +1,11 @@
+import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
+
+export const Home = {
+ name: "Home",
+ components: { GalaxyEmbed },
+ template: `
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js
new file mode 100644
index 0000000000..b8ba239cba
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js
@@ -0,0 +1,246 @@
+import { api, showSnackbar } from "../api.js"
+import { useLogStream } from "../composables.js"
+import { GalaxyConfirm } from "../components/GalaxyModal.js"
+import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
+
+function parseLogDate(filename) {
+ const m = filename.match(/(\d{4})-(\d{2})-(\d{2})[T_]?(\d{2})-?(\d{2})-?(\d{2})?/)
+ if (!m) return new Date()
+ const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0))
+ return isNaN(date.getTime()) ? new Date() : date
+}
+
+export const Logs = {
+ name: "Logs",
+ components: { GalaxyEmbed },
+ data() {
+ return {
+ tab: "errors",
+ errorFiles: [],
+ errorsLoading: true,
+ selectedLog: "",
+ logContent: "",
+ logLoading: false,
+ tmuxFiles: [],
+ tmuxLoading: true,
+ troubleshootData: null,
+ troubleshootRunning: false,
+ searchFilter: "",
+ tmuxAutoScroll: true,
+ }
+ },
+ created() {
+ this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
+ },
+ watch: {
+ "stream.state.log"() {
+ this.$nextTick(() => {
+ if (this.stream.state.paused || !this.tmuxAutoScroll) return
+ const el = this.$refs.tmuxtail
+ if (el) el.scrollTop = el.scrollHeight
+ })
+ },
+ },
+ mounted() {
+ this.loadErrorLogs()
+ this.loadTmuxLogs()
+ this.stream.start()
+ this.loadTroubleshoot()
+ },
+ beforeUnmount() { this.stream.destroy() },
+ computed: {
+ filteredErrorFiles() {
+ if (!this.searchFilter) return this.errorFiles
+ const q = this.searchFilter.toLowerCase()
+ return this.errorFiles.filter((f) => f.filename.toLowerCase().includes(q))
+ },
+ },
+ methods: {
+ async loadErrorLogs() {
+ try {
+ const files = await api.getErrorLogs()
+ this.errorFiles = files.map((f) => {
+ const date = parseLogDate(f)
+ return { filename: f, date: date.toLocaleString() }
+ })
+ } catch (e) {
+ showSnackbar("Failed to load error logs.", "error")
+ } finally {
+ this.errorsLoading = false
+ }
+ },
+ async viewLog(file) {
+ this.selectedLog = file.filename
+ this.logLoading = true
+ try {
+ this.logContent = await api.getErrorLog(file.filename)
+ } catch (e) {
+ this.logContent = "Could not load this log."
+ } finally {
+ this.logLoading = false
+ this.$nextTick(() => {
+ const el = this.$refs.logview
+ if (el) el.scrollTop = el.scrollHeight
+ })
+ }
+ },
+ async deleteLog(file) {
+ if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
+ await api.deleteErrorLog(file.filename)
+ this.errorFiles = this.errorFiles.filter((f) => f.filename !== file.filename)
+ if (this.selectedLog === file.filename) this.selectedLog = ""
+ showSnackbar("Log deleted!")
+ },
+ async deleteAllLogs() {
+ if (!(await GalaxyConfirm({ title: "Delete all error logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
+ try {
+ const ok = await api.deleteAllErrorLogs()
+ if (!ok) throw new Error("Delete failed")
+ this.errorFiles = []
+ this.selectedLog = ""
+ showSnackbar("All error logs deleted!")
+ } catch (e) {
+ showSnackbar("Delete all failed.", "error")
+ }
+ },
+ copyLog() {
+ const text = this.logContent
+ if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text)
+ else { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove() }
+ showSnackbar("Copied to clipboard!")
+ },
+ async loadTmuxLogs() {
+ try {
+ const files = await api.getTmuxLogs()
+ this.tmuxFiles = files.map((f) => {
+ const date = new Date(f.timestamp * 1000)
+ return { filename: f.filename, date: date.toLocaleString() }
+ })
+ } catch (e) {
+ this.tmuxFiles = []
+ } finally {
+ this.tmuxLoading = false
+ }
+ },
+ async captureTmux() {
+ const ok = await api.tmuxCapture()
+ showSnackbar(ok ? "Current session captured!" : "Capture failed.", ok ? "info" : "error")
+ this.tmuxLoading = true
+ await this.loadTmuxLogs()
+ },
+ async deleteTmux(file) {
+ if (!(await GalaxyConfirm({ title: "Delete log?", message: `Delete ${file.filename}?`, confirmLabel: "Delete", danger: true }))) return
+ const ok = await api.deleteTmuxLog(file.filename)
+ showSnackbar(ok ? "Deleted!" : "Delete failed.", ok ? "info" : "error")
+ this.tmuxFiles = this.tmuxFiles.filter((f) => f.filename !== file.filename)
+ },
+ async deleteAllTmux() {
+ if (!(await GalaxyConfirm({ title: "Delete all logs?", message: "This cannot be undone.", confirmLabel: "Delete All", danger: true }))) return
+ const ok = await api.deleteAllTmuxLogs()
+ showSnackbar(ok ? "All logs deleted!" : "Delete failed.", ok ? "info" : "error")
+ this.tmuxFiles = []
+ },
+ async loadTroubleshoot() {
+ try {
+ this.troubleshootData = await api.getTroubleshoot()
+ } catch (e) { this.troubleshootData = null }
+ },
+ async runTroubleshoot() {
+ this.troubleshootRunning = true
+ try {
+ this.troubleshootData = await api.runTroubleshoot()
+ } catch (e) {
+ showSnackbar("Troubleshoot failed.", "error")
+ } finally {
+ this.troubleshootRunning = false
+ }
+ },
+ async resetTroubleshoot() {
+ const ok = await api.resetTroubleshoot()
+ if (ok) this.troubleshootData = null
+ showSnackbar(ok ? "Troubleshoot reset!" : "Reset failed.", ok ? "info" : "error")
+ },
+ onTmuxScroll(e) {
+ const el = e.target
+ this.tmuxAutoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40
+ },
+ },
+ template: `
+
+
Logs & Diagnostics
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+ No error logs!
+
+
+ {{ f.date }}
+ {{ f.filename }}
+
+
+
+
+
+
+
{{ logLoading ? 'Loading...' : logContent }}
+
+
+
+
+
+
+ {{ stream.state.log || '(waiting for log output…)' }}
+
+
+
+
+
+
+
+
+ Loading...
+ No tmux logs found.
+
+
+ {{ f.filename }}
+ {{ f.date }}
+
+
+
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js
new file mode 100644
index 0000000000..845f769fd5
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Navigation.js
@@ -0,0 +1,89 @@
+import { api, showSnackbar } from "../api.js"
+import { GalaxySection } from "../components/GalaxySection.js"
+import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
+import { useTabRouting } from "../composables.js"
+
+export const Navigation = {
+ name: "Navigation",
+ components: { GalaxySection, GalaxyEmbed },
+ data() {
+ return { destination: "", favorites: [], navLoading: true }
+ },
+ setup() {
+ return useTabRouting("/navigation", {
+ nav: "", maps: "maps", keys: "keys", speeds: "speeds",
+ })
+ },
+ computed: {
+ embedSrc() {
+ const sources = {
+ maps: "/manage_maps",
+ keys: "/manage_navigation_keys",
+ speeds: "/download_speed_limits",
+ }
+ return sources[this.tab] || ""
+ },
+ embedTitle() {
+ const titles = {
+ maps: "Maps",
+ keys: "App Keys",
+ speeds: "Speed Limits",
+ }
+ return titles[this.tab] || "Navigation"
+ },
+ },
+ mounted() { this.loadNavigation() },
+ methods: {
+ async loadNavigation() {
+ this.navLoading = true
+ try {
+ const data = await api.getNavigation()
+ this.destination = data?.destination || data?.name || ""
+ this.favorites = Array.isArray(data?.favorites) ? data.favorites : []
+ } catch (e) {
+ this.favorites = []
+ } finally {
+ this.navLoading = false
+ }
+ },
+ async setDestination() {
+ if (!this.destination) return
+ try {
+ const payload = await api.setNavigation({ destination: this.destination })
+ showSnackbar(payload?.message || "Destination set.")
+ } catch (e) {
+ showSnackbar(e?.message || "Failed to set destination.", "error")
+ }
+ },
+ },
+ template: `
+
+
Navigation & Maps
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Favorites
+
+ {{ fav.name }}
+
+
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js
new file mode 100644
index 0000000000..c0d8f8339d
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Recordings.js
@@ -0,0 +1,305 @@
+import { api, showSnackbar } from "../api.js"
+import { GalaxyConfirm } from "../components/GalaxyModal.js"
+import { GalaxySection } from "../components/GalaxySection.js"
+
+function fmtDuration(seconds) {
+ seconds = Number(seconds) || 0
+ const h = Math.floor(seconds / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
+}
+
+function formatBytes(bytes) {
+ if (!bytes) return "0 MB"
+ const mb = bytes / 1e6
+ return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`
+}
+
+function normalizeRoute(r) {
+ const name = String(r?.name || "")
+ const isCustomName = !!r?.isCustomName
+ return {
+ name,
+ displayName: r?.displayName || name.split("--").pop() || name,
+ displayDate: r?.displayDate || "",
+ approxDurationSeconds: Number(r?.approxDurationSeconds || 0),
+ segmentCount: Number(r?.segmentCount || r?.numSegments || 0),
+ is_preserved: !!r?.is_preserved,
+ isCustomName,
+ png: r?.png || "",
+ }
+}
+
+export const Recordings = {
+ name: "Recordings",
+ components: { GalaxySection },
+ data() {
+ return {
+ loading: true,
+ error: "",
+ routes: [],
+ progress: 0,
+ searchQuery: "",
+ sortOrder: "newest",
+ showPreservedOnly: false,
+ playerRoute: null,
+ playerLoading: false,
+ playerError: "",
+ segments: [],
+ current: 0,
+ cameras: [],
+ selectedCamera: "",
+ logsRoute: null,
+ logsData: null,
+ }
+ },
+ computed: {
+ stats() {
+ return {
+ count: this.routes.length,
+ formattedDuration: fmtDuration(this.routes.reduce((n, r) => n + r.approxDurationSeconds, 0)),
+ preservedCount: this.routes.filter((r) => r.is_preserved).length,
+ }
+ },
+ visibleRoutes() {
+ let list = this.routes.slice()
+ if (this.showPreservedOnly) list = list.filter((r) => r.is_preserved)
+ if (this.searchQuery.trim()) {
+ const q = this.searchQuery.toLowerCase()
+ list = list.filter((r) => [r.displayName, r.displayDate, r.name].some((v) => String(v || "").toLowerCase().includes(q)))
+ }
+ const sorters = {
+ newest: (a, b) => (b.name > a.name ? 1 : -1),
+ oldest: (a, b) => (a.name > b.name ? 1 : -1),
+ longest: (a, b) => b.approxDurationSeconds - a.approxDurationSeconds,
+ shortest: (a, b) => a.approxDurationSeconds - b.approxDurationSeconds,
+ }
+ return list.sort(sorters[this.sortOrder] || sorters.newest)
+ },
+ },
+ methods: {
+ fmtDuration,
+ formatBytes,
+ async loadRoutes() {
+ this.loading = true
+ this.error = ""
+ this.routes = []
+ this.progress = 0
+ const seen = new Set()
+ try {
+ this.controller?.abort()
+ this.controller = new AbortController()
+ await api.getRoutesStream({
+ signal: this.controller.signal,
+ onProgress: (p) => { this.progress = p },
+ onRoutes: (raw) => {
+ for (const r of raw) {
+ if (seen.has(r.name)) continue
+ seen.add(r.name)
+ this.routes.push(normalizeRoute(r))
+ }
+ },
+ })
+ } catch (e) {
+ if (e?.name !== "AbortError") this.error = "Couldn't load routes. Try refreshing."
+ } finally {
+ this.loading = false
+ }
+ },
+ async deleteRoute(route) {
+ if (!(await GalaxyConfirm({ title: "Delete route?", message: `Delete “${route.displayName}”?`, confirmLabel: "Delete", danger: true }))) return
+ try {
+ await api.deleteRoute(route.name)
+ this.routes = this.routes.filter((r) => r.name !== route.name)
+ showSnackbar("Route deleted!")
+ } catch (e) {
+ showSnackbar("Delete failed.", "error")
+ }
+ },
+ async togglePreserved(route) {
+ try {
+ await api.setRoutePreserved(route.name, !route.is_preserved)
+ route.is_preserved = !route.is_preserved
+ } catch (e) {
+ showSnackbar("Failed to update preserved state.", "error")
+ }
+ },
+ async renameRoute(route) {
+ const newName = prompt("Rename route:", route.displayName)
+ if (!newName || newName === route.displayName) return
+ try {
+ const payload = await api.renameRoute(route.name, newName)
+ Object.assign(route, normalizeRoute({ ...route, name: payload.name || newName, isCustomName: true }))
+ route.displayName = payload.name || newName
+ showSnackbar("Route renamed!")
+ } catch (e) {
+ showSnackbar("Rename failed.", "error")
+ }
+ },
+ async deleteAllRoutes(includePreserved) {
+ const label = includePreserved ? "Delete all routes, including preserved?" : "Delete all non-preserved routes?"
+ if (!(await GalaxyConfirm({ title: label, message: "This action cannot be undone.", confirmLabel: includePreserved ? "Delete Everything" : "Delete Non-Preserved", danger: true }))) return
+ try {
+ const payload = await api.deleteAllRoutes(includePreserved)
+ this.routes = []
+ showSnackbar(payload?.message || "Routes deleted!")
+ } catch (e) {
+ showSnackbar("Failed to delete routes.", "error")
+ }
+ },
+ async openPlayer(route) {
+ this.playerRoute = route
+ this.playerLoading = true
+ this.playerError = ""
+ try {
+ const data = await api.getRoute(route.name)
+ const segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter((u) => typeof u === "string") : []
+ const cameras = ["forward", "wide", "driver"].filter((c) => data.available_cameras?.includes(c))
+ if (!segments.length) throw new Error("No video segments for this route.")
+ if (!cameras.length) throw new Error("No camera video for this route.")
+ this.segments = segments
+ this.current = 0
+ this.cameras = cameras
+ this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0]
+ this.$nextTick(() => this.playSegment())
+ } catch (e) {
+ this.playerError = e?.message || "Could not load route."
+ } finally {
+ this.playerLoading = false
+ }
+ },
+ cameraUrl(url, low) {
+ if (this.selectedCamera === "forward") return low && !url.includes("?" ) ? `${url}?quality=low` : url
+ const sep = url.includes("?") ? "&" : "?"
+ return `${url}${sep}camera=${encodeURIComponent(this.selectedCamera)}${low ? "&quality=low" : ""}`
+ },
+ playSegment() {
+ const video = this.$refs.player
+ if (!video || !this.segments[this.current]) return
+ video.src = this.cameraUrl(this.segments[this.current])
+ video.load()
+ video.play().catch(() => {})
+ },
+ downloadRoute() {
+ if (!this.playerRoute) return
+ const a = document.createElement("a")
+ a.href = `/video/${this.playerRoute.name}/combined?camera=${encodeURIComponent(this.selectedCamera)}`
+ a.download = `${this.playerRoute.displayName}-${this.selectedCamera}.mp4`
+ a.click()
+ },
+ closePlayer() {
+ if (this.$refs.player) { this.$refs.player.pause(); this.$refs.player.removeAttribute("src") }
+ this.playerRoute = null
+ this.playerLoading = false
+ this.playerError = ""
+ this.segments = []
+ this.cameras = []
+ },
+ async openLogs(route) {
+ try {
+ this.logsData = await api.getRouteLogs(route.name)
+ this.logsRoute = route
+ } catch (e) {
+ showSnackbar("Could not read logs.", "error")
+ }
+ },
+ },
+ async mounted() { await this.loadRoutes() },
+ beforeUnmount() { this.controller?.abort() },
+ template: `
+
+
Recordings
+
+
+
+
Finding local routes... ({{ Math.round(progress) }}%)
+
{{ error }}
+
+ No routes found.
+
+
+ {{ r.displayName }} Preserved
+ {{ fmtDuration(r.approxDurationSeconds) }} · {{ r.segmentCount }} segments
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Segment {{ seg.segmentNum }}
+ {{ seg.filename }} · {{ formatBytes(seg.bytes) }}
+
+
Download
+
+
+
+
+
+
+
+
+
{{ playerError }}
+
Loading video...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js
new file mode 100644
index 0000000000..64e6c2ed78
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Settings.js
@@ -0,0 +1,166 @@
+import { api, showSnackbar } from "../api.js"
+import { navigate, store } from "../store.js"
+import {
+ applyParamChange, countAdvancedHiddenByDeveloperMode, GALAXY_DEVELOPER_MODE_KEY, isSettingVisible,
+ slugifySectionName,
+} from "../params.js"
+import { SettingTree } from "../components/SettingTree.js"
+import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
+import { GalaxySection } from "../components/GalaxySection.js"
+import { DevModeBanner } from "../components/DevModeBanner.js"
+
+export const Settings = {
+ name: "Settings",
+ components: { SettingTree, GalaxyToggleCard, GalaxySection, DevModeBanner },
+ data() {
+ return {
+ layout: [],
+ values: {},
+ expanded: {},
+ loading: true,
+ activeSectionSlug: "",
+ defaultSectionSlug: "lateral-steering",
+ }
+ },
+ computed: {
+ devModeOn() { return !!this.values[GALAXY_DEVELOPER_MODE_KEY] },
+ route() { return store.route },
+ sections() {
+ return this.layout
+ .filter((s) => s.name !== "Model & Customization")
+ .map((s) => ({
+ ...s,
+ params: (s.params || []).filter((p) => isSettingVisible(s, p, this.values)),
+ slug: slugifySectionName(s.name),
+ }))
+ .filter((s) => s.params.length > 0)
+ },
+ activeSection() {
+ return this.sections.find((s) => s.slug === this.activeSectionSlug) || this.sections[0]
+ },
+ hiddenAdvancedCount() { return countAdvancedHiddenByDeveloperMode(this.layout, this.values) },
+ searchActive() { return !!this.searchTerm },
+ searchTerm: {
+ get() { return store.search },
+ set(v) { store.search = v },
+ },
+ searchResults() {
+ if (!this.searchActive) return []
+ return this.sections
+ .map((s) => ({ ...s, matches: s.params.filter((p) => this.matchesFilter(p)) }))
+ .filter((s) => s.matches.length > 0)
+ },
+ },
+ methods: {
+ async load() {
+ try {
+ const [layout, values, defaults] = await Promise.all([
+ api.getLayout(), api.getParams(), api.getDefaults(),
+ ])
+ this.layout = layout
+ this.values = values || {}
+ this.defaults = defaults || {}
+ if (!this.activeSectionSlug && this.sections.length) {
+ const preferred = this.sections.find((s) => s.slug === this.defaultSectionSlug)
+ this.activeSectionSlug = (preferred || this.sections[0]).slug
+ }
+ } catch (e) {
+ showSnackbar("Failed to load settings: " + (e?.message || e), "error")
+ } finally {
+ this.loading = false
+ }
+ },
+ onParamChange(patch) {
+ this.values = applyParamChange(this.values, patch)
+ },
+ toggleManage(key) {
+ const next = !this.expanded[key]
+ this.expanded = { ...this.expanded, [key]: next }
+ const base = "/settings/" + this.activeSectionSlug
+ window.location.hash = next ? `${base}?open=${encodeURIComponent(key)}` : base
+ },
+ matchesFilter(p) {
+ if (!this.searchTerm) return true
+ const q = this.searchTerm.toLowerCase()
+ return [p.label, p.key, p.description].some((v) => String(v || "").toLowerCase().includes(q))
+ },
+ selectSection(slug) {
+ if (slug !== this.activeSectionSlug) navigate("/settings/" + slug)
+ },
+ applyRouteSection() {
+ const route = store.route
+ if (!route.startsWith("/settings/")) return
+ const slug = route.replace(/^\/settings\/?/, "").split("?")[0].split("/")[0]
+ if (slug && this.sections.some((s) => s.slug === slug)) this.activeSectionSlug = slug
+ if (store.params.open) this.expanded = { ...this.expanded, [store.params.open]: true }
+ },
+ lockReason(param) {
+ if (param?.requires_offroad && this.values.IsOnroad) return "This setting can only be changed while parked."
+ if (param?.requires_parked && !this.values.VehicleParked) return "This setting can only be changed while the vehicle is in Park."
+ if (param?.disabled_when_key_true && this.values[param.disabled_when_key_true]) return param.disabled_reason || "Disabled by another setting."
+ if (param?.requires_nonempty_key) {
+ const val = this.values[param.requires_nonempty_key]
+ if (!val || val === "{}" || val === "") return param.disabled_reason || "Required configuration missing."
+ }
+ return ""
+ },
+ },
+ watch: {
+ route() { this.applyRouteSection() },
+ devModeOn() { this.load() },
+ },
+ async mounted() {
+ await this.load()
+ this.applyRouteSection()
+ },
+ template: `
+
+
Toggles
+
+
+
+
Loading configuration...
+
+
+
+
+
+
+
+
+
+
+
+
+
No settings in this section.
+
+
+
+
+
No settings available.
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js
new file mode 100644
index 0000000000..11d5be3372
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js
@@ -0,0 +1,261 @@
+import { api, showSnackbar } from "../api.js"
+import { usePolling } from "../composables.js"
+import { GalaxyConfirm } from "../components/GalaxyModal.js"
+import { GalaxySection } from "../components/GalaxySection.js"
+import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
+
+function shortCommit(commit) {
+ return String(commit || "").slice(0, 10) || "—"
+}
+
+export const SystemTools = {
+ name: "SystemTools",
+ components: { GalaxySection, GalaxyEmbed },
+ data() {
+ return {
+ branches: [],
+ currentBranch: "",
+ branchLoading: true,
+ fastStatus: null,
+ checkedForUpdates: false,
+ busy: "",
+ }
+ },
+ created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() },
+ mounted() { this.loadBranches() },
+ beforeUnmount() { this.poll?.destroy() },
+ computed: {
+ updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
+ },
+ methods: {
+ shortCommit,
+ async loadBranches() {
+ try {
+ const data = await api.getUpdateBranches()
+ this.branches = Array.isArray(data?.branches) ? data.branches : []
+ this.currentBranch = data?.currentBranch || ""
+ this.isOnroad = !!data?.isOnroad
+ } catch (e) {
+ showSnackbar("Failed to load update info.", "error")
+ } finally {
+ this.branchLoading = false
+ }
+ },
+ async loadFastStatus() {
+ try { this.fastStatus = await api.getUpdateFastStatus() } catch (e) { this.fastStatus = null }
+ },
+ async backupToggles() {
+ try {
+ const blob = await api.backupToggles()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = "toggle-backup.json"
+ a.click()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+ showSnackbar("Toggle backup downloaded.")
+ } catch (e) {
+ showSnackbar(e?.message || "Backup failed.", "error")
+ }
+ },
+ onRestoreFile(e) {
+ const file = e.target.files[0]
+ e.target.value = ""
+ if (!file) return
+ if (file.size > 5_000_000) { showSnackbar("That toggle backup file is too large.", "error"); return }
+ file.text().then((text) => {
+ let data
+ try { data = JSON.parse(text) } catch { showSnackbar("That file is not a valid toggle backup.", "error"); return }
+ if (!data || typeof data !== "object" || Array.isArray(data)) { showSnackbar("That file is not a valid toggle backup.", "error"); return }
+ api.restoreToggles(data).then((res) => {
+ showSnackbar(res?.message || "Toggles restored!")
+ }).catch((err) => showSnackbar(err?.message || "Failed to restore toggles.", "error"))
+ })
+ },
+ async resetDefault() {
+ if (!(await GalaxyConfirm({ title: "Reset toggles to default?", message: "This resets all toggles to their default values and reboots.", confirmLabel: "Reset", danger: true }))) return
+ try {
+ await api.resetTogglesDefault()
+ showSnackbar("Resetting toggles to default... rebooting.")
+ } catch (e) {
+ showSnackbar("Reset failed.", "error")
+ }
+ },
+ onBranchSelect(e) {
+ const branch = e.target.value
+ e.target.value = this.currentBranch || ""
+ this.switchBranch(branch)
+ },
+ async switchBranch(branch) {
+ if (!branch || branch === this.currentBranch) return
+ if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return
+ try {
+ await api.setUpdateBranch(branch)
+ showSnackbar(`Switching to ${branch}...`)
+ } catch (e) {
+ showSnackbar(e?.message || "Switch failed.", "error")
+ }
+ },
+ async checkUpdates() {
+ if (this.busy) return
+ this.busy = "check"
+ try {
+ await this.loadFastStatus()
+ this.checkedForUpdates = true
+ const st = this.fastStatus
+ if (st?.running) showSnackbar("An update is already running.")
+ else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.")
+ else showSnackbar(st?.message || "No update available — you're up to date.")
+ } catch (e) {
+ showSnackbar("Failed to check for updates.", "error")
+ } finally {
+ this.busy = ""
+ }
+ },
+ async applyFastUpdate() {
+ if (this.busy || this.isOnroad) return
+ if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return }
+ if (!this.checkedForUpdates || !this.updateAvailable) {
+ showSnackbar("No update available. Run \"Check for Updates\" first.", "error")
+ return
+ }
+ const st = this.fastStatus
+ const confirmed = await GalaxyConfirm({
+ title: "Update available",
+ message: `Fast update to the latest commit on ${st?.branch || "this branch"}.\n\nYour device will reboot when the update is done.`,
+ confirmLabel: "Update & Reboot",
+ danger: true,
+ })
+ if (!confirmed) return
+ await this.runUpdate("fast")
+ },
+ async runUpdate(action) {
+ if (this.busy) return
+ this.busy = action
+ try {
+ if (action === "rollback") {
+ const st = this.fastStatus
+ if (st && !st.rollbackAvailable) {
+ showSnackbar("No previous installed version is available to roll back to.", "error")
+ return
+ }
+ }
+ if (action !== "fast") {
+ const actionLabels = { recover: "Recover the interrupted update?", rollback: "Roll back to the previous installed version?" }
+ if (!(await GalaxyConfirm({ title: actionLabels[action] || "Continue?", message: "Your device will reboot when the operation is done.", confirmLabel: "Continue", danger: true }))) return
+ }
+ const fn = action === "fast" ? api.updateFast : action === "recover" ? api.updateRecover : api.updateRollback
+ const payload = await fn()
+ showSnackbar(payload?.message || "Update started.")
+ await this.loadFastStatus()
+ } catch (e) {
+ showSnackbar(e?.message || "Update failed.", "error")
+ } finally {
+ this.busy = ""
+ }
+ },
+ async factoryReset() {
+ if (!(await GalaxyConfirm({ title: "Factory reset?", message: "This wipes params, backups, themes, models, maps, and route data, then reboots. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
+ try {
+ await api.factoryReset()
+ showSnackbar("Factory resetting...")
+ } catch (e) {
+ showSnackbar(e?.message || "Factory reset failed.", "error")
+ }
+ },
+ async saveMe() {
+ if (!(await GalaxyConfirm({ title: "SAVE ME", message: "This will factory reset the device by wiping params, backups, themes, models, maps, and route data. The device will reboot when the wipe is complete. This cannot be undone.", confirmLabel: "Factory Reset", danger: true }))) return
+ try {
+ await api.factoryReset()
+ showSnackbar("SAVE ME initiated — factory resetting...")
+ } catch (e) {
+ showSnackbar(e?.message || "Factory reset failed.", "error")
+ }
+ },
+ async deleteAllDrivingRoutes() {
+ if (!(await GalaxyConfirm({ title: "Delete All Driving Routes", message: "This permanently deletes all local routes from standard, high-resolution, and alternate footage storage. It does not reset settings or reboot the device.", confirmLabel: "Delete Routes", danger: true }))) return
+ try {
+ const payload = await api.deleteAllRoutes(true)
+ showSnackbar(payload?.message || "All local driving routes deleted.")
+ } catch (e) {
+ showSnackbar(e?.message || "Failed to delete driving routes.", "error")
+ }
+ },
+ },
+ template: `
+
+
System Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading update info...
+
+ Updates and branch switching are only available while offroad.
+
+
+
+
+
Branch{{ fastStatus.branch || currentBranch || '—' }}
+
Stage{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}
+
Local{{ shortCommit(fastStatus.localCommit) }}
+
Remote{{ shortCommit(fastStatus.remoteCommit) }}
+
{{ fastStatus.message }}
+
{{ fastStatus.warning }}
+
+
+
+
+ Switch branch
+
+
+
+
+
+
+
+
+
+
+ The device is up to date. Update becomes available only after a check finds a newer commit.
+
+
+
+
+
+
+
+
Last resort only. This wipes params, backups, themes, models, maps, and route data, then reboots the device.
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js
new file mode 100644
index 0000000000..a78134ed07
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js
@@ -0,0 +1,46 @@
+import { store } from "../store.js"
+import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
+
+export const ToolEmbed = {
+ name: "ToolEmbed",
+ components: { GalaxyEmbed },
+ computed: {
+ src() { return store.params.src || "/tools" },
+ title() {
+ const map = {
+ "/manage_models": "Model Manager",
+ "/galaxy": "Galaxy",
+ "/sentry": "Sentry Mode",
+ "/plots": "Live Plots",
+ "/download_speed_limits": "Download Speed Limits",
+ "/testing_ground": "Testing Ground",
+ "/theme_maker": "Theme Maker",
+ "/troubleshoot": "Troubleshoot",
+ "/manage_tmux": "Tmux Log",
+ "/manage_toggles": "Backup and Restore",
+ "/manage_updates": "Software",
+ "/manage_error_logs": "Error Logs",
+ "/bluetooth": "Bluetooth",
+ "/wheel-controls": "Controllers",
+ "/vehicle_features": "Vehicle Features",
+ "/manage_v_asm": "V-Adj Spot Monitor",
+ "/manage_pip_sidecam": "PiP Side Camera",
+ "/manage_doors": "Lock/Unlock Doors",
+ "/manage_tsk": "Toyota Security Keys",
+ "/set_navigation_destination": "Navigation Destination",
+ "/manage_navigation_keys": "App Keys",
+ "/manage_maps": "Maps",
+ }
+ return map[this.src] || "Tool"
+ },
+ },
+ template: `
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js
new file mode 100644
index 0000000000..64c9595513
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js
@@ -0,0 +1,39 @@
+import { navigate, toolHref } from "../store.js"
+
+const TOOLS = [
+ { name: "Galaxy", link: "/galaxy", icon: "bi-globe2", desc: "Pairing & remote access" },
+ { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" },
+ { name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" },
+ { name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" },
+ { name: "PiP Side Camera", link: "/manage_pip_sidecam", icon: "bi-camera-video", desc: "Adjust PiP side-camera window" },
+ { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow", desc: "Live telemetry plots" },
+ { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" },
+ { name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" },
+ { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2", desc: "Experiments & testing" },
+ { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill", desc: "Customize the look" },
+ { name: "Tuning & Maneuvers", link: "/tuning", icon: "bi-sign-turn-right", desc: "Steering & speed behaviour" },
+ { name: "V-ASM Spot Monitor", link: "/manage_v_asm", icon: "bi-bounding-box", desc: "Adjust spot-monitor window" },
+ { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front", desc: "Controllers, bluetooth, vehicle features" },
+].sort((a, b) => a.name.localeCompare(b.name))
+
+export const Tools = {
+ name: "Tools",
+ data() { return { TOOLS } },
+ methods: {
+ open(t) {
+ navigate(toolHref(t.link))
+ },
+ },
+ template: `
+
+
Tools
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js
new file mode 100644
index 0000000000..767cebca41
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Tuning.js
@@ -0,0 +1,15 @@
+import { GalaxyEmbed } from "../components/GalaxyEmbed.js"
+
+export const Tuning = {
+ name: "Tuning",
+ components: { GalaxyEmbed },
+ template: `
+
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js
new file mode 100644
index 0000000000..568b06ed96
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Vehicle.js
@@ -0,0 +1,79 @@
+import { api, showSnackbar } from "../api.js"
+import { navigate, toolHref } from "../store.js"
+import { WheelControls } from "../components/WheelControls.js"
+import { BluetoothPanel } from "../components/BluetoothPanel.js"
+import { GalaxySection } from "../components/GalaxySection.js"
+
+const FEATURES = [
+ { key: "doors", name: "Lock/Unlock Doors", icon: "bi-door-closed", desc: "Send lock or unlock commands remotely to your vehicle.", embed: "/manage_doors" },
+ { key: "tsk", name: "Toyota Security Keys", icon: "bi-key-fill", desc: "Manage and apply security keys for secOC protected devices.", embed: "/manage_tsk" },
+]
+
+export const Vehicle = {
+ name: "Vehicle",
+ components: { WheelControls, BluetoothPanel, GalaxySection },
+ data() {
+ return {
+ features: FEATURES,
+ featureStatus: {},
+ busy: "",
+ }
+ },
+ methods: {
+ statusOf(key) { return this.featureStatus[key] || "untested" },
+ async openFeature(f) {
+ if (this.busy) return
+ if (this.statusOf(f.key) === "denied") {
+ showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
+ return
+ }
+ if (this.statusOf(f.key) === "allowed") {
+ navigate(toolHref(f.embed))
+ return
+ }
+ this.busy = f.key
+ try {
+ const data = await api.carFeaturesCheck(f.key)
+ const allowed = !!data?.result
+ this.featureStatus = { ...this.featureStatus, [f.key]: allowed ? "allowed" : "denied" }
+ if (allowed) navigate(toolHref(f.embed))
+ else showSnackbar(`${f.name} is not supported for your current vehicle.`, "error")
+ } catch (e) {
+ this.featureStatus = { ...this.featureStatus, [f.key]: "denied" }
+ showSnackbar("Could not check vehicle compatibility.", "error")
+ } finally {
+ this.busy = ""
+ }
+ },
+ },
+ template: `
+
+
Vehicle Controls
+
+
+
+
+
+
+
+
+
+
+
+
+
These features verify vehicle compatibility when launched.
+
+
+
+ `,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/manifest.json b/starpilot/system/the_galaxy/assets/mobile/manifest.json
new file mode 100644
index 0000000000..3fdbe29c9b
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "Galaxy",
+ "short_name": "Galaxy",
+ "description": "Control and configure your openpilot device from anywhere.",
+ "icons": [
+ { "src": "/assets/images/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
+ { "src": "/assets/images/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
+ { "src": "/assets/images/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
+ ],
+ "id": "46bf2df73deba8e1512c35de",
+ "start_url": "/mobile/",
+ "scope": "/",
+ "background_color": "#06060f",
+ "theme_color": "#8b6cc5",
+ "display": "standalone",
+ "display_override": ["standalone", "minimal-ui"],
+ "orientation": "portrait-primary"
+}
diff --git a/starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js b/starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js
new file mode 100644
index 0000000000..c2fd80a85d
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/vendor/vue/vue.esm-browser.js
@@ -0,0 +1,16774 @@
+/**
+* vue v3.4.38
+* (c) 2018-present Yuxi (Evan) You and Vue contributors
+* @license MIT
+**/
+/*! #__NO_SIDE_EFFECTS__ */
+// @__NO_SIDE_EFFECTS__
+function makeMap(str, expectsLowerCase) {
+ const set = new Set(str.split(","));
+ return expectsLowerCase ? (val) => set.has(val.toLowerCase()) : (val) => set.has(val);
+}
+
+const EMPTY_OBJ = Object.freeze({}) ;
+const EMPTY_ARR = Object.freeze([]) ;
+const NOOP = () => {
+};
+const NO = () => false;
+const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter
+(key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97);
+const isModelListener = (key) => key.startsWith("onUpdate:");
+const extend = Object.assign;
+const remove = (arr, el) => {
+ const i = arr.indexOf(el);
+ if (i > -1) {
+ arr.splice(i, 1);
+ }
+};
+const hasOwnProperty$1 = Object.prototype.hasOwnProperty;
+const hasOwn = (val, key) => hasOwnProperty$1.call(val, key);
+const isArray = Array.isArray;
+const isMap = (val) => toTypeString(val) === "[object Map]";
+const isSet = (val) => toTypeString(val) === "[object Set]";
+const isDate = (val) => toTypeString(val) === "[object Date]";
+const isRegExp = (val) => toTypeString(val) === "[object RegExp]";
+const isFunction = (val) => typeof val === "function";
+const isString = (val) => typeof val === "string";
+const isSymbol = (val) => typeof val === "symbol";
+const isObject = (val) => val !== null && typeof val === "object";
+const isPromise = (val) => {
+ return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch);
+};
+const objectToString = Object.prototype.toString;
+const toTypeString = (value) => objectToString.call(value);
+const toRawType = (value) => {
+ return toTypeString(value).slice(8, -1);
+};
+const isPlainObject = (val) => toTypeString(val) === "[object Object]";
+const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key;
+const isReservedProp = /* @__PURE__ */ makeMap(
+ // the leading comma is intentional so empty string "" is also included
+ ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"
+);
+const isBuiltInDirective = /* @__PURE__ */ makeMap(
+ "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"
+);
+const cacheStringFunction = (fn) => {
+ const cache = /* @__PURE__ */ Object.create(null);
+ return (str) => {
+ const hit = cache[str];
+ return hit || (cache[str] = fn(str));
+ };
+};
+const camelizeRE = /-(\w)/g;
+const camelize = cacheStringFunction((str) => {
+ return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : "");
+});
+const hyphenateRE = /\B([A-Z])/g;
+const hyphenate = cacheStringFunction(
+ (str) => str.replace(hyphenateRE, "-$1").toLowerCase()
+);
+const capitalize = cacheStringFunction((str) => {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+});
+const toHandlerKey = cacheStringFunction((str) => {
+ const s = str ? `on${capitalize(str)}` : ``;
+ return s;
+});
+const hasChanged = (value, oldValue) => !Object.is(value, oldValue);
+const invokeArrayFns = (fns, ...arg) => {
+ for (let i = 0; i < fns.length; i++) {
+ fns[i](...arg);
+ }
+};
+const def = (obj, key, value, writable = false) => {
+ Object.defineProperty(obj, key, {
+ configurable: true,
+ enumerable: false,
+ writable,
+ value
+ });
+};
+const looseToNumber = (val) => {
+ const n = parseFloat(val);
+ return isNaN(n) ? val : n;
+};
+const toNumber = (val) => {
+ const n = isString(val) ? Number(val) : NaN;
+ return isNaN(n) ? val : n;
+};
+let _globalThis;
+const getGlobalThis = () => {
+ return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {});
+};
+
+const PatchFlagNames = {
+ [1]: `TEXT`,
+ [2]: `CLASS`,
+ [4]: `STYLE`,
+ [8]: `PROPS`,
+ [16]: `FULL_PROPS`,
+ [32]: `NEED_HYDRATION`,
+ [64]: `STABLE_FRAGMENT`,
+ [128]: `KEYED_FRAGMENT`,
+ [256]: `UNKEYED_FRAGMENT`,
+ [512]: `NEED_PATCH`,
+ [1024]: `DYNAMIC_SLOTS`,
+ [2048]: `DEV_ROOT_FRAGMENT`,
+ [-1]: `HOISTED`,
+ [-2]: `BAIL`
+};
+
+const slotFlagsText = {
+ [1]: "STABLE",
+ [2]: "DYNAMIC",
+ [3]: "FORWARDED"
+};
+
+const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error";
+const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED);
+
+const range = 2;
+function generateCodeFrame(source, start = 0, end = source.length) {
+ start = Math.max(0, Math.min(start, source.length));
+ end = Math.max(0, Math.min(end, source.length));
+ if (start > end) return "";
+ let lines = source.split(/(\r?\n)/);
+ const newlineSequences = lines.filter((_, idx) => idx % 2 === 1);
+ lines = lines.filter((_, idx) => idx % 2 === 0);
+ let count = 0;
+ const res = [];
+ for (let i = 0; i < lines.length; i++) {
+ count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0);
+ if (count >= start) {
+ for (let j = i - range; j <= i + range || end > count; j++) {
+ if (j < 0 || j >= lines.length) continue;
+ const line = j + 1;
+ res.push(
+ `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}`
+ );
+ const lineLength = lines[j].length;
+ const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0;
+ if (j === i) {
+ const pad = start - (count - (lineLength + newLineSeqLength));
+ const length = Math.max(
+ 1,
+ end > count ? lineLength - pad : end - start
+ );
+ res.push(` | ` + " ".repeat(pad) + "^".repeat(length));
+ } else if (j > i) {
+ if (end > count) {
+ const length = Math.max(Math.min(end - count, lineLength), 1);
+ res.push(` | ` + "^".repeat(length));
+ }
+ count += lineLength + newLineSeqLength;
+ }
+ }
+ break;
+ }
+ }
+ return res.join("\n");
+}
+
+function normalizeStyle(value) {
+ if (isArray(value)) {
+ const res = {};
+ for (let i = 0; i < value.length; i++) {
+ const item = value[i];
+ const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item);
+ if (normalized) {
+ for (const key in normalized) {
+ res[key] = normalized[key];
+ }
+ }
+ }
+ return res;
+ } else if (isString(value) || isObject(value)) {
+ return value;
+ }
+}
+const listDelimiterRE = /;(?![^(]*\))/g;
+const propertyDelimiterRE = /:([^]+)/;
+const styleCommentRE = /\/\*[^]*?\*\//g;
+function parseStringStyle(cssText) {
+ const ret = {};
+ cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => {
+ if (item) {
+ const tmp = item.split(propertyDelimiterRE);
+ tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim());
+ }
+ });
+ return ret;
+}
+function stringifyStyle(styles) {
+ let ret = "";
+ if (!styles || isString(styles)) {
+ return ret;
+ }
+ for (const key in styles) {
+ const value = styles[key];
+ if (isString(value) || typeof value === "number") {
+ const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key);
+ ret += `${normalizedKey}:${value};`;
+ }
+ }
+ return ret;
+}
+function normalizeClass(value) {
+ let res = "";
+ if (isString(value)) {
+ res = value;
+ } else if (isArray(value)) {
+ for (let i = 0; i < value.length; i++) {
+ const normalized = normalizeClass(value[i]);
+ if (normalized) {
+ res += normalized + " ";
+ }
+ }
+ } else if (isObject(value)) {
+ for (const name in value) {
+ if (value[name]) {
+ res += name + " ";
+ }
+ }
+ }
+ return res.trim();
+}
+function normalizeProps(props) {
+ if (!props) return null;
+ let { class: klass, style } = props;
+ if (klass && !isString(klass)) {
+ props.class = normalizeClass(klass);
+ }
+ if (style) {
+ props.style = normalizeStyle(style);
+ }
+ return props;
+}
+
+const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot";
+const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view";
+const MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics";
+const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr";
+const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS);
+const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS);
+const isMathMLTag = /* @__PURE__ */ makeMap(MATH_TAGS);
+const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS);
+
+const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`;
+const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs);
+const isBooleanAttr = /* @__PURE__ */ makeMap(
+ specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected`
+);
+function includeBooleanAttr(value) {
+ return !!value || value === "";
+}
+const isKnownHtmlAttr = /* @__PURE__ */ makeMap(
+ `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap`
+);
+const isKnownSvgAttr = /* @__PURE__ */ makeMap(
+ `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan`
+);
+function isRenderableAttrValue(value) {
+ if (value == null) {
+ return false;
+ }
+ const type = typeof value;
+ return type === "string" || type === "number" || type === "boolean";
+}
+
+function looseCompareArrays(a, b) {
+ if (a.length !== b.length) return false;
+ let equal = true;
+ for (let i = 0; equal && i < a.length; i++) {
+ equal = looseEqual(a[i], b[i]);
+ }
+ return equal;
+}
+function looseEqual(a, b) {
+ if (a === b) return true;
+ let aValidType = isDate(a);
+ let bValidType = isDate(b);
+ if (aValidType || bValidType) {
+ return aValidType && bValidType ? a.getTime() === b.getTime() : false;
+ }
+ aValidType = isSymbol(a);
+ bValidType = isSymbol(b);
+ if (aValidType || bValidType) {
+ return a === b;
+ }
+ aValidType = isArray(a);
+ bValidType = isArray(b);
+ if (aValidType || bValidType) {
+ return aValidType && bValidType ? looseCompareArrays(a, b) : false;
+ }
+ aValidType = isObject(a);
+ bValidType = isObject(b);
+ if (aValidType || bValidType) {
+ if (!aValidType || !bValidType) {
+ return false;
+ }
+ const aKeysCount = Object.keys(a).length;
+ const bKeysCount = Object.keys(b).length;
+ if (aKeysCount !== bKeysCount) {
+ return false;
+ }
+ for (const key in a) {
+ const aHasKey = a.hasOwnProperty(key);
+ const bHasKey = b.hasOwnProperty(key);
+ if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) {
+ return false;
+ }
+ }
+ }
+ return String(a) === String(b);
+}
+function looseIndexOf(arr, val) {
+ return arr.findIndex((item) => looseEqual(item, val));
+}
+
+const isRef$1 = (val) => {
+ return !!(val && val.__v_isRef === true);
+};
+const toDisplayString = (val) => {
+ return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val);
+};
+const replacer = (_key, val) => {
+ if (isRef$1(val)) {
+ return replacer(_key, val.value);
+ } else if (isMap(val)) {
+ return {
+ [`Map(${val.size})`]: [...val.entries()].reduce(
+ (entries, [key, val2], i) => {
+ entries[stringifySymbol(key, i) + " =>"] = val2;
+ return entries;
+ },
+ {}
+ )
+ };
+ } else if (isSet(val)) {
+ return {
+ [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v))
+ };
+ } else if (isSymbol(val)) {
+ return stringifySymbol(val);
+ } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) {
+ return String(val);
+ }
+ return val;
+};
+const stringifySymbol = (v, i = "") => {
+ var _a;
+ return (
+ // Symbol.description in es2019+ so we need to cast here to pass
+ // the lib: es2016 check
+ isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v
+ );
+};
+
+function warn$2(msg, ...args) {
+ console.warn(`[Vue warn] ${msg}`, ...args);
+}
+
+let activeEffectScope;
+class EffectScope {
+ constructor(detached = false) {
+ this.detached = detached;
+ /**
+ * @internal
+ */
+ this._active = true;
+ /**
+ * @internal
+ */
+ this.effects = [];
+ /**
+ * @internal
+ */
+ this.cleanups = [];
+ this.parent = activeEffectScope;
+ if (!detached && activeEffectScope) {
+ this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
+ this
+ ) - 1;
+ }
+ }
+ get active() {
+ return this._active;
+ }
+ run(fn) {
+ if (this._active) {
+ const currentEffectScope = activeEffectScope;
+ try {
+ activeEffectScope = this;
+ return fn();
+ } finally {
+ activeEffectScope = currentEffectScope;
+ }
+ } else {
+ warn$2(`cannot run an inactive effect scope.`);
+ }
+ }
+ /**
+ * This should only be called on non-detached scopes
+ * @internal
+ */
+ on() {
+ activeEffectScope = this;
+ }
+ /**
+ * This should only be called on non-detached scopes
+ * @internal
+ */
+ off() {
+ activeEffectScope = this.parent;
+ }
+ stop(fromParent) {
+ if (this._active) {
+ let i, l;
+ for (i = 0, l = this.effects.length; i < l; i++) {
+ this.effects[i].stop();
+ }
+ for (i = 0, l = this.cleanups.length; i < l; i++) {
+ this.cleanups[i]();
+ }
+ if (this.scopes) {
+ for (i = 0, l = this.scopes.length; i < l; i++) {
+ this.scopes[i].stop(true);
+ }
+ }
+ if (!this.detached && this.parent && !fromParent) {
+ const last = this.parent.scopes.pop();
+ if (last && last !== this) {
+ this.parent.scopes[this.index] = last;
+ last.index = this.index;
+ }
+ }
+ this.parent = void 0;
+ this._active = false;
+ }
+ }
+}
+function effectScope(detached) {
+ return new EffectScope(detached);
+}
+function recordEffectScope(effect, scope = activeEffectScope) {
+ if (scope && scope.active) {
+ scope.effects.push(effect);
+ }
+}
+function getCurrentScope() {
+ return activeEffectScope;
+}
+function onScopeDispose(fn) {
+ if (activeEffectScope) {
+ activeEffectScope.cleanups.push(fn);
+ } else {
+ warn$2(
+ `onScopeDispose() is called when there is no active effect scope to be associated with.`
+ );
+ }
+}
+
+let activeEffect;
+class ReactiveEffect {
+ constructor(fn, trigger, scheduler, scope) {
+ this.fn = fn;
+ this.trigger = trigger;
+ this.scheduler = scheduler;
+ this.active = true;
+ this.deps = [];
+ /**
+ * @internal
+ */
+ this._dirtyLevel = 4;
+ /**
+ * @internal
+ */
+ this._trackId = 0;
+ /**
+ * @internal
+ */
+ this._runnings = 0;
+ /**
+ * @internal
+ */
+ this._shouldSchedule = false;
+ /**
+ * @internal
+ */
+ this._depsLength = 0;
+ recordEffectScope(this, scope);
+ }
+ get dirty() {
+ if (this._dirtyLevel === 2 || this._dirtyLevel === 3) {
+ this._dirtyLevel = 1;
+ pauseTracking();
+ for (let i = 0; i < this._depsLength; i++) {
+ const dep = this.deps[i];
+ if (dep.computed) {
+ triggerComputed(dep.computed);
+ if (this._dirtyLevel >= 4) {
+ break;
+ }
+ }
+ }
+ if (this._dirtyLevel === 1) {
+ this._dirtyLevel = 0;
+ }
+ resetTracking();
+ }
+ return this._dirtyLevel >= 4;
+ }
+ set dirty(v) {
+ this._dirtyLevel = v ? 4 : 0;
+ }
+ run() {
+ this._dirtyLevel = 0;
+ if (!this.active) {
+ return this.fn();
+ }
+ let lastShouldTrack = shouldTrack;
+ let lastEffect = activeEffect;
+ try {
+ shouldTrack = true;
+ activeEffect = this;
+ this._runnings++;
+ preCleanupEffect(this);
+ return this.fn();
+ } finally {
+ postCleanupEffect(this);
+ this._runnings--;
+ activeEffect = lastEffect;
+ shouldTrack = lastShouldTrack;
+ }
+ }
+ stop() {
+ if (this.active) {
+ preCleanupEffect(this);
+ postCleanupEffect(this);
+ this.onStop && this.onStop();
+ this.active = false;
+ }
+ }
+}
+function triggerComputed(computed) {
+ return computed.value;
+}
+function preCleanupEffect(effect2) {
+ effect2._trackId++;
+ effect2._depsLength = 0;
+}
+function postCleanupEffect(effect2) {
+ if (effect2.deps.length > effect2._depsLength) {
+ for (let i = effect2._depsLength; i < effect2.deps.length; i++) {
+ cleanupDepEffect(effect2.deps[i], effect2);
+ }
+ effect2.deps.length = effect2._depsLength;
+ }
+}
+function cleanupDepEffect(dep, effect2) {
+ const trackId = dep.get(effect2);
+ if (trackId !== void 0 && effect2._trackId !== trackId) {
+ dep.delete(effect2);
+ if (dep.size === 0) {
+ dep.cleanup();
+ }
+ }
+}
+function effect(fn, options) {
+ if (fn.effect instanceof ReactiveEffect) {
+ fn = fn.effect.fn;
+ }
+ const _effect = new ReactiveEffect(fn, NOOP, () => {
+ if (_effect.dirty) {
+ _effect.run();
+ }
+ });
+ if (options) {
+ extend(_effect, options);
+ if (options.scope) recordEffectScope(_effect, options.scope);
+ }
+ if (!options || !options.lazy) {
+ _effect.run();
+ }
+ const runner = _effect.run.bind(_effect);
+ runner.effect = _effect;
+ return runner;
+}
+function stop(runner) {
+ runner.effect.stop();
+}
+let shouldTrack = true;
+let pauseScheduleStack = 0;
+const trackStack = [];
+function pauseTracking() {
+ trackStack.push(shouldTrack);
+ shouldTrack = false;
+}
+function resetTracking() {
+ const last = trackStack.pop();
+ shouldTrack = last === void 0 ? true : last;
+}
+function pauseScheduling() {
+ pauseScheduleStack++;
+}
+function resetScheduling() {
+ pauseScheduleStack--;
+ while (!pauseScheduleStack && queueEffectSchedulers.length) {
+ queueEffectSchedulers.shift()();
+ }
+}
+function trackEffect(effect2, dep, debuggerEventExtraInfo) {
+ var _a;
+ if (dep.get(effect2) !== effect2._trackId) {
+ dep.set(effect2, effect2._trackId);
+ const oldDep = effect2.deps[effect2._depsLength];
+ if (oldDep !== dep) {
+ if (oldDep) {
+ cleanupDepEffect(oldDep, effect2);
+ }
+ effect2.deps[effect2._depsLength++] = dep;
+ } else {
+ effect2._depsLength++;
+ }
+ {
+ (_a = effect2.onTrack) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo));
+ }
+ }
+}
+const queueEffectSchedulers = [];
+function triggerEffects(dep, dirtyLevel, debuggerEventExtraInfo) {
+ var _a;
+ pauseScheduling();
+ for (const effect2 of dep.keys()) {
+ let tracking;
+ if (effect2._dirtyLevel < dirtyLevel && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) {
+ effect2._shouldSchedule || (effect2._shouldSchedule = effect2._dirtyLevel === 0);
+ effect2._dirtyLevel = dirtyLevel;
+ }
+ if (effect2._shouldSchedule && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) {
+ {
+ (_a = effect2.onTrigger) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo));
+ }
+ effect2.trigger();
+ if ((!effect2._runnings || effect2.allowRecurse) && effect2._dirtyLevel !== 2) {
+ effect2._shouldSchedule = false;
+ if (effect2.scheduler) {
+ queueEffectSchedulers.push(effect2.scheduler);
+ }
+ }
+ }
+ }
+ resetScheduling();
+}
+
+const createDep = (cleanup, computed) => {
+ const dep = /* @__PURE__ */ new Map();
+ dep.cleanup = cleanup;
+ dep.computed = computed;
+ return dep;
+};
+
+const targetMap = /* @__PURE__ */ new WeakMap();
+const ITERATE_KEY = Symbol("iterate" );
+const MAP_KEY_ITERATE_KEY = Symbol("Map key iterate" );
+function track(target, type, key) {
+ if (shouldTrack && activeEffect) {
+ let depsMap = targetMap.get(target);
+ if (!depsMap) {
+ targetMap.set(target, depsMap = /* @__PURE__ */ new Map());
+ }
+ let dep = depsMap.get(key);
+ if (!dep) {
+ depsMap.set(key, dep = createDep(() => depsMap.delete(key)));
+ }
+ trackEffect(
+ activeEffect,
+ dep,
+ {
+ target,
+ type,
+ key
+ }
+ );
+ }
+}
+function trigger(target, type, key, newValue, oldValue, oldTarget) {
+ const depsMap = targetMap.get(target);
+ if (!depsMap) {
+ return;
+ }
+ let deps = [];
+ if (type === "clear") {
+ deps = [...depsMap.values()];
+ } else if (key === "length" && isArray(target)) {
+ const newLength = Number(newValue);
+ depsMap.forEach((dep, key2) => {
+ if (key2 === "length" || !isSymbol(key2) && key2 >= newLength) {
+ deps.push(dep);
+ }
+ });
+ } else {
+ if (key !== void 0) {
+ deps.push(depsMap.get(key));
+ }
+ switch (type) {
+ case "add":
+ if (!isArray(target)) {
+ deps.push(depsMap.get(ITERATE_KEY));
+ if (isMap(target)) {
+ deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
+ }
+ } else if (isIntegerKey(key)) {
+ deps.push(depsMap.get("length"));
+ }
+ break;
+ case "delete":
+ if (!isArray(target)) {
+ deps.push(depsMap.get(ITERATE_KEY));
+ if (isMap(target)) {
+ deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
+ }
+ }
+ break;
+ case "set":
+ if (isMap(target)) {
+ deps.push(depsMap.get(ITERATE_KEY));
+ }
+ break;
+ }
+ }
+ pauseScheduling();
+ for (const dep of deps) {
+ if (dep) {
+ triggerEffects(
+ dep,
+ 4,
+ {
+ target,
+ type,
+ key,
+ newValue,
+ oldValue,
+ oldTarget
+ }
+ );
+ }
+ }
+ resetScheduling();
+}
+function getDepFromReactive(object, key) {
+ const depsMap = targetMap.get(object);
+ return depsMap && depsMap.get(key);
+}
+
+const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`);
+const builtInSymbols = new Set(
+ /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol)
+);
+const arrayInstrumentations = /* @__PURE__ */ createArrayInstrumentations();
+function createArrayInstrumentations() {
+ const instrumentations = {};
+ ["includes", "indexOf", "lastIndexOf"].forEach((key) => {
+ instrumentations[key] = function(...args) {
+ const arr = toRaw(this);
+ for (let i = 0, l = this.length; i < l; i++) {
+ track(arr, "get", i + "");
+ }
+ const res = arr[key](...args);
+ if (res === -1 || res === false) {
+ return arr[key](...args.map(toRaw));
+ } else {
+ return res;
+ }
+ };
+ });
+ ["push", "pop", "shift", "unshift", "splice"].forEach((key) => {
+ instrumentations[key] = function(...args) {
+ pauseTracking();
+ pauseScheduling();
+ const res = toRaw(this)[key].apply(this, args);
+ resetScheduling();
+ resetTracking();
+ return res;
+ };
+ });
+ return instrumentations;
+}
+function hasOwnProperty(key) {
+ if (!isSymbol(key)) key = String(key);
+ const obj = toRaw(this);
+ track(obj, "has", key);
+ return obj.hasOwnProperty(key);
+}
+class BaseReactiveHandler {
+ constructor(_isReadonly = false, _isShallow = false) {
+ this._isReadonly = _isReadonly;
+ this._isShallow = _isShallow;
+ }
+ get(target, key, receiver) {
+ const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow;
+ if (key === "__v_isReactive") {
+ return !isReadonly2;
+ } else if (key === "__v_isReadonly") {
+ return isReadonly2;
+ } else if (key === "__v_isShallow") {
+ return isShallow2;
+ } else if (key === "__v_raw") {
+ if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype
+ // this means the receiver is a user proxy of the reactive proxy
+ Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) {
+ return target;
+ }
+ return;
+ }
+ const targetIsArray = isArray(target);
+ if (!isReadonly2) {
+ if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
+ return Reflect.get(arrayInstrumentations, key, receiver);
+ }
+ if (key === "hasOwnProperty") {
+ return hasOwnProperty;
+ }
+ }
+ const res = Reflect.get(target, key, receiver);
+ if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
+ return res;
+ }
+ if (!isReadonly2) {
+ track(target, "get", key);
+ }
+ if (isShallow2) {
+ return res;
+ }
+ if (isRef(res)) {
+ return targetIsArray && isIntegerKey(key) ? res : res.value;
+ }
+ if (isObject(res)) {
+ return isReadonly2 ? readonly(res) : reactive(res);
+ }
+ return res;
+ }
+}
+class MutableReactiveHandler extends BaseReactiveHandler {
+ constructor(isShallow2 = false) {
+ super(false, isShallow2);
+ }
+ set(target, key, value, receiver) {
+ let oldValue = target[key];
+ if (!this._isShallow) {
+ const isOldValueReadonly = isReadonly(oldValue);
+ if (!isShallow(value) && !isReadonly(value)) {
+ oldValue = toRaw(oldValue);
+ value = toRaw(value);
+ }
+ if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
+ if (isOldValueReadonly) {
+ return false;
+ } else {
+ oldValue.value = value;
+ return true;
+ }
+ }
+ }
+ const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key);
+ const result = Reflect.set(target, key, value, receiver);
+ if (target === toRaw(receiver)) {
+ if (!hadKey) {
+ trigger(target, "add", key, value);
+ } else if (hasChanged(value, oldValue)) {
+ trigger(target, "set", key, value, oldValue);
+ }
+ }
+ return result;
+ }
+ deleteProperty(target, key) {
+ const hadKey = hasOwn(target, key);
+ const oldValue = target[key];
+ const result = Reflect.deleteProperty(target, key);
+ if (result && hadKey) {
+ trigger(target, "delete", key, void 0, oldValue);
+ }
+ return result;
+ }
+ has(target, key) {
+ const result = Reflect.has(target, key);
+ if (!isSymbol(key) || !builtInSymbols.has(key)) {
+ track(target, "has", key);
+ }
+ return result;
+ }
+ ownKeys(target) {
+ track(
+ target,
+ "iterate",
+ isArray(target) ? "length" : ITERATE_KEY
+ );
+ return Reflect.ownKeys(target);
+ }
+}
+class ReadonlyReactiveHandler extends BaseReactiveHandler {
+ constructor(isShallow2 = false) {
+ super(true, isShallow2);
+ }
+ set(target, key) {
+ {
+ warn$2(
+ `Set operation on key "${String(key)}" failed: target is readonly.`,
+ target
+ );
+ }
+ return true;
+ }
+ deleteProperty(target, key) {
+ {
+ warn$2(
+ `Delete operation on key "${String(key)}" failed: target is readonly.`,
+ target
+ );
+ }
+ return true;
+ }
+}
+const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler();
+const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler();
+const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler(
+ true
+);
+const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true);
+
+const toShallow = (value) => value;
+const getProto = (v) => Reflect.getPrototypeOf(v);
+function get(target, key, isReadonly2 = false, isShallow2 = false) {
+ target = target["__v_raw"];
+ const rawTarget = toRaw(target);
+ const rawKey = toRaw(key);
+ if (!isReadonly2) {
+ if (hasChanged(key, rawKey)) {
+ track(rawTarget, "get", key);
+ }
+ track(rawTarget, "get", rawKey);
+ }
+ const { has: has2 } = getProto(rawTarget);
+ const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive;
+ if (has2.call(rawTarget, key)) {
+ return wrap(target.get(key));
+ } else if (has2.call(rawTarget, rawKey)) {
+ return wrap(target.get(rawKey));
+ } else if (target !== rawTarget) {
+ target.get(key);
+ }
+}
+function has(key, isReadonly2 = false) {
+ const target = this["__v_raw"];
+ const rawTarget = toRaw(target);
+ const rawKey = toRaw(key);
+ if (!isReadonly2) {
+ if (hasChanged(key, rawKey)) {
+ track(rawTarget, "has", key);
+ }
+ track(rawTarget, "has", rawKey);
+ }
+ return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey);
+}
+function size(target, isReadonly2 = false) {
+ target = target["__v_raw"];
+ !isReadonly2 && track(toRaw(target), "iterate", ITERATE_KEY);
+ return Reflect.get(target, "size", target);
+}
+function add(value, _isShallow = false) {
+ if (!_isShallow && !isShallow(value) && !isReadonly(value)) {
+ value = toRaw(value);
+ }
+ const target = toRaw(this);
+ const proto = getProto(target);
+ const hadKey = proto.has.call(target, value);
+ if (!hadKey) {
+ target.add(value);
+ trigger(target, "add", value, value);
+ }
+ return this;
+}
+function set(key, value, _isShallow = false) {
+ if (!_isShallow && !isShallow(value) && !isReadonly(value)) {
+ value = toRaw(value);
+ }
+ const target = toRaw(this);
+ const { has: has2, get: get2 } = getProto(target);
+ let hadKey = has2.call(target, key);
+ if (!hadKey) {
+ key = toRaw(key);
+ hadKey = has2.call(target, key);
+ } else {
+ checkIdentityKeys(target, has2, key);
+ }
+ const oldValue = get2.call(target, key);
+ target.set(key, value);
+ if (!hadKey) {
+ trigger(target, "add", key, value);
+ } else if (hasChanged(value, oldValue)) {
+ trigger(target, "set", key, value, oldValue);
+ }
+ return this;
+}
+function deleteEntry(key) {
+ const target = toRaw(this);
+ const { has: has2, get: get2 } = getProto(target);
+ let hadKey = has2.call(target, key);
+ if (!hadKey) {
+ key = toRaw(key);
+ hadKey = has2.call(target, key);
+ } else {
+ checkIdentityKeys(target, has2, key);
+ }
+ const oldValue = get2 ? get2.call(target, key) : void 0;
+ const result = target.delete(key);
+ if (hadKey) {
+ trigger(target, "delete", key, void 0, oldValue);
+ }
+ return result;
+}
+function clear() {
+ const target = toRaw(this);
+ const hadItems = target.size !== 0;
+ const oldTarget = isMap(target) ? new Map(target) : new Set(target) ;
+ const result = target.clear();
+ if (hadItems) {
+ trigger(target, "clear", void 0, void 0, oldTarget);
+ }
+ return result;
+}
+function createForEach(isReadonly2, isShallow2) {
+ return function forEach(callback, thisArg) {
+ const observed = this;
+ const target = observed["__v_raw"];
+ const rawTarget = toRaw(target);
+ const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive;
+ !isReadonly2 && track(rawTarget, "iterate", ITERATE_KEY);
+ return target.forEach((value, key) => {
+ return callback.call(thisArg, wrap(value), wrap(key), observed);
+ });
+ };
+}
+function createIterableMethod(method, isReadonly2, isShallow2) {
+ return function(...args) {
+ const target = this["__v_raw"];
+ const rawTarget = toRaw(target);
+ const targetIsMap = isMap(rawTarget);
+ const isPair = method === "entries" || method === Symbol.iterator && targetIsMap;
+ const isKeyOnly = method === "keys" && targetIsMap;
+ const innerIterator = target[method](...args);
+ const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive;
+ !isReadonly2 && track(
+ rawTarget,
+ "iterate",
+ isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY
+ );
+ return {
+ // iterator protocol
+ next() {
+ const { value, done } = innerIterator.next();
+ return done ? { value, done } : {
+ value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
+ done
+ };
+ },
+ // iterable protocol
+ [Symbol.iterator]() {
+ return this;
+ }
+ };
+ };
+}
+function createReadonlyMethod(type) {
+ return function(...args) {
+ {
+ const key = args[0] ? `on key "${args[0]}" ` : ``;
+ warn$2(
+ `${capitalize(type)} operation ${key}failed: target is readonly.`,
+ toRaw(this)
+ );
+ }
+ return type === "delete" ? false : type === "clear" ? void 0 : this;
+ };
+}
+function createInstrumentations() {
+ const mutableInstrumentations2 = {
+ get(key) {
+ return get(this, key);
+ },
+ get size() {
+ return size(this);
+ },
+ has,
+ add,
+ set,
+ delete: deleteEntry,
+ clear,
+ forEach: createForEach(false, false)
+ };
+ const shallowInstrumentations2 = {
+ get(key) {
+ return get(this, key, false, true);
+ },
+ get size() {
+ return size(this);
+ },
+ has,
+ add(value) {
+ return add.call(this, value, true);
+ },
+ set(key, value) {
+ return set.call(this, key, value, true);
+ },
+ delete: deleteEntry,
+ clear,
+ forEach: createForEach(false, true)
+ };
+ const readonlyInstrumentations2 = {
+ get(key) {
+ return get(this, key, true);
+ },
+ get size() {
+ return size(this, true);
+ },
+ has(key) {
+ return has.call(this, key, true);
+ },
+ add: createReadonlyMethod("add"),
+ set: createReadonlyMethod("set"),
+ delete: createReadonlyMethod("delete"),
+ clear: createReadonlyMethod("clear"),
+ forEach: createForEach(true, false)
+ };
+ const shallowReadonlyInstrumentations2 = {
+ get(key) {
+ return get(this, key, true, true);
+ },
+ get size() {
+ return size(this, true);
+ },
+ has(key) {
+ return has.call(this, key, true);
+ },
+ add: createReadonlyMethod("add"),
+ set: createReadonlyMethod("set"),
+ delete: createReadonlyMethod("delete"),
+ clear: createReadonlyMethod("clear"),
+ forEach: createForEach(true, true)
+ };
+ const iteratorMethods = [
+ "keys",
+ "values",
+ "entries",
+ Symbol.iterator
+ ];
+ iteratorMethods.forEach((method) => {
+ mutableInstrumentations2[method] = createIterableMethod(method, false, false);
+ readonlyInstrumentations2[method] = createIterableMethod(method, true, false);
+ shallowInstrumentations2[method] = createIterableMethod(method, false, true);
+ shallowReadonlyInstrumentations2[method] = createIterableMethod(
+ method,
+ true,
+ true
+ );
+ });
+ return [
+ mutableInstrumentations2,
+ readonlyInstrumentations2,
+ shallowInstrumentations2,
+ shallowReadonlyInstrumentations2
+ ];
+}
+const [
+ mutableInstrumentations,
+ readonlyInstrumentations,
+ shallowInstrumentations,
+ shallowReadonlyInstrumentations
+] = /* @__PURE__ */ createInstrumentations();
+function createInstrumentationGetter(isReadonly2, shallow) {
+ const instrumentations = shallow ? isReadonly2 ? shallowReadonlyInstrumentations : shallowInstrumentations : isReadonly2 ? readonlyInstrumentations : mutableInstrumentations;
+ return (target, key, receiver) => {
+ if (key === "__v_isReactive") {
+ return !isReadonly2;
+ } else if (key === "__v_isReadonly") {
+ return isReadonly2;
+ } else if (key === "__v_raw") {
+ return target;
+ }
+ return Reflect.get(
+ hasOwn(instrumentations, key) && key in target ? instrumentations : target,
+ key,
+ receiver
+ );
+ };
+}
+const mutableCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(false, false)
+};
+const shallowCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(false, true)
+};
+const readonlyCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(true, false)
+};
+const shallowReadonlyCollectionHandlers = {
+ get: /* @__PURE__ */ createInstrumentationGetter(true, true)
+};
+function checkIdentityKeys(target, has2, key) {
+ const rawKey = toRaw(key);
+ if (rawKey !== key && has2.call(target, rawKey)) {
+ const type = toRawType(target);
+ warn$2(
+ `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`
+ );
+ }
+}
+
+const reactiveMap = /* @__PURE__ */ new WeakMap();
+const shallowReactiveMap = /* @__PURE__ */ new WeakMap();
+const readonlyMap = /* @__PURE__ */ new WeakMap();
+const shallowReadonlyMap = /* @__PURE__ */ new WeakMap();
+function targetTypeMap(rawType) {
+ switch (rawType) {
+ case "Object":
+ case "Array":
+ return 1 /* COMMON */;
+ case "Map":
+ case "Set":
+ case "WeakMap":
+ case "WeakSet":
+ return 2 /* COLLECTION */;
+ default:
+ return 0 /* INVALID */;
+ }
+}
+function getTargetType(value) {
+ return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value));
+}
+function reactive(target) {
+ if (isReadonly(target)) {
+ return target;
+ }
+ return createReactiveObject(
+ target,
+ false,
+ mutableHandlers,
+ mutableCollectionHandlers,
+ reactiveMap
+ );
+}
+function shallowReactive(target) {
+ return createReactiveObject(
+ target,
+ false,
+ shallowReactiveHandlers,
+ shallowCollectionHandlers,
+ shallowReactiveMap
+ );
+}
+function readonly(target) {
+ return createReactiveObject(
+ target,
+ true,
+ readonlyHandlers,
+ readonlyCollectionHandlers,
+ readonlyMap
+ );
+}
+function shallowReadonly(target) {
+ return createReactiveObject(
+ target,
+ true,
+ shallowReadonlyHandlers,
+ shallowReadonlyCollectionHandlers,
+ shallowReadonlyMap
+ );
+}
+function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) {
+ if (!isObject(target)) {
+ {
+ warn$2(
+ `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String(
+ target
+ )}`
+ );
+ }
+ return target;
+ }
+ if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) {
+ return target;
+ }
+ const existingProxy = proxyMap.get(target);
+ if (existingProxy) {
+ return existingProxy;
+ }
+ const targetType = getTargetType(target);
+ if (targetType === 0 /* INVALID */) {
+ return target;
+ }
+ const proxy = new Proxy(
+ target,
+ targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers
+ );
+ proxyMap.set(target, proxy);
+ return proxy;
+}
+function isReactive(value) {
+ if (isReadonly(value)) {
+ return isReactive(value["__v_raw"]);
+ }
+ return !!(value && value["__v_isReactive"]);
+}
+function isReadonly(value) {
+ return !!(value && value["__v_isReadonly"]);
+}
+function isShallow(value) {
+ return !!(value && value["__v_isShallow"]);
+}
+function isProxy(value) {
+ return value ? !!value["__v_raw"] : false;
+}
+function toRaw(observed) {
+ const raw = observed && observed["__v_raw"];
+ return raw ? toRaw(raw) : observed;
+}
+function markRaw(value) {
+ if (Object.isExtensible(value)) {
+ def(value, "__v_skip", true);
+ }
+ return value;
+}
+const toReactive = (value) => isObject(value) ? reactive(value) : value;
+const toReadonly = (value) => isObject(value) ? readonly(value) : value;
+
+const COMPUTED_SIDE_EFFECT_WARN = `Computed is still dirty after getter evaluation, likely because a computed is mutating its own dependency in its getter. State mutations in computed getters should be avoided. Check the docs for more details: https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free`;
+class ComputedRefImpl {
+ constructor(getter, _setter, isReadonly, isSSR) {
+ this.getter = getter;
+ this._setter = _setter;
+ this.dep = void 0;
+ this.__v_isRef = true;
+ this["__v_isReadonly"] = false;
+ this.effect = new ReactiveEffect(
+ () => getter(this._value),
+ () => triggerRefValue(
+ this,
+ this.effect._dirtyLevel === 2 ? 2 : 3
+ )
+ );
+ this.effect.computed = this;
+ this.effect.active = this._cacheable = !isSSR;
+ this["__v_isReadonly"] = isReadonly;
+ }
+ get value() {
+ const self = toRaw(this);
+ if ((!self._cacheable || self.effect.dirty) && hasChanged(self._value, self._value = self.effect.run())) {
+ triggerRefValue(self, 4);
+ }
+ trackRefValue(self);
+ if (self.effect._dirtyLevel >= 2) {
+ if (this._warnRecursive) {
+ warn$2(COMPUTED_SIDE_EFFECT_WARN, `
+
+getter: `, this.getter);
+ }
+ triggerRefValue(self, 2);
+ }
+ return self._value;
+ }
+ set value(newValue) {
+ this._setter(newValue);
+ }
+ // #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x
+ get _dirty() {
+ return this.effect.dirty;
+ }
+ set _dirty(v) {
+ this.effect.dirty = v;
+ }
+ // #endregion
+}
+function computed$1(getterOrOptions, debugOptions, isSSR = false) {
+ let getter;
+ let setter;
+ const onlyGetter = isFunction(getterOrOptions);
+ if (onlyGetter) {
+ getter = getterOrOptions;
+ setter = () => {
+ warn$2("Write operation failed: computed value is readonly");
+ } ;
+ } else {
+ getter = getterOrOptions.get;
+ setter = getterOrOptions.set;
+ }
+ const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR);
+ if (debugOptions && !isSSR) {
+ cRef.effect.onTrack = debugOptions.onTrack;
+ cRef.effect.onTrigger = debugOptions.onTrigger;
+ }
+ return cRef;
+}
+
+function trackRefValue(ref2) {
+ var _a;
+ if (shouldTrack && activeEffect) {
+ ref2 = toRaw(ref2);
+ trackEffect(
+ activeEffect,
+ (_a = ref2.dep) != null ? _a : ref2.dep = createDep(
+ () => ref2.dep = void 0,
+ ref2 instanceof ComputedRefImpl ? ref2 : void 0
+ ),
+ {
+ target: ref2,
+ type: "get",
+ key: "value"
+ }
+ );
+ }
+}
+function triggerRefValue(ref2, dirtyLevel = 4, newVal, oldVal) {
+ ref2 = toRaw(ref2);
+ const dep = ref2.dep;
+ if (dep) {
+ triggerEffects(
+ dep,
+ dirtyLevel,
+ {
+ target: ref2,
+ type: "set",
+ key: "value",
+ newValue: newVal,
+ oldValue: oldVal
+ }
+ );
+ }
+}
+function isRef(r) {
+ return !!(r && r.__v_isRef === true);
+}
+function ref(value) {
+ return createRef(value, false);
+}
+function shallowRef(value) {
+ return createRef(value, true);
+}
+function createRef(rawValue, shallow) {
+ if (isRef(rawValue)) {
+ return rawValue;
+ }
+ return new RefImpl(rawValue, shallow);
+}
+class RefImpl {
+ constructor(value, __v_isShallow) {
+ this.__v_isShallow = __v_isShallow;
+ this.dep = void 0;
+ this.__v_isRef = true;
+ this._rawValue = __v_isShallow ? value : toRaw(value);
+ this._value = __v_isShallow ? value : toReactive(value);
+ }
+ get value() {
+ trackRefValue(this);
+ return this._value;
+ }
+ set value(newVal) {
+ const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal);
+ newVal = useDirectValue ? newVal : toRaw(newVal);
+ if (hasChanged(newVal, this._rawValue)) {
+ const oldVal = this._rawValue;
+ this._rawValue = newVal;
+ this._value = useDirectValue ? newVal : toReactive(newVal);
+ triggerRefValue(this, 4, newVal, oldVal);
+ }
+ }
+}
+function triggerRef(ref2) {
+ triggerRefValue(ref2, 4, ref2.value );
+}
+function unref(ref2) {
+ return isRef(ref2) ? ref2.value : ref2;
+}
+function toValue(source) {
+ return isFunction(source) ? source() : unref(source);
+}
+const shallowUnwrapHandlers = {
+ get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
+ set: (target, key, value, receiver) => {
+ const oldValue = target[key];
+ if (isRef(oldValue) && !isRef(value)) {
+ oldValue.value = value;
+ return true;
+ } else {
+ return Reflect.set(target, key, value, receiver);
+ }
+ }
+};
+function proxyRefs(objectWithRefs) {
+ return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers);
+}
+class CustomRefImpl {
+ constructor(factory) {
+ this.dep = void 0;
+ this.__v_isRef = true;
+ const { get, set } = factory(
+ () => trackRefValue(this),
+ () => triggerRefValue(this)
+ );
+ this._get = get;
+ this._set = set;
+ }
+ get value() {
+ return this._get();
+ }
+ set value(newVal) {
+ this._set(newVal);
+ }
+}
+function customRef(factory) {
+ return new CustomRefImpl(factory);
+}
+function toRefs(object) {
+ if (!isProxy(object)) {
+ warn$2(`toRefs() expects a reactive object but received a plain one.`);
+ }
+ const ret = isArray(object) ? new Array(object.length) : {};
+ for (const key in object) {
+ ret[key] = propertyToRef(object, key);
+ }
+ return ret;
+}
+class ObjectRefImpl {
+ constructor(_object, _key, _defaultValue) {
+ this._object = _object;
+ this._key = _key;
+ this._defaultValue = _defaultValue;
+ this.__v_isRef = true;
+ }
+ get value() {
+ const val = this._object[this._key];
+ return val === void 0 ? this._defaultValue : val;
+ }
+ set value(newVal) {
+ this._object[this._key] = newVal;
+ }
+ get dep() {
+ return getDepFromReactive(toRaw(this._object), this._key);
+ }
+}
+class GetterRefImpl {
+ constructor(_getter) {
+ this._getter = _getter;
+ this.__v_isRef = true;
+ this.__v_isReadonly = true;
+ }
+ get value() {
+ return this._getter();
+ }
+}
+function toRef(source, key, defaultValue) {
+ if (isRef(source)) {
+ return source;
+ } else if (isFunction(source)) {
+ return new GetterRefImpl(source);
+ } else if (isObject(source) && arguments.length > 1) {
+ return propertyToRef(source, key, defaultValue);
+ } else {
+ return ref(source);
+ }
+}
+function propertyToRef(source, key, defaultValue) {
+ const val = source[key];
+ return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue);
+}
+
+const TrackOpTypes = {
+ "GET": "get",
+ "HAS": "has",
+ "ITERATE": "iterate"
+};
+const TriggerOpTypes = {
+ "SET": "set",
+ "ADD": "add",
+ "DELETE": "delete",
+ "CLEAR": "clear"
+};
+
+const stack$1 = [];
+function pushWarningContext(vnode) {
+ stack$1.push(vnode);
+}
+function popWarningContext() {
+ stack$1.pop();
+}
+let isWarning = false;
+function warn$1(msg, ...args) {
+ if (isWarning) return;
+ isWarning = true;
+ pauseTracking();
+ const instance = stack$1.length ? stack$1[stack$1.length - 1].component : null;
+ const appWarnHandler = instance && instance.appContext.config.warnHandler;
+ const trace = getComponentTrace();
+ if (appWarnHandler) {
+ callWithErrorHandling(
+ appWarnHandler,
+ instance,
+ 11,
+ [
+ // eslint-disable-next-line no-restricted-syntax
+ msg + args.map((a) => {
+ var _a, _b;
+ return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a);
+ }).join(""),
+ instance && instance.proxy,
+ trace.map(
+ ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>`
+ ).join("\n"),
+ trace
+ ]
+ );
+ } else {
+ const warnArgs = [`[Vue warn]: ${msg}`, ...args];
+ if (trace.length && // avoid spamming console during tests
+ true) {
+ warnArgs.push(`
+`, ...formatTrace(trace));
+ }
+ console.warn(...warnArgs);
+ }
+ resetTracking();
+ isWarning = false;
+}
+function getComponentTrace() {
+ let currentVNode = stack$1[stack$1.length - 1];
+ if (!currentVNode) {
+ return [];
+ }
+ const normalizedStack = [];
+ while (currentVNode) {
+ const last = normalizedStack[0];
+ if (last && last.vnode === currentVNode) {
+ last.recurseCount++;
+ } else {
+ normalizedStack.push({
+ vnode: currentVNode,
+ recurseCount: 0
+ });
+ }
+ const parentInstance = currentVNode.component && currentVNode.component.parent;
+ currentVNode = parentInstance && parentInstance.vnode;
+ }
+ return normalizedStack;
+}
+function formatTrace(trace) {
+ const logs = [];
+ trace.forEach((entry, i) => {
+ logs.push(...i === 0 ? [] : [`
+`], ...formatTraceEntry(entry));
+ });
+ return logs;
+}
+function formatTraceEntry({ vnode, recurseCount }) {
+ const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``;
+ const isRoot = vnode.component ? vnode.component.parent == null : false;
+ const open = ` at <${formatComponentName(
+ vnode.component,
+ vnode.type,
+ isRoot
+ )}`;
+ const close = `>` + postfix;
+ return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close];
+}
+function formatProps(props) {
+ const res = [];
+ const keys = Object.keys(props);
+ keys.slice(0, 3).forEach((key) => {
+ res.push(...formatProp(key, props[key]));
+ });
+ if (keys.length > 3) {
+ res.push(` ...`);
+ }
+ return res;
+}
+function formatProp(key, value, raw) {
+ if (isString(value)) {
+ value = JSON.stringify(value);
+ return raw ? value : [`${key}=${value}`];
+ } else if (typeof value === "number" || typeof value === "boolean" || value == null) {
+ return raw ? value : [`${key}=${value}`];
+ } else if (isRef(value)) {
+ value = formatProp(key, toRaw(value.value), true);
+ return raw ? value : [`${key}=Ref<`, value, `>`];
+ } else if (isFunction(value)) {
+ return [`${key}=fn${value.name ? `<${value.name}>` : ``}`];
+ } else {
+ value = toRaw(value);
+ return raw ? value : [`${key}=`, value];
+ }
+}
+function assertNumber(val, type) {
+ if (val === void 0) {
+ return;
+ } else if (typeof val !== "number") {
+ warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`);
+ } else if (isNaN(val)) {
+ warn$1(`${type} is NaN - the duration expression might be incorrect.`);
+ }
+}
+
+const ErrorCodes = {
+ "SETUP_FUNCTION": 0,
+ "0": "SETUP_FUNCTION",
+ "RENDER_FUNCTION": 1,
+ "1": "RENDER_FUNCTION",
+ "WATCH_GETTER": 2,
+ "2": "WATCH_GETTER",
+ "WATCH_CALLBACK": 3,
+ "3": "WATCH_CALLBACK",
+ "WATCH_CLEANUP": 4,
+ "4": "WATCH_CLEANUP",
+ "NATIVE_EVENT_HANDLER": 5,
+ "5": "NATIVE_EVENT_HANDLER",
+ "COMPONENT_EVENT_HANDLER": 6,
+ "6": "COMPONENT_EVENT_HANDLER",
+ "VNODE_HOOK": 7,
+ "7": "VNODE_HOOK",
+ "DIRECTIVE_HOOK": 8,
+ "8": "DIRECTIVE_HOOK",
+ "TRANSITION_HOOK": 9,
+ "9": "TRANSITION_HOOK",
+ "APP_ERROR_HANDLER": 10,
+ "10": "APP_ERROR_HANDLER",
+ "APP_WARN_HANDLER": 11,
+ "11": "APP_WARN_HANDLER",
+ "FUNCTION_REF": 12,
+ "12": "FUNCTION_REF",
+ "ASYNC_COMPONENT_LOADER": 13,
+ "13": "ASYNC_COMPONENT_LOADER",
+ "SCHEDULER": 14,
+ "14": "SCHEDULER",
+ "COMPONENT_UPDATE": 15,
+ "15": "COMPONENT_UPDATE"
+};
+const ErrorTypeStrings$1 = {
+ ["sp"]: "serverPrefetch hook",
+ ["bc"]: "beforeCreate hook",
+ ["c"]: "created hook",
+ ["bm"]: "beforeMount hook",
+ ["m"]: "mounted hook",
+ ["bu"]: "beforeUpdate hook",
+ ["u"]: "updated",
+ ["bum"]: "beforeUnmount hook",
+ ["um"]: "unmounted hook",
+ ["a"]: "activated hook",
+ ["da"]: "deactivated hook",
+ ["ec"]: "errorCaptured hook",
+ ["rtc"]: "renderTracked hook",
+ ["rtg"]: "renderTriggered hook",
+ [0]: "setup function",
+ [1]: "render function",
+ [2]: "watcher getter",
+ [3]: "watcher callback",
+ [4]: "watcher cleanup function",
+ [5]: "native event handler",
+ [6]: "component event handler",
+ [7]: "vnode hook",
+ [8]: "directive hook",
+ [9]: "transition hook",
+ [10]: "app errorHandler",
+ [11]: "app warnHandler",
+ [12]: "ref function",
+ [13]: "async component loader",
+ [14]: "scheduler flush",
+ [15]: "component update"
+};
+function callWithErrorHandling(fn, instance, type, args) {
+ try {
+ return args ? fn(...args) : fn();
+ } catch (err) {
+ handleError(err, instance, type);
+ }
+}
+function callWithAsyncErrorHandling(fn, instance, type, args) {
+ if (isFunction(fn)) {
+ const res = callWithErrorHandling(fn, instance, type, args);
+ if (res && isPromise(res)) {
+ res.catch((err) => {
+ handleError(err, instance, type);
+ });
+ }
+ return res;
+ }
+ if (isArray(fn)) {
+ const values = [];
+ for (let i = 0; i < fn.length; i++) {
+ values.push(callWithAsyncErrorHandling(fn[i], instance, type, args));
+ }
+ return values;
+ } else {
+ warn$1(
+ `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}`
+ );
+ }
+}
+function handleError(err, instance, type, throwInDev = true) {
+ const contextVNode = instance ? instance.vnode : null;
+ if (instance) {
+ let cur = instance.parent;
+ const exposedInstance = instance.proxy;
+ const errorInfo = ErrorTypeStrings$1[type] ;
+ while (cur) {
+ const errorCapturedHooks = cur.ec;
+ if (errorCapturedHooks) {
+ for (let i = 0; i < errorCapturedHooks.length; i++) {
+ if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) {
+ return;
+ }
+ }
+ }
+ cur = cur.parent;
+ }
+ const appErrorHandler = instance.appContext.config.errorHandler;
+ if (appErrorHandler) {
+ pauseTracking();
+ callWithErrorHandling(
+ appErrorHandler,
+ null,
+ 10,
+ [err, exposedInstance, errorInfo]
+ );
+ resetTracking();
+ return;
+ }
+ }
+ logError(err, type, contextVNode, throwInDev);
+}
+function logError(err, type, contextVNode, throwInDev = true) {
+ {
+ const info = ErrorTypeStrings$1[type];
+ if (contextVNode) {
+ pushWarningContext(contextVNode);
+ }
+ warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`);
+ if (contextVNode) {
+ popWarningContext();
+ }
+ if (throwInDev) {
+ throw err;
+ } else {
+ console.error(err);
+ }
+ }
+}
+
+let isFlushing = false;
+let isFlushPending = false;
+const queue = [];
+let flushIndex = 0;
+const pendingPostFlushCbs = [];
+let activePostFlushCbs = null;
+let postFlushIndex = 0;
+const resolvedPromise = /* @__PURE__ */ Promise.resolve();
+let currentFlushPromise = null;
+const RECURSION_LIMIT = 100;
+function nextTick(fn) {
+ const p = currentFlushPromise || resolvedPromise;
+ return fn ? p.then(this ? fn.bind(this) : fn) : p;
+}
+function findInsertionIndex(id) {
+ let start = flushIndex + 1;
+ let end = queue.length;
+ while (start < end) {
+ const middle = start + end >>> 1;
+ const middleJob = queue[middle];
+ const middleJobId = getId(middleJob);
+ if (middleJobId < id || middleJobId === id && middleJob.pre) {
+ start = middle + 1;
+ } else {
+ end = middle;
+ }
+ }
+ return start;
+}
+function queueJob(job) {
+ if (!queue.length || !queue.includes(
+ job,
+ isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
+ )) {
+ if (job.id == null) {
+ queue.push(job);
+ } else {
+ queue.splice(findInsertionIndex(job.id), 0, job);
+ }
+ queueFlush();
+ }
+}
+function queueFlush() {
+ if (!isFlushing && !isFlushPending) {
+ isFlushPending = true;
+ currentFlushPromise = resolvedPromise.then(flushJobs);
+ }
+}
+function invalidateJob(job) {
+ const i = queue.indexOf(job);
+ if (i > flushIndex) {
+ queue.splice(i, 1);
+ }
+}
+function queuePostFlushCb(cb) {
+ if (!isArray(cb)) {
+ if (!activePostFlushCbs || !activePostFlushCbs.includes(
+ cb,
+ cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
+ )) {
+ pendingPostFlushCbs.push(cb);
+ }
+ } else {
+ pendingPostFlushCbs.push(...cb);
+ }
+ queueFlush();
+}
+function flushPreFlushCbs(instance, seen, i = isFlushing ? flushIndex + 1 : 0) {
+ {
+ seen = seen || /* @__PURE__ */ new Map();
+ }
+ for (; i < queue.length; i++) {
+ const cb = queue[i];
+ if (cb && cb.pre) {
+ if (instance && cb.id !== instance.uid) {
+ continue;
+ }
+ if (checkRecursiveUpdates(seen, cb)) {
+ continue;
+ }
+ queue.splice(i, 1);
+ i--;
+ cb();
+ }
+ }
+}
+function flushPostFlushCbs(seen) {
+ if (pendingPostFlushCbs.length) {
+ const deduped = [...new Set(pendingPostFlushCbs)].sort(
+ (a, b) => getId(a) - getId(b)
+ );
+ pendingPostFlushCbs.length = 0;
+ if (activePostFlushCbs) {
+ activePostFlushCbs.push(...deduped);
+ return;
+ }
+ activePostFlushCbs = deduped;
+ {
+ seen = seen || /* @__PURE__ */ new Map();
+ }
+ for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) {
+ const cb = activePostFlushCbs[postFlushIndex];
+ if (checkRecursiveUpdates(seen, cb)) {
+ continue;
+ }
+ if (cb.active !== false) cb();
+ }
+ activePostFlushCbs = null;
+ postFlushIndex = 0;
+ }
+}
+const getId = (job) => job.id == null ? Infinity : job.id;
+const comparator = (a, b) => {
+ const diff = getId(a) - getId(b);
+ if (diff === 0) {
+ if (a.pre && !b.pre) return -1;
+ if (b.pre && !a.pre) return 1;
+ }
+ return diff;
+};
+function flushJobs(seen) {
+ isFlushPending = false;
+ isFlushing = true;
+ {
+ seen = seen || /* @__PURE__ */ new Map();
+ }
+ queue.sort(comparator);
+ const check = (job) => checkRecursiveUpdates(seen, job) ;
+ try {
+ for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
+ const job = queue[flushIndex];
+ if (job && job.active !== false) {
+ if (check(job)) {
+ continue;
+ }
+ callWithErrorHandling(
+ job,
+ job.i,
+ job.i ? 15 : 14
+ );
+ }
+ }
+ } finally {
+ flushIndex = 0;
+ queue.length = 0;
+ flushPostFlushCbs(seen);
+ isFlushing = false;
+ currentFlushPromise = null;
+ if (queue.length || pendingPostFlushCbs.length) {
+ flushJobs(seen);
+ }
+ }
+}
+function checkRecursiveUpdates(seen, fn) {
+ if (!seen.has(fn)) {
+ seen.set(fn, 1);
+ } else {
+ const count = seen.get(fn);
+ if (count > RECURSION_LIMIT) {
+ const instance = fn.i;
+ const componentName = instance && getComponentName(instance.type);
+ handleError(
+ `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`,
+ null,
+ 10
+ );
+ return true;
+ } else {
+ seen.set(fn, count + 1);
+ }
+ }
+}
+
+let isHmrUpdating = false;
+const hmrDirtyComponents = /* @__PURE__ */ new Map();
+{
+ getGlobalThis().__VUE_HMR_RUNTIME__ = {
+ createRecord: tryWrap(createRecord),
+ rerender: tryWrap(rerender),
+ reload: tryWrap(reload)
+ };
+}
+const map = /* @__PURE__ */ new Map();
+function registerHMR(instance) {
+ const id = instance.type.__hmrId;
+ let record = map.get(id);
+ if (!record) {
+ createRecord(id, instance.type);
+ record = map.get(id);
+ }
+ record.instances.add(instance);
+}
+function unregisterHMR(instance) {
+ map.get(instance.type.__hmrId).instances.delete(instance);
+}
+function createRecord(id, initialDef) {
+ if (map.has(id)) {
+ return false;
+ }
+ map.set(id, {
+ initialDef: normalizeClassComponent(initialDef),
+ instances: /* @__PURE__ */ new Set()
+ });
+ return true;
+}
+function normalizeClassComponent(component) {
+ return isClassComponent(component) ? component.__vccOpts : component;
+}
+function rerender(id, newRender) {
+ const record = map.get(id);
+ if (!record) {
+ return;
+ }
+ record.initialDef.render = newRender;
+ [...record.instances].forEach((instance) => {
+ if (newRender) {
+ instance.render = newRender;
+ normalizeClassComponent(instance.type).render = newRender;
+ }
+ instance.renderCache = [];
+ isHmrUpdating = true;
+ instance.effect.dirty = true;
+ instance.update();
+ isHmrUpdating = false;
+ });
+}
+function reload(id, newComp) {
+ const record = map.get(id);
+ if (!record) return;
+ newComp = normalizeClassComponent(newComp);
+ updateComponentDef(record.initialDef, newComp);
+ const instances = [...record.instances];
+ for (let i = 0; i < instances.length; i++) {
+ const instance = instances[i];
+ const oldComp = normalizeClassComponent(instance.type);
+ let dirtyInstances = hmrDirtyComponents.get(oldComp);
+ if (!dirtyInstances) {
+ if (oldComp !== record.initialDef) {
+ updateComponentDef(oldComp, newComp);
+ }
+ hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set());
+ }
+ dirtyInstances.add(instance);
+ instance.appContext.propsCache.delete(instance.type);
+ instance.appContext.emitsCache.delete(instance.type);
+ instance.appContext.optionsCache.delete(instance.type);
+ if (instance.ceReload) {
+ dirtyInstances.add(instance);
+ instance.ceReload(newComp.styles);
+ dirtyInstances.delete(instance);
+ } else if (instance.parent) {
+ instance.parent.effect.dirty = true;
+ queueJob(() => {
+ instance.parent.update();
+ dirtyInstances.delete(instance);
+ });
+ } else if (instance.appContext.reload) {
+ instance.appContext.reload();
+ } else if (typeof window !== "undefined") {
+ window.location.reload();
+ } else {
+ console.warn(
+ "[HMR] Root or manually mounted instance modified. Full reload required."
+ );
+ }
+ }
+ queuePostFlushCb(() => {
+ hmrDirtyComponents.clear();
+ });
+}
+function updateComponentDef(oldComp, newComp) {
+ extend(oldComp, newComp);
+ for (const key in oldComp) {
+ if (key !== "__file" && !(key in newComp)) {
+ delete oldComp[key];
+ }
+ }
+}
+function tryWrap(fn) {
+ return (id, arg) => {
+ try {
+ return fn(id, arg);
+ } catch (e) {
+ console.error(e);
+ console.warn(
+ `[HMR] Something went wrong during Vue component hot-reload. Full reload required.`
+ );
+ }
+ };
+}
+
+let devtools$1;
+let buffer = [];
+let devtoolsNotInstalled = false;
+function emit$1(event, ...args) {
+ if (devtools$1) {
+ devtools$1.emit(event, ...args);
+ } else if (!devtoolsNotInstalled) {
+ buffer.push({ event, args });
+ }
+}
+function setDevtoolsHook$1(hook, target) {
+ var _a, _b;
+ devtools$1 = hook;
+ if (devtools$1) {
+ devtools$1.enabled = true;
+ buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args));
+ buffer = [];
+ } else if (
+ // handle late devtools injection - only do this if we are in an actual
+ // browser environment to avoid the timer handle stalling test runner exit
+ // (#4815)
+ typeof window !== "undefined" && // some envs mock window but not fully
+ window.HTMLElement && // also exclude jsdom
+ // eslint-disable-next-line no-restricted-syntax
+ !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom"))
+ ) {
+ const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || [];
+ replay.push((newHook) => {
+ setDevtoolsHook$1(newHook, target);
+ });
+ setTimeout(() => {
+ if (!devtools$1) {
+ target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null;
+ devtoolsNotInstalled = true;
+ buffer = [];
+ }
+ }, 3e3);
+ } else {
+ devtoolsNotInstalled = true;
+ buffer = [];
+ }
+}
+function devtoolsInitApp(app, version) {
+ emit$1("app:init" /* APP_INIT */, app, version, {
+ Fragment,
+ Text,
+ Comment,
+ Static
+ });
+}
+function devtoolsUnmountApp(app) {
+ emit$1("app:unmount" /* APP_UNMOUNT */, app);
+}
+const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook(
+ "component:added" /* COMPONENT_ADDED */
+);
+const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */);
+const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook(
+ "component:removed" /* COMPONENT_REMOVED */
+);
+const devtoolsComponentRemoved = (component) => {
+ if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered
+ !devtools$1.cleanupBuffer(component)) {
+ _devtoolsComponentRemoved(component);
+ }
+};
+/*! #__NO_SIDE_EFFECTS__ */
+// @__NO_SIDE_EFFECTS__
+function createDevtoolsComponentHook(hook) {
+ return (component) => {
+ emit$1(
+ hook,
+ component.appContext.app,
+ component.uid,
+ component.parent ? component.parent.uid : void 0,
+ component
+ );
+ };
+}
+const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook(
+ "perf:start" /* PERFORMANCE_START */
+);
+const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook(
+ "perf:end" /* PERFORMANCE_END */
+);
+function createDevtoolsPerformanceHook(hook) {
+ return (component, type, time) => {
+ emit$1(hook, component.appContext.app, component.uid, component, type, time);
+ };
+}
+function devtoolsComponentEmit(component, event, params) {
+ emit$1(
+ "component:emit" /* COMPONENT_EMIT */,
+ component.appContext.app,
+ component,
+ event,
+ params
+ );
+}
+
+let currentRenderingInstance = null;
+let currentScopeId = null;
+function setCurrentRenderingInstance(instance) {
+ const prev = currentRenderingInstance;
+ currentRenderingInstance = instance;
+ currentScopeId = instance && instance.type.__scopeId || null;
+ return prev;
+}
+function pushScopeId(id) {
+ currentScopeId = id;
+}
+function popScopeId() {
+ currentScopeId = null;
+}
+const withScopeId = (_id) => withCtx;
+function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) {
+ if (!ctx) return fn;
+ if (fn._n) {
+ return fn;
+ }
+ const renderFnWithContext = (...args) => {
+ if (renderFnWithContext._d) {
+ setBlockTracking(-1);
+ }
+ const prevInstance = setCurrentRenderingInstance(ctx);
+ let res;
+ try {
+ res = fn(...args);
+ } finally {
+ setCurrentRenderingInstance(prevInstance);
+ if (renderFnWithContext._d) {
+ setBlockTracking(1);
+ }
+ }
+ {
+ devtoolsComponentUpdated(ctx);
+ }
+ return res;
+ };
+ renderFnWithContext._n = true;
+ renderFnWithContext._c = true;
+ renderFnWithContext._d = true;
+ return renderFnWithContext;
+}
+
+function validateDirectiveName(name) {
+ if (isBuiltInDirective(name)) {
+ warn$1("Do not use built-in directive ids as custom directive id: " + name);
+ }
+}
+function withDirectives(vnode, directives) {
+ if (currentRenderingInstance === null) {
+ warn$1(`withDirectives can only be used inside render functions.`);
+ return vnode;
+ }
+ const instance = getComponentPublicInstance(currentRenderingInstance);
+ const bindings = vnode.dirs || (vnode.dirs = []);
+ for (let i = 0; i < directives.length; i++) {
+ let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i];
+ if (dir) {
+ if (isFunction(dir)) {
+ dir = {
+ mounted: dir,
+ updated: dir
+ };
+ }
+ if (dir.deep) {
+ traverse(value);
+ }
+ bindings.push({
+ dir,
+ instance,
+ value,
+ oldValue: void 0,
+ arg,
+ modifiers
+ });
+ }
+ }
+ return vnode;
+}
+function invokeDirectiveHook(vnode, prevVNode, instance, name) {
+ const bindings = vnode.dirs;
+ const oldBindings = prevVNode && prevVNode.dirs;
+ for (let i = 0; i < bindings.length; i++) {
+ const binding = bindings[i];
+ if (oldBindings) {
+ binding.oldValue = oldBindings[i].value;
+ }
+ let hook = binding.dir[name];
+ if (hook) {
+ pauseTracking();
+ callWithAsyncErrorHandling(hook, instance, 8, [
+ vnode.el,
+ binding,
+ vnode,
+ prevVNode
+ ]);
+ resetTracking();
+ }
+ }
+}
+
+const leaveCbKey = Symbol("_leaveCb");
+const enterCbKey$1 = Symbol("_enterCb");
+function useTransitionState() {
+ const state = {
+ isMounted: false,
+ isLeaving: false,
+ isUnmounting: false,
+ leavingVNodes: /* @__PURE__ */ new Map()
+ };
+ onMounted(() => {
+ state.isMounted = true;
+ });
+ onBeforeUnmount(() => {
+ state.isUnmounting = true;
+ });
+ return state;
+}
+const TransitionHookValidator = [Function, Array];
+const BaseTransitionPropsValidators = {
+ mode: String,
+ appear: Boolean,
+ persisted: Boolean,
+ // enter
+ onBeforeEnter: TransitionHookValidator,
+ onEnter: TransitionHookValidator,
+ onAfterEnter: TransitionHookValidator,
+ onEnterCancelled: TransitionHookValidator,
+ // leave
+ onBeforeLeave: TransitionHookValidator,
+ onLeave: TransitionHookValidator,
+ onAfterLeave: TransitionHookValidator,
+ onLeaveCancelled: TransitionHookValidator,
+ // appear
+ onBeforeAppear: TransitionHookValidator,
+ onAppear: TransitionHookValidator,
+ onAfterAppear: TransitionHookValidator,
+ onAppearCancelled: TransitionHookValidator
+};
+const recursiveGetSubtree = (instance) => {
+ const subTree = instance.subTree;
+ return subTree.component ? recursiveGetSubtree(subTree.component) : subTree;
+};
+const BaseTransitionImpl = {
+ name: `BaseTransition`,
+ props: BaseTransitionPropsValidators,
+ setup(props, { slots }) {
+ const instance = getCurrentInstance();
+ const state = useTransitionState();
+ return () => {
+ const children = slots.default && getTransitionRawChildren(slots.default(), true);
+ if (!children || !children.length) {
+ return;
+ }
+ let child = children[0];
+ if (children.length > 1) {
+ let hasFound = false;
+ for (const c of children) {
+ if (c.type !== Comment) {
+ if (hasFound) {
+ warn$1(
+ " can only be used on a single element or component. Use for lists."
+ );
+ break;
+ }
+ child = c;
+ hasFound = true;
+ }
+ }
+ }
+ const rawProps = toRaw(props);
+ const { mode } = rawProps;
+ if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") {
+ warn$1(`invalid mode: ${mode}`);
+ }
+ if (state.isLeaving) {
+ return emptyPlaceholder(child);
+ }
+ const innerChild = getKeepAliveChild(child);
+ if (!innerChild) {
+ return emptyPlaceholder(child);
+ }
+ let enterHooks = resolveTransitionHooks(
+ innerChild,
+ rawProps,
+ state,
+ instance,
+ // #11061, ensure enterHooks is fresh after clone
+ (hooks) => enterHooks = hooks
+ );
+ setTransitionHooks(innerChild, enterHooks);
+ const oldChild = instance.subTree;
+ const oldInnerChild = oldChild && getKeepAliveChild(oldChild);
+ if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(innerChild, oldInnerChild) && recursiveGetSubtree(instance).type !== Comment) {
+ const leavingHooks = resolveTransitionHooks(
+ oldInnerChild,
+ rawProps,
+ state,
+ instance
+ );
+ setTransitionHooks(oldInnerChild, leavingHooks);
+ if (mode === "out-in" && innerChild.type !== Comment) {
+ state.isLeaving = true;
+ leavingHooks.afterLeave = () => {
+ state.isLeaving = false;
+ if (instance.update.active !== false) {
+ instance.effect.dirty = true;
+ instance.update();
+ }
+ };
+ return emptyPlaceholder(child);
+ } else if (mode === "in-out" && innerChild.type !== Comment) {
+ leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => {
+ const leavingVNodesCache = getLeavingNodesForType(
+ state,
+ oldInnerChild
+ );
+ leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild;
+ el[leaveCbKey] = () => {
+ earlyRemove();
+ el[leaveCbKey] = void 0;
+ delete enterHooks.delayedLeave;
+ };
+ enterHooks.delayedLeave = delayedLeave;
+ };
+ }
+ }
+ return child;
+ };
+ }
+};
+const BaseTransition = BaseTransitionImpl;
+function getLeavingNodesForType(state, vnode) {
+ const { leavingVNodes } = state;
+ let leavingVNodesCache = leavingVNodes.get(vnode.type);
+ if (!leavingVNodesCache) {
+ leavingVNodesCache = /* @__PURE__ */ Object.create(null);
+ leavingVNodes.set(vnode.type, leavingVNodesCache);
+ }
+ return leavingVNodesCache;
+}
+function resolveTransitionHooks(vnode, props, state, instance, postClone) {
+ const {
+ appear,
+ mode,
+ persisted = false,
+ onBeforeEnter,
+ onEnter,
+ onAfterEnter,
+ onEnterCancelled,
+ onBeforeLeave,
+ onLeave,
+ onAfterLeave,
+ onLeaveCancelled,
+ onBeforeAppear,
+ onAppear,
+ onAfterAppear,
+ onAppearCancelled
+ } = props;
+ const key = String(vnode.key);
+ const leavingVNodesCache = getLeavingNodesForType(state, vnode);
+ const callHook = (hook, args) => {
+ hook && callWithAsyncErrorHandling(
+ hook,
+ instance,
+ 9,
+ args
+ );
+ };
+ const callAsyncHook = (hook, args) => {
+ const done = args[1];
+ callHook(hook, args);
+ if (isArray(hook)) {
+ if (hook.every((hook2) => hook2.length <= 1)) done();
+ } else if (hook.length <= 1) {
+ done();
+ }
+ };
+ const hooks = {
+ mode,
+ persisted,
+ beforeEnter(el) {
+ let hook = onBeforeEnter;
+ if (!state.isMounted) {
+ if (appear) {
+ hook = onBeforeAppear || onBeforeEnter;
+ } else {
+ return;
+ }
+ }
+ if (el[leaveCbKey]) {
+ el[leaveCbKey](
+ true
+ /* cancelled */
+ );
+ }
+ const leavingVNode = leavingVNodesCache[key];
+ if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) {
+ leavingVNode.el[leaveCbKey]();
+ }
+ callHook(hook, [el]);
+ },
+ enter(el) {
+ let hook = onEnter;
+ let afterHook = onAfterEnter;
+ let cancelHook = onEnterCancelled;
+ if (!state.isMounted) {
+ if (appear) {
+ hook = onAppear || onEnter;
+ afterHook = onAfterAppear || onAfterEnter;
+ cancelHook = onAppearCancelled || onEnterCancelled;
+ } else {
+ return;
+ }
+ }
+ let called = false;
+ const done = el[enterCbKey$1] = (cancelled) => {
+ if (called) return;
+ called = true;
+ if (cancelled) {
+ callHook(cancelHook, [el]);
+ } else {
+ callHook(afterHook, [el]);
+ }
+ if (hooks.delayedLeave) {
+ hooks.delayedLeave();
+ }
+ el[enterCbKey$1] = void 0;
+ };
+ if (hook) {
+ callAsyncHook(hook, [el, done]);
+ } else {
+ done();
+ }
+ },
+ leave(el, remove) {
+ const key2 = String(vnode.key);
+ if (el[enterCbKey$1]) {
+ el[enterCbKey$1](
+ true
+ /* cancelled */
+ );
+ }
+ if (state.isUnmounting) {
+ return remove();
+ }
+ callHook(onBeforeLeave, [el]);
+ let called = false;
+ const done = el[leaveCbKey] = (cancelled) => {
+ if (called) return;
+ called = true;
+ remove();
+ if (cancelled) {
+ callHook(onLeaveCancelled, [el]);
+ } else {
+ callHook(onAfterLeave, [el]);
+ }
+ el[leaveCbKey] = void 0;
+ if (leavingVNodesCache[key2] === vnode) {
+ delete leavingVNodesCache[key2];
+ }
+ };
+ leavingVNodesCache[key2] = vnode;
+ if (onLeave) {
+ callAsyncHook(onLeave, [el, done]);
+ } else {
+ done();
+ }
+ },
+ clone(vnode2) {
+ const hooks2 = resolveTransitionHooks(
+ vnode2,
+ props,
+ state,
+ instance,
+ postClone
+ );
+ if (postClone) postClone(hooks2);
+ return hooks2;
+ }
+ };
+ return hooks;
+}
+function emptyPlaceholder(vnode) {
+ if (isKeepAlive(vnode)) {
+ vnode = cloneVNode(vnode);
+ vnode.children = null;
+ return vnode;
+ }
+}
+function getKeepAliveChild(vnode) {
+ if (!isKeepAlive(vnode)) {
+ return vnode;
+ }
+ if (vnode.component) {
+ return vnode.component.subTree;
+ }
+ const { shapeFlag, children } = vnode;
+ if (children) {
+ if (shapeFlag & 16) {
+ return children[0];
+ }
+ if (shapeFlag & 32 && isFunction(children.default)) {
+ return children.default();
+ }
+ }
+}
+function setTransitionHooks(vnode, hooks) {
+ if (vnode.shapeFlag & 6 && vnode.component) {
+ setTransitionHooks(vnode.component.subTree, hooks);
+ } else if (vnode.shapeFlag & 128) {
+ vnode.ssContent.transition = hooks.clone(vnode.ssContent);
+ vnode.ssFallback.transition = hooks.clone(vnode.ssFallback);
+ } else {
+ vnode.transition = hooks;
+ }
+}
+function getTransitionRawChildren(children, keepComment = false, parentKey) {
+ let ret = [];
+ let keyedFragmentCount = 0;
+ for (let i = 0; i < children.length; i++) {
+ let child = children[i];
+ const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i);
+ if (child.type === Fragment) {
+ if (child.patchFlag & 128) keyedFragmentCount++;
+ ret = ret.concat(
+ getTransitionRawChildren(child.children, keepComment, key)
+ );
+ } else if (keepComment || child.type !== Comment) {
+ ret.push(key != null ? cloneVNode(child, { key }) : child);
+ }
+ }
+ if (keyedFragmentCount > 1) {
+ for (let i = 0; i < ret.length; i++) {
+ ret[i].patchFlag = -2;
+ }
+ }
+ return ret;
+}
+
+/*! #__NO_SIDE_EFFECTS__ */
+// @__NO_SIDE_EFFECTS__
+function defineComponent(options, extraOptions) {
+ return isFunction(options) ? (
+ // #8326: extend call and options.name access are considered side-effects
+ // by Rollup, so we have to wrap it in a pure-annotated IIFE.
+ /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))()
+ ) : options;
+}
+
+const isAsyncWrapper = (i) => !!i.type.__asyncLoader;
+/*! #__NO_SIDE_EFFECTS__ */
+// @__NO_SIDE_EFFECTS__
+function defineAsyncComponent(source) {
+ if (isFunction(source)) {
+ source = { loader: source };
+ }
+ const {
+ loader,
+ loadingComponent,
+ errorComponent,
+ delay = 200,
+ timeout,
+ // undefined = never times out
+ suspensible = true,
+ onError: userOnError
+ } = source;
+ let pendingRequest = null;
+ let resolvedComp;
+ let retries = 0;
+ const retry = () => {
+ retries++;
+ pendingRequest = null;
+ return load();
+ };
+ const load = () => {
+ let thisRequest;
+ return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => {
+ err = err instanceof Error ? err : new Error(String(err));
+ if (userOnError) {
+ return new Promise((resolve, reject) => {
+ const userRetry = () => resolve(retry());
+ const userFail = () => reject(err);
+ userOnError(err, userRetry, userFail, retries + 1);
+ });
+ } else {
+ throw err;
+ }
+ }).then((comp) => {
+ if (thisRequest !== pendingRequest && pendingRequest) {
+ return pendingRequest;
+ }
+ if (!comp) {
+ warn$1(
+ `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.`
+ );
+ }
+ if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) {
+ comp = comp.default;
+ }
+ if (comp && !isObject(comp) && !isFunction(comp)) {
+ throw new Error(`Invalid async component load result: ${comp}`);
+ }
+ resolvedComp = comp;
+ return comp;
+ }));
+ };
+ return defineComponent({
+ name: "AsyncComponentWrapper",
+ __asyncLoader: load,
+ get __asyncResolved() {
+ return resolvedComp;
+ },
+ setup() {
+ const instance = currentInstance;
+ if (resolvedComp) {
+ return () => createInnerComp(resolvedComp, instance);
+ }
+ const onError = (err) => {
+ pendingRequest = null;
+ handleError(
+ err,
+ instance,
+ 13,
+ !errorComponent
+ );
+ };
+ if (suspensible && instance.suspense || false) {
+ return load().then((comp) => {
+ return () => createInnerComp(comp, instance);
+ }).catch((err) => {
+ onError(err);
+ return () => errorComponent ? createVNode(errorComponent, {
+ error: err
+ }) : null;
+ });
+ }
+ const loaded = ref(false);
+ const error = ref();
+ const delayed = ref(!!delay);
+ if (delay) {
+ setTimeout(() => {
+ delayed.value = false;
+ }, delay);
+ }
+ if (timeout != null) {
+ setTimeout(() => {
+ if (!loaded.value && !error.value) {
+ const err = new Error(
+ `Async component timed out after ${timeout}ms.`
+ );
+ onError(err);
+ error.value = err;
+ }
+ }, timeout);
+ }
+ load().then(() => {
+ loaded.value = true;
+ if (instance.parent && isKeepAlive(instance.parent.vnode)) {
+ instance.parent.effect.dirty = true;
+ queueJob(instance.parent.update);
+ }
+ }).catch((err) => {
+ onError(err);
+ error.value = err;
+ });
+ return () => {
+ if (loaded.value && resolvedComp) {
+ return createInnerComp(resolvedComp, instance);
+ } else if (error.value && errorComponent) {
+ return createVNode(errorComponent, {
+ error: error.value
+ });
+ } else if (loadingComponent && !delayed.value) {
+ return createVNode(loadingComponent);
+ }
+ };
+ }
+ });
+}
+function createInnerComp(comp, parent) {
+ const { ref: ref2, props, children, ce } = parent.vnode;
+ const vnode = createVNode(comp, props, children);
+ vnode.ref = ref2;
+ vnode.ce = ce;
+ delete parent.vnode.ce;
+ return vnode;
+}
+
+const isKeepAlive = (vnode) => vnode.type.__isKeepAlive;
+const KeepAliveImpl = {
+ name: `KeepAlive`,
+ // Marker for special handling inside the renderer. We are not using a ===
+ // check directly on KeepAlive in the renderer, because importing it directly
+ // would prevent it from being tree-shaken.
+ __isKeepAlive: true,
+ props: {
+ include: [String, RegExp, Array],
+ exclude: [String, RegExp, Array],
+ max: [String, Number]
+ },
+ setup(props, { slots }) {
+ const instance = getCurrentInstance();
+ const sharedContext = instance.ctx;
+ const cache = /* @__PURE__ */ new Map();
+ const keys = /* @__PURE__ */ new Set();
+ let current = null;
+ {
+ instance.__v_cache = cache;
+ }
+ const parentSuspense = instance.suspense;
+ const {
+ renderer: {
+ p: patch,
+ m: move,
+ um: _unmount,
+ o: { createElement }
+ }
+ } = sharedContext;
+ const storageContainer = createElement("div");
+ sharedContext.activate = (vnode, container, anchor, namespace, optimized) => {
+ const instance2 = vnode.component;
+ move(vnode, container, anchor, 0, parentSuspense);
+ patch(
+ instance2.vnode,
+ vnode,
+ container,
+ anchor,
+ instance2,
+ parentSuspense,
+ namespace,
+ vnode.slotScopeIds,
+ optimized
+ );
+ queuePostRenderEffect(() => {
+ instance2.isDeactivated = false;
+ if (instance2.a) {
+ invokeArrayFns(instance2.a);
+ }
+ const vnodeHook = vnode.props && vnode.props.onVnodeMounted;
+ if (vnodeHook) {
+ invokeVNodeHook(vnodeHook, instance2.parent, vnode);
+ }
+ }, parentSuspense);
+ {
+ devtoolsComponentAdded(instance2);
+ }
+ };
+ sharedContext.deactivate = (vnode) => {
+ const instance2 = vnode.component;
+ invalidateMount(instance2.m);
+ invalidateMount(instance2.a);
+ move(vnode, storageContainer, null, 1, parentSuspense);
+ queuePostRenderEffect(() => {
+ if (instance2.da) {
+ invokeArrayFns(instance2.da);
+ }
+ const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted;
+ if (vnodeHook) {
+ invokeVNodeHook(vnodeHook, instance2.parent, vnode);
+ }
+ instance2.isDeactivated = true;
+ }, parentSuspense);
+ {
+ devtoolsComponentAdded(instance2);
+ }
+ };
+ function unmount(vnode) {
+ resetShapeFlag(vnode);
+ _unmount(vnode, instance, parentSuspense, true);
+ }
+ function pruneCache(filter) {
+ cache.forEach((vnode, key) => {
+ const name = getComponentName(vnode.type);
+ if (name && (!filter || !filter(name))) {
+ pruneCacheEntry(key);
+ }
+ });
+ }
+ function pruneCacheEntry(key) {
+ const cached = cache.get(key);
+ if (cached && (!current || !isSameVNodeType(cached, current))) {
+ unmount(cached);
+ } else if (current) {
+ resetShapeFlag(current);
+ }
+ cache.delete(key);
+ keys.delete(key);
+ }
+ watch(
+ () => [props.include, props.exclude],
+ ([include, exclude]) => {
+ include && pruneCache((name) => matches(include, name));
+ exclude && pruneCache((name) => !matches(exclude, name));
+ },
+ // prune post-render after `current` has been updated
+ { flush: "post", deep: true }
+ );
+ let pendingCacheKey = null;
+ const cacheSubtree = () => {
+ if (pendingCacheKey != null) {
+ if (isSuspense(instance.subTree.type)) {
+ queuePostRenderEffect(() => {
+ cache.set(pendingCacheKey, getInnerChild(instance.subTree));
+ }, instance.subTree.suspense);
+ } else {
+ cache.set(pendingCacheKey, getInnerChild(instance.subTree));
+ }
+ }
+ };
+ onMounted(cacheSubtree);
+ onUpdated(cacheSubtree);
+ onBeforeUnmount(() => {
+ cache.forEach((cached) => {
+ const { subTree, suspense } = instance;
+ const vnode = getInnerChild(subTree);
+ if (cached.type === vnode.type && cached.key === vnode.key) {
+ resetShapeFlag(vnode);
+ const da = vnode.component.da;
+ da && queuePostRenderEffect(da, suspense);
+ return;
+ }
+ unmount(cached);
+ });
+ });
+ return () => {
+ pendingCacheKey = null;
+ if (!slots.default) {
+ return null;
+ }
+ const children = slots.default();
+ const rawVNode = children[0];
+ if (children.length > 1) {
+ {
+ warn$1(`KeepAlive should contain exactly one component child.`);
+ }
+ current = null;
+ return children;
+ } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) {
+ current = null;
+ return rawVNode;
+ }
+ let vnode = getInnerChild(rawVNode);
+ if (vnode.type === Comment) {
+ current = null;
+ return vnode;
+ }
+ const comp = vnode.type;
+ const name = getComponentName(
+ isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp
+ );
+ const { include, exclude, max } = props;
+ if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) {
+ current = vnode;
+ return rawVNode;
+ }
+ const key = vnode.key == null ? comp : vnode.key;
+ const cachedVNode = cache.get(key);
+ if (vnode.el) {
+ vnode = cloneVNode(vnode);
+ if (rawVNode.shapeFlag & 128) {
+ rawVNode.ssContent = vnode;
+ }
+ }
+ pendingCacheKey = key;
+ if (cachedVNode) {
+ vnode.el = cachedVNode.el;
+ vnode.component = cachedVNode.component;
+ if (vnode.transition) {
+ setTransitionHooks(vnode, vnode.transition);
+ }
+ vnode.shapeFlag |= 512;
+ keys.delete(key);
+ keys.add(key);
+ } else {
+ keys.add(key);
+ if (max && keys.size > parseInt(max, 10)) {
+ pruneCacheEntry(keys.values().next().value);
+ }
+ }
+ vnode.shapeFlag |= 256;
+ current = vnode;
+ return isSuspense(rawVNode.type) ? rawVNode : vnode;
+ };
+ }
+};
+const KeepAlive = KeepAliveImpl;
+function matches(pattern, name) {
+ if (isArray(pattern)) {
+ return pattern.some((p) => matches(p, name));
+ } else if (isString(pattern)) {
+ return pattern.split(",").includes(name);
+ } else if (isRegExp(pattern)) {
+ return pattern.test(name);
+ }
+ return false;
+}
+function onActivated(hook, target) {
+ registerKeepAliveHook(hook, "a", target);
+}
+function onDeactivated(hook, target) {
+ registerKeepAliveHook(hook, "da", target);
+}
+function registerKeepAliveHook(hook, type, target = currentInstance) {
+ const wrappedHook = hook.__wdc || (hook.__wdc = () => {
+ let current = target;
+ while (current) {
+ if (current.isDeactivated) {
+ return;
+ }
+ current = current.parent;
+ }
+ return hook();
+ });
+ injectHook(type, wrappedHook, target);
+ if (target) {
+ let current = target.parent;
+ while (current && current.parent) {
+ if (isKeepAlive(current.parent.vnode)) {
+ injectToKeepAliveRoot(wrappedHook, type, target, current);
+ }
+ current = current.parent;
+ }
+ }
+}
+function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) {
+ const injected = injectHook(
+ type,
+ hook,
+ keepAliveRoot,
+ true
+ /* prepend */
+ );
+ onUnmounted(() => {
+ remove(keepAliveRoot[type], injected);
+ }, target);
+}
+function resetShapeFlag(vnode) {
+ vnode.shapeFlag &= ~256;
+ vnode.shapeFlag &= ~512;
+}
+function getInnerChild(vnode) {
+ return vnode.shapeFlag & 128 ? vnode.ssContent : vnode;
+}
+
+function injectHook(type, hook, target = currentInstance, prepend = false) {
+ if (target) {
+ const hooks = target[type] || (target[type] = []);
+ const wrappedHook = hook.__weh || (hook.__weh = (...args) => {
+ pauseTracking();
+ const reset = setCurrentInstance(target);
+ const res = callWithAsyncErrorHandling(hook, target, type, args);
+ reset();
+ resetTracking();
+ return res;
+ });
+ if (prepend) {
+ hooks.unshift(wrappedHook);
+ } else {
+ hooks.push(wrappedHook);
+ }
+ return wrappedHook;
+ } else {
+ const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, ""));
+ warn$1(
+ `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` )
+ );
+ }
+}
+const createHook = (lifecycle) => (hook, target = currentInstance) => {
+ if (!isInSSRComponentSetup || lifecycle === "sp") {
+ injectHook(lifecycle, (...args) => hook(...args), target);
+ }
+};
+const onBeforeMount = createHook("bm");
+const onMounted = createHook("m");
+const onBeforeUpdate = createHook("bu");
+const onUpdated = createHook("u");
+const onBeforeUnmount = createHook("bum");
+const onUnmounted = createHook("um");
+const onServerPrefetch = createHook("sp");
+const onRenderTriggered = createHook(
+ "rtg"
+);
+const onRenderTracked = createHook(
+ "rtc"
+);
+function onErrorCaptured(hook, target = currentInstance) {
+ injectHook("ec", hook, target);
+}
+
+const COMPONENTS = "components";
+const DIRECTIVES = "directives";
+function resolveComponent(name, maybeSelfReference) {
+ return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name;
+}
+const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc");
+function resolveDynamicComponent(component) {
+ if (isString(component)) {
+ return resolveAsset(COMPONENTS, component, false) || component;
+ } else {
+ return component || NULL_DYNAMIC_COMPONENT;
+ }
+}
+function resolveDirective(name) {
+ return resolveAsset(DIRECTIVES, name);
+}
+function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) {
+ const instance = currentRenderingInstance || currentInstance;
+ if (instance) {
+ const Component = instance.type;
+ if (type === COMPONENTS) {
+ const selfName = getComponentName(
+ Component,
+ false
+ );
+ if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) {
+ return Component;
+ }
+ }
+ const res = (
+ // local registration
+ // check instance[type] first which is resolved for options API
+ resolve(instance[type] || Component[type], name) || // global registration
+ resolve(instance.appContext[type], name)
+ );
+ if (!res && maybeSelfReference) {
+ return Component;
+ }
+ if (warnMissing && !res) {
+ const extra = type === COMPONENTS ? `
+If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``;
+ warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`);
+ }
+ return res;
+ } else {
+ warn$1(
+ `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().`
+ );
+ }
+}
+function resolve(registry, name) {
+ return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]);
+}
+
+function renderList(source, renderItem, cache, index) {
+ let ret;
+ const cached = cache && cache[index];
+ if (isArray(source) || isString(source)) {
+ ret = new Array(source.length);
+ for (let i = 0, l = source.length; i < l; i++) {
+ ret[i] = renderItem(source[i], i, void 0, cached && cached[i]);
+ }
+ } else if (typeof source === "number") {
+ if (!Number.isInteger(source)) {
+ warn$1(`The v-for range expect an integer value but got ${source}.`);
+ }
+ ret = new Array(source);
+ for (let i = 0; i < source; i++) {
+ ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]);
+ }
+ } else if (isObject(source)) {
+ if (source[Symbol.iterator]) {
+ ret = Array.from(
+ source,
+ (item, i) => renderItem(item, i, void 0, cached && cached[i])
+ );
+ } else {
+ const keys = Object.keys(source);
+ ret = new Array(keys.length);
+ for (let i = 0, l = keys.length; i < l; i++) {
+ const key = keys[i];
+ ret[i] = renderItem(source[key], key, i, cached && cached[i]);
+ }
+ }
+ } else {
+ ret = [];
+ }
+ if (cache) {
+ cache[index] = ret;
+ }
+ return ret;
+}
+
+function createSlots(slots, dynamicSlots) {
+ for (let i = 0; i < dynamicSlots.length; i++) {
+ const slot = dynamicSlots[i];
+ if (isArray(slot)) {
+ for (let j = 0; j < slot.length; j++) {
+ slots[slot[j].name] = slot[j].fn;
+ }
+ } else if (slot) {
+ slots[slot.name] = slot.key ? (...args) => {
+ const res = slot.fn(...args);
+ if (res) res.key = slot.key;
+ return res;
+ } : slot.fn;
+ }
+ }
+ return slots;
+}
+
+function renderSlot(slots, name, props = {}, fallback, noSlotted) {
+ if (currentRenderingInstance.isCE || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.isCE) {
+ if (name !== "default") props.name = name;
+ return createVNode("slot", props, fallback && fallback());
+ }
+ let slot = slots[name];
+ if (slot && slot.length > 1) {
+ warn$1(
+ `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.`
+ );
+ slot = () => [];
+ }
+ if (slot && slot._c) {
+ slot._d = false;
+ }
+ openBlock();
+ const validSlotContent = slot && ensureValidVNode(slot(props));
+ const rendered = createBlock(
+ Fragment,
+ {
+ key: (props.key || // slot content array of a dynamic conditional slot may have a branch
+ // key attached in the `createSlots` helper, respect that
+ validSlotContent && validSlotContent.key || `_${name}`) + // #7256 force differentiate fallback content from actual content
+ (!validSlotContent && fallback ? "_fb" : "")
+ },
+ validSlotContent || (fallback ? fallback() : []),
+ validSlotContent && slots._ === 1 ? 64 : -2
+ );
+ if (!noSlotted && rendered.scopeId) {
+ rendered.slotScopeIds = [rendered.scopeId + "-s"];
+ }
+ if (slot && slot._c) {
+ slot._d = true;
+ }
+ return rendered;
+}
+function ensureValidVNode(vnodes) {
+ return vnodes.some((child) => {
+ if (!isVNode(child)) return true;
+ if (child.type === Comment) return false;
+ if (child.type === Fragment && !ensureValidVNode(child.children))
+ return false;
+ return true;
+ }) ? vnodes : null;
+}
+
+function toHandlers(obj, preserveCaseIfNecessary) {
+ const ret = {};
+ if (!isObject(obj)) {
+ warn$1(`v-on with no argument expects an object value.`);
+ return ret;
+ }
+ for (const key in obj) {
+ ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key];
+ }
+ return ret;
+}
+
+const getPublicInstance = (i) => {
+ if (!i) return null;
+ if (isStatefulComponent(i)) return getComponentPublicInstance(i);
+ return getPublicInstance(i.parent);
+};
+const publicPropertiesMap = (
+ // Move PURE marker to new line to workaround compiler discarding it
+ // due to type annotation
+ /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), {
+ $: (i) => i,
+ $el: (i) => i.vnode.el,
+ $data: (i) => i.data,
+ $props: (i) => shallowReadonly(i.props) ,
+ $attrs: (i) => shallowReadonly(i.attrs) ,
+ $slots: (i) => shallowReadonly(i.slots) ,
+ $refs: (i) => shallowReadonly(i.refs) ,
+ $parent: (i) => getPublicInstance(i.parent),
+ $root: (i) => getPublicInstance(i.root),
+ $emit: (i) => i.emit,
+ $options: (i) => resolveMergedOptions(i) ,
+ $forceUpdate: (i) => i.f || (i.f = () => {
+ i.effect.dirty = true;
+ queueJob(i.update);
+ }),
+ $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)),
+ $watch: (i) => instanceWatch.bind(i)
+ })
+);
+const isReservedPrefix = (key) => key === "_" || key === "$";
+const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key);
+const PublicInstanceProxyHandlers = {
+ get({ _: instance }, key) {
+ if (key === "__v_skip") {
+ return true;
+ }
+ const { ctx, setupState, data, props, accessCache, type, appContext } = instance;
+ if (key === "__isVue") {
+ return true;
+ }
+ let normalizedProps;
+ if (key[0] !== "$") {
+ const n = accessCache[key];
+ if (n !== void 0) {
+ switch (n) {
+ case 1 /* SETUP */:
+ return setupState[key];
+ case 2 /* DATA */:
+ return data[key];
+ case 4 /* CONTEXT */:
+ return ctx[key];
+ case 3 /* PROPS */:
+ return props[key];
+ }
+ } else if (hasSetupBinding(setupState, key)) {
+ accessCache[key] = 1 /* SETUP */;
+ return setupState[key];
+ } else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
+ accessCache[key] = 2 /* DATA */;
+ return data[key];
+ } else if (
+ // only cache other properties when instance has declared (thus stable)
+ // props
+ (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key)
+ ) {
+ accessCache[key] = 3 /* PROPS */;
+ return props[key];
+ } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
+ accessCache[key] = 4 /* CONTEXT */;
+ return ctx[key];
+ } else if (shouldCacheAccess) {
+ accessCache[key] = 0 /* OTHER */;
+ }
+ }
+ const publicGetter = publicPropertiesMap[key];
+ let cssModule, globalProperties;
+ if (publicGetter) {
+ if (key === "$attrs") {
+ track(instance.attrs, "get", "");
+ markAttrsAccessed();
+ } else if (key === "$slots") {
+ track(instance, "get", key);
+ }
+ return publicGetter(instance);
+ } else if (
+ // css module (injected by vue-loader)
+ (cssModule = type.__cssModules) && (cssModule = cssModule[key])
+ ) {
+ return cssModule;
+ } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
+ accessCache[key] = 4 /* CONTEXT */;
+ return ctx[key];
+ } else if (
+ // global properties
+ globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key)
+ ) {
+ {
+ return globalProperties[key];
+ }
+ } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading
+ // to infinite warning loop
+ key.indexOf("__v") !== 0)) {
+ if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) {
+ warn$1(
+ `Property ${JSON.stringify(
+ key
+ )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.`
+ );
+ } else if (instance === currentRenderingInstance) {
+ warn$1(
+ `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.`
+ );
+ }
+ }
+ },
+ set({ _: instance }, key, value) {
+ const { data, setupState, ctx } = instance;
+ if (hasSetupBinding(setupState, key)) {
+ setupState[key] = value;
+ return true;
+ } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) {
+ warn$1(`Cannot mutate
Galaxy
+
+
diff --git a/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py b/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py
new file mode 100644
index 0000000000..199917d90f
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py
@@ -0,0 +1,53 @@
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[4]
+DEVICE_SETTINGS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.js"
+DEVICE_SETTINGS_CSS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/device_settings.css"
+
+
+def _device_settings():
+ return DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
+
+
+def test_device_settings_surfaces_hidden_advanced_settings_count():
+ source = _device_settings()
+
+ assert "countAdvancedHiddenByDeveloperMode" in source
+ assert "isAdvancedHiddenByDeveloperMode" in source
+ assert "hiddenAdvancedCount" in source
+
+
+def test_developer_mode_notice_is_rendered_when_advanced_settings_hidden():
+ source = _device_settings()
+
+ assert "ds-dev-mode-notice" in source
+ assert "advanced setting" in source
+ assert "Enable Developer Mode" in source
+
+
+def test_developer_mode_notice_navigates_to_developer_section():
+ source = _device_settings()
+
+ assert 'window.__theGalaxyNavigate("/device_settings/developer")' in source
+
+
+def test_advanced_settings_hidden_count_shown_in_status_bar():
+ source = _device_settings()
+
+ assert "advanced hidden" in source
+
+
+def test_device_settings_uses_the_params_api_and_layout_json():
+ source = _device_settings()
+
+ assert 'fetch("/api/params/all")' in source
+ assert 'fetch("/api/params/defaults")' in source
+ assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source
+
+
+def test_developer_mode_notice_has_styles():
+ css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
+
+ assert ".ds-dev-mode-notice" in css
+ assert ".ds-dev-mode-notice-btn" in css
diff --git a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py
new file mode 100644
index 0000000000..1b59954d9b
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py
@@ -0,0 +1,437 @@
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[4]
+UI_ROOT = REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile"
+GALAXY_PY = REPO_ROOT / "starpilot/system/the_galaxy/the_galaxy.py"
+
+
+def _read(rel):
+ return (UI_ROOT / rel).read_text(encoding="utf-8")
+
+
+def test_ui_app_shell_files_exist():
+ required = [
+ "index.html",
+ "manifest.json",
+ "css/material.css",
+ "js/app.js",
+ "js/store.js",
+ "js/api.js",
+ "js/params.js",
+ "js/components/AppShell.js",
+ "js/components/GalaxyModal.js",
+ "js/components/GalaxySection.js",
+ "js/components/GalaxyEmbed.js",
+ "js/components/GalaxyToggleCard.js",
+ "js/components/SettingTree.js",
+ "js/components/ParamSections.js",
+ "js/components/ManeuverCard.js",
+ "js/components/WheelControls.js",
+ "js/components/BluetoothPanel.js",
+ "js/components/DevModeBanner.js",
+ "js/composables.js",
+ "js/views/Home.js",
+ "js/views/Settings.js",
+ "js/views/Tools.js",
+ "js/views/Recordings.js",
+ "js/views/Logs.js",
+ "js/views/Tuning.js",
+ "js/views/Navigation.js",
+ "js/views/Vehicle.js",
+ "js/views/SystemTools.js",
+ ]
+ for rel in required:
+ assert (UI_ROOT / rel).is_file(), f"missing new-UI file: {rel}"
+
+
+def test_ui_index_wires_vue_and_mount_point():
+ index = _read("index.html")
+ assert 'id="galaxy-app"' in index
+ assert 'src="/assets/mobile/js/app.js"' in index
+ assert '"vue": "/assets/vendor/vue/vue.esm-browser.js"' in index
+
+
+def test_ui_uses_same_backend_endpoints():
+ settings = _read("js/views/Settings.js")
+ params = _read("js/params.js")
+ api = _read("js/api.js")
+
+ # Settings fetches the exact same layout JSON + params API the original UI used.
+ assert '/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in settings or \
+ '/assets/components/tools/device_settings_layout.json?v=settings-tier-1' in api
+ assert '"/api/params/all"' in api
+ assert '"/api/params"' in api
+ assert '"/api/params/defaults"' in api
+
+
+def test_ui_ports_developer_mode_gating():
+ params = _read("js/params.js")
+ assert "countAdvancedHiddenByDeveloperMode" in params
+ assert "isSettingVisible" in params
+ assert "isAdvancedHiddenByDeveloperMode" in params
+
+
+def test_ui_restores_hierarchical_sub_toggle_rendering():
+ # Children must nest under parents via the recursive SettingTree, gated on
+ # the parent being enabled AND expanded (classic renderSettingTree contract).
+ params = _read("js/params.js")
+ settings = _read("js/views/Settings.js")
+ tree = _read("js/components/SettingTree.js")
+
+ assert "buildRenderTree" in params
+ assert "isParamEnabledForChildren" in params
+ assert "hasChildParams" in params
+
+ assert "SettingTree" in settings
+ assert ' { if (!cond) throw new Error("FAIL: " + msg) }
+assert(P.isSettingVisible(sec, sec.params[0], {}) === true, "simple visible (off)")
+assert(P.isSettingVisible(sec, sec.params[1], {}) === false, "advanced hidden (off)")
+assert(P.isSettingVisible(sec, sec.params[1], { GalaxyDeveloperMode: true }) === true, "advanced visible (on)")
+assert(P.countAdvancedHiddenByDeveloperMode([sec], {}) === 1, "count hidden (off)")
+assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true }) === 0, "count hidden (on)")
+const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 }
+assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap")
+assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format")
+console.log("params.js logic OK")
+""",
+ encoding="utf-8",
+ )
+
+ result = subprocess.run([node, str(script)], capture_output=True, text=True)
+ assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
+
+
+@pytest.mark.skipif(_node_exe() is None, reason="no node.js runtime available")
+def test_ui_hierarchical_tree_logic_runs_and_passes(tmp_path):
+ node = _node_exe()
+ params_src = _read("js/params.js")
+ (tmp_path / "params.mjs").write_text(params_src, encoding="utf-8")
+
+ script = tmp_path / "treecheck.mjs"
+ script.write_text(
+ """
+import * as P from "./params.mjs"
+const params = [
+ { key: "Parent", ui_type: "toggle" },
+ { key: "Child", parent_key: "Parent", ui_type: "toggle" },
+ { key: "GrandChild", parent_key: "Child", ui_type: "toggle" },
+ { key: "Sibling", ui_type: "toggle" },
+]
+const assert = (cond, msg) => { if (!cond) throw new Error("FAIL: " + msg) }
+
+// Parent off -> no children rendered.
+let tree = P.buildRenderTree(params, {}, {})
+assert(tree.length === 2, "parent off shows only roots, got " + tree.length)
+assert(tree.map(t => t.param.key).join(",") === "Parent,Sibling", "parent off keys")
+
+// Parent on but collapsed -> still no children.
+tree = P.buildRenderTree(params, { Parent: true }, {})
+assert(tree.length === 2, "parent on but collapsed hides children")
+
+// Parent on + expanded -> children, but grandchild hidden until Child expanded.
+tree = P.buildRenderTree(params, { Parent: true }, { Parent: true })
+assert(tree.map(t => t.param.key).join(",") === "Parent,Child,Sibling", "one level deep")
+assert(tree[1].depth === 1, "child depth is 1")
+
+// Full expansion -> grandchild at depth 2.
+tree = P.buildRenderTree(params, { Parent: true, Child: true }, { Parent: true, Child: true })
+const gc = tree.find(t => t.param.key === "GrandChild")
+assert(gc && gc.depth === 2, "grandchild nested at depth 2")
+assert(tree[0].depth === 0, "root depth is 0")
+
+assert(P.isParamEnabledForChildren({ key: "X" }, { X: true }) === true, "enabled when true")
+assert(P.isParamEnabledForChildren({ key: "X" }, { X: false }) === false, "disabled when false")
+assert(P.isParamEnabledForChildren({ ui_type: "group" }, {}) === true, "group always enabled")
+assert(P.hasChildParams(params, "Parent") === true, "hasChildParams true")
+assert(P.hasChildParams(params, "Sibling") === false, "hasChildParams false")
+console.log("hierarchy logic OK")
+""",
+ encoding="utf-8",
+ )
+
+ result = subprocess.run([node, str(script)], capture_output=True, text=True)
+ assert result.returncode == 0, f"node failed:\n{result.stdout}\n{result.stderr}"
diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py
index bd621b1837..34714f9c7c 100644
--- a/starpilot/system/the_galaxy/the_galaxy.py
+++ b/starpilot/system/the_galaxy/the_galaxy.py
@@ -4958,6 +4958,18 @@ def setup(app):
response.headers["Expires"] = "0"
return response
+ def _no_store_response(response):
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+
+ def _serve_new_ui():
+ ui_index_path = Path(app.static_folder) / "mobile" / "index.html"
+ if not ui_index_path.is_file():
+ return "Galaxy UI not found", 404
+ return _no_store_response(make_response(send_file(str(ui_index_path))))
+
@app.route("/", methods=["GET"])
def index():
response = make_response(render_template("index.html"))
@@ -4966,6 +4978,18 @@ def setup(app):
response.headers["Expires"] = "0"
return response
+ @app.route("/classic", methods=["GET"])
+ @app.route("/classic/", methods=["GET"])
+ def classic_index():
+ return _no_store_response(make_response(render_template("index.html")))
+
+ @app.route("/mobile", methods=["GET"])
+ @app.route("/mobile/", methods=["GET"])
+ @app.route("/ui", methods=["GET"])
+ @app.route("/ui/", methods=["GET"])
+ def mobile_index():
+ return _serve_new_ui()
+
@app.route("/api/bluetooth/status", methods=["GET"])
def bluetooth_status():
try:
@@ -7213,6 +7237,15 @@ def setup(app):
with _STATS_RESPONSE_LOCK:
return _get_stats_locked()
+ @app.route("/api/device/status", methods=["GET"])
+ def device_status():
+ return jsonify({
+ "status": "Driving" if params.get_bool("IsOnroad") else "Parked",
+ "online": True,
+ "lanIp": utilities.get_current_lan_ip(),
+ "networkName": utilities.get_current_network_name(),
+ }), 200
+
@app.route("/api/stats/ignore_drive", methods=["POST"])
def ignore_drive_stats():
request_data = request.get_json() or {}
diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py
index 353ad1bf24..f56d6f017b 100644
--- a/starpilot/system/the_galaxy/utilities.py
+++ b/starpilot/system/the_galaxy/utilities.py
@@ -1103,7 +1103,7 @@ def _dashboard_time_is_valid(value, now=None, require_recent=False):
return True
-def _timestamp_to_dashboard_time(timestamp, require_recent=False):
+def _timestamp_to_dashboard_time(timestamp, require_recent=False, now=None):
timestamp = _safe_float(timestamp, 0.0)
if timestamp <= 0.0:
return None
@@ -1111,7 +1111,7 @@ def _timestamp_to_dashboard_time(timestamp, require_recent=False):
parsed = datetime.fromtimestamp(timestamp)
except (OSError, OverflowError, ValueError):
return None
- return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent) else None
+ return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent, now=now) else None
def _parse_segment_dir_name(name):
@@ -1605,15 +1605,15 @@ def _public_drive(drive, is_metric):
return public
-def _route_time_range(route_info, duration_seconds):
+def _route_time_range(route_info, duration_seconds, now=None):
modified_at = _safe_float(route_info.get("modifiedAt", 0.0), 0.0)
duration_seconds = max(0.0, _safe_float(duration_seconds, 0.0))
started_at = route_info.get("startedAt")
- if _dashboard_time_is_valid(started_at, require_recent=True):
+ if _dashboard_time_is_valid(started_at, now=now, require_recent=True):
end_time = started_at + timedelta(seconds=duration_seconds) if duration_seconds > 0.0 else None
return _jsonable_time(started_at), _jsonable_time(end_time)
- modified_time = _timestamp_to_dashboard_time(modified_at, require_recent=True)
+ modified_time = _timestamp_to_dashboard_time(modified_at, now=now, require_recent=True)
if modified_time is not None and duration_seconds > 0.0:
end_time = modified_time
start_time = end_time - timedelta(seconds=duration_seconds)
@@ -1625,10 +1625,10 @@ def _distance_from_meters(distance_m, is_metric):
return distance_m * (METER_TO_KILOMETER if is_metric else METER_TO_MILE)
-def _route_shell_drive(route_info, params_obj, model_names, is_metric):
+def _route_shell_drive(route_info, params_obj, model_names, is_metric, now=None):
segment_count = max(0, _safe_int(route_info.get("segmentCount", 0), 0))
duration_seconds = segment_count * 60
- start_date, end_date = _route_time_range(route_info, duration_seconds)
+ start_date, end_date = _route_time_range(route_info, duration_seconds, now=now)
return {
"name": route_info.get("name", ""),
"routeNames": [route_info.get("name", "")],
@@ -2986,7 +2986,7 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None):
persistent_stats = _load_dashboard_persistent_stats(params_obj)
shell_drives = [
- _route_shell_drive(route_info, params_obj, model_names, is_metric)
+ _route_shell_drive(route_info, params_obj, model_names, is_metric, now=now)
for route_info in route_infos
]
if shell_drives: