GalaxySelect refactor, update tests, rename components to be more accurate, Discord Support button

This commit is contained in:
Prabhaav Pillai
2026-09-15 01:06:17 -04:00
parent cbe6f39030
commit 3ba36ed4fc
21 changed files with 114 additions and 187 deletions
@@ -544,7 +544,7 @@ ul { list-style: none; margin: 0; padding: 0; }
width: 100%;
}
.liquid-glass-nav {
.blur-nav {
position: fixed;
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
left: 50%;
@@ -571,11 +571,11 @@ ul { list-style: none; margin: 0; padding: 0; }
max-width: calc(100vw - 32px);
}
[data-theme="light"] .liquid-glass-nav {
[data-theme="light"] .blur-nav {
border: 1.5px solid var(--primary);
}
.liquid-glass-nav .nav-item {
.blur-nav .nav-item {
display: flex;
flex-direction: column;
align-items: center;
@@ -592,28 +592,28 @@ ul { list-style: none; margin: 0; padding: 0; }
cursor: pointer;
}
.liquid-glass-nav .nav-item i {
.blur-nav .nav-item i {
font-size: 1.3rem;
margin-bottom: 2px;
transition: transform var(--motion-fast);
}
.liquid-glass-nav .nav-item:hover {
.blur-nav .nav-item:hover {
color: var(--on-surface);
background: rgba(255, 255, 255, 0.08);
}
.liquid-glass-nav .nav-item:active i {
.blur-nav .nav-item:active i {
transform: scale(0.88);
}
.liquid-glass-nav .nav-item.active {
.blur-nav .nav-item.active {
background: var(--glass-active-bg);
color: var(--primary);
font-weight: var(--fw-bold);
}
.liquid-glass-nav .nav-item.active i {
.blur-nav .nav-item.active i {
transform: translateY(-2px);
}
@@ -791,7 +791,7 @@ body.is-scrolling .gx-tile {
.gx-row__desc {
color: var(--text-muted);
font-size: var(--fs-sm);
margin-top: 2px;
margin-top: 4px;
line-height: 1.4;
}
@@ -807,14 +807,6 @@ body.is-scrolling .gx-tile {
.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; }
/* Keep the native keyboard/touch picker, but wrap its verified label instead
of clipping Conditional Experimental at enlarged phone UI scales. */
.gx-mode-select { position: relative; width: 100%; }
.gx-mode-select__label { display: flex; align-items: center; justify-content: space-between; gap: 12px; white-space: normal; }
.gx-mode-select__label span { min-width: 0; overflow-wrap: anywhere; }
.gx-mode-select__label i { flex-shrink: 0; }
.gx-mode-select select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.gx-mode-select:focus-within { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: 16px; }
.gx-row--stack .gx-row__info { flex: none; }
.gx-row--stack .gx-field,
.gx-row--stack .gx-slider-row { width: 100%; }
@@ -981,7 +973,6 @@ input[type="range"].gx-slider:active::-webkit-slider-thumb {
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 {
@@ -1285,7 +1276,7 @@ button.gx-chip:hover {
.gx-drawer.open { transform: translateX(0); }
.gx-drawer.open ~ .liquid-glass-nav {
.gx-drawer.open ~ .blur-nav {
opacity: 0;
pointer-events: none;
transform: translateX(-50%) translateY(12px) translateZ(0);
@@ -1935,75 +1926,6 @@ button.gx-chip:hover {
flex: 0 1 240px;
}
/* Custom dropdown standing in for a native <select>: Android renders a native
select as a full-screen wall of options with no way to restyle it, so segment
picking gets its own small themed menu instead, consistent on every platform. */
.gx-segment-picker {
position: relative;
}
.gx-segment-picker__trigger {
align-items: center;
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);
cursor: pointer;
display: flex;
font-family: var(--font-body);
font-size: var(--fs-base);
justify-content: space-between;
min-height: 46px;
padding: 0 var(--sp-3);
width: 100%;
}
.gx-segment-picker__trigger:hover { background: rgba(255, 255, 255, 0.1); }
.gx-segment-picker__menu {
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-md);
bottom: calc(100% + 4px);
box-shadow: var(--elev-3);
left: 0;
max-height: 220px;
overflow-y: auto;
padding: var(--sp-1);
position: absolute;
width: 100%;
z-index: 2;
}
[data-theme="light"] .gx-segment-picker__menu {
border: 1.5px solid var(--primary);
}
.gx-segment-picker__item {
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--on-surface);
cursor: pointer;
display: block;
font-family: var(--font-body);
font-size: var(--fs-sm);
padding: var(--sp-2) var(--sp-3);
text-align: left;
width: 100%;
}
.gx-segment-picker__item:hover { background: var(--glass-bg-hover); }
.gx-segment-picker__item.active {
background: var(--primary-container);
color: var(--on-primary-container);
font-weight: var(--fw-bold);
}
.gx-route-logs-section {
scroll-margin-top: calc(var(--appbar-height) + var(--sp-3));
}
@@ -2317,7 +2239,7 @@ button.gx-chip:hover {
.gx-install__manual { display: grid; gap: var(--sp-2); }
@media (max-width: 767px) {
.liquid-glass-nav { display: flex; }
.blur-nav { display: flex; }
.gx-drawer { padding-bottom: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)); }
.gx-back-btn { display: inline-flex; }
.gx-appbar__search { flex: 1; min-width: 0; }
@@ -2326,7 +2248,7 @@ button.gx-chip:hover {
}
@media (min-width: 768px) {
.liquid-glass-nav { display: none; }
.blur-nav { display: none; }
.gx-back-btn { display: none; }
.gx-content { padding-bottom: var(--sp-6); }
}
@@ -2353,7 +2275,7 @@ button.gx-chip:hover {
.gx-monitor tbody tr:hover { background:var(--surface); }
@media(max-width:600px) { .gx-monitor__summary { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .gx-monitor__metric { padding:12px; } .gx-monitor__metric strong { font-size:22px; } }
/* Version and branch selector menus. */
/* GalaxySelect: the themed dropdown used for every select in the app. */
.gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; }
.gx-select.gx-field { padding: 0; background: var(--surface-container-high); }
.gx-select.gx-field--full { display: flex; width: 100%; }
@@ -23,6 +23,7 @@ import { Cameras } from "./views/Cameras.js"
import { store, initRouter, navigate } from "./store.js"
import { showSnackbar } from "./api.js"
import { installDomTranslator } from "./i18n.js"
import { GalaxySelect } from "./components/GalaxySelect.js"
window.__galaxyVue = { createApp, h }
@@ -87,6 +88,8 @@ const app = createApp({
},
})
app.component("GalaxySelect", GalaxySelect)
app.mount("#galaxy-app")
installDomTranslator(document.getElementById("galaxy-app"))
@@ -182,7 +182,7 @@ export const AppShell = {
<slot />
</main>
<nav class="liquid-glass-nav">
<nav class="blur-nav">
<button v-for="item in BOTTOM_NAV" :key="item.link" type="button"
class="nav-item" :class="{ active: isActive(item.link) }"
@click="bottomNavTo(item)">
@@ -311,11 +311,11 @@ export const GalaxyToggleCard = {
</div>
</div>
<select v-else-if="isSelect" class="gx-field" :disabled="locked || updating" :value="String(value ?? '')" @change="onSelect">
<GalaxySelect v-else-if="isSelect" class="gx-field" :disabled="locked || updating" :value="String(value ?? '')" @change="onSelect">
<option v-if="optionsLoading" value="">{{ tr("Loading...") }}</option>
<option v-else-if="!selectOptions.length" value="">{{ tr("No options available") }}</option>
<option v-for="opt in selectOptions" :key="String(opt.value)" :value="String(opt.value)">{{ tr(opt.label, opt.label) }}</option>
</select>
</GalaxySelect>
<input v-else-if="isText" class="gx-field" :type="param.input_type || 'text'" :value="value ?? ''"
:placeholder="param.placeholder || ''" :disabled="locked || updating" @change="onText" />
@@ -57,9 +57,9 @@ export const LanguageSelector = {
<span class="gx-row__label">{{ tr("Select language") }}</span>
<span class="gx-row__desc">{{ tr("Galaxy uses English when no language is selected.") }}</span>
</div>
<select class="gx-field" :value="selected" :disabled="saving" @change="change">
<GalaxySelect class="gx-field" :value="selected" :disabled="saving" @change="change">
<option v-for="option in languages" :key="option.value" :value="option.value">{{ tr(option.label, option.label) }}</option>
</select>
</GalaxySelect>
</div>
<GxNotice v-if="error" tone="danger" :text="error" style="margin: 0 var(--sp-4) var(--sp-4);" />
</div>
@@ -10,7 +10,6 @@ export const LongitudinalMode = {
data() { return { snapshot: null, pending: false, reading: false, expanded: {}, open: false, error: "", generation: 0, timer: null, disposed: false, modes: LONGITUDINAL_MODES } },
computed: {
mode() { return this.snapshot?.mode || "" },
label() { return this.modes.find(m => m.value === this.mode)?.label || "Unavailable" },
reason() { return this.snapshot?.locked ? this.snapshot.reason : this.snapshot ? "" : "Speed control state unavailable." },
locked() { return this.pending || !this.snapshot || this.snapshot.locked },
conditional() { return this.mode === "conditional_experimental" || this.mode === "conditional_chill" },
@@ -66,13 +65,10 @@ export const LongitudinalMode = {
<span v-if="reason" class="gx-row__desc" role="status">{{ reason }}</span>
<span v-if="error" class="gx-row__desc" role="alert">{{ error }}</span>
</div>
<div class="gx-mode-select">
<div class="gx-field gx-mode-select__label" aria-hidden="true"><span>{{ label }}</span><i class="bi bi-chevron-down"></i></div>
<select id="gx-longitudinal-mode" :value="mode" :disabled="locked" aria-describedby="gx-longitudinal-description" @change="select">
<option v-if="!snapshot" value="">Unavailable</option>
<option v-for="m in modes" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<GalaxySelect id="gx-longitudinal-mode" class="gx-field gx-field--full" :value="mode" :disabled="locked" aria-describedby="gx-longitudinal-description" @change="select">
<option v-if="!snapshot" value="">Unavailable</option>
<option v-for="m in modes" :key="m.value" :value="m.value">{{ m.label }}</option>
</GalaxySelect>
</div>
<button v-if="conditional" type="button" class="gx-manage-btn" :aria-expanded="open" aria-controls="gx-longitudinal-children" @click="open = !open">
{{ open ? 'Close' : 'Manage' }}<i class="bi" aria-hidden="true" :class="open ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
@@ -397,9 +397,9 @@ export const MapsPanel = {
</div>
<div class="gx-row" style="border-top:none; flex-wrap:wrap;">
<span class="gx-row__label">Auto Update</span>
<select class="gx-field" style="flex:1; min-width:160px;" :value="scheduleDraft" @change="scheduleDraft = $event.target.value">
<GalaxySelect class="gx-field" style="flex:1; min-width:160px;" :value="scheduleDraft" @change="scheduleDraft = $event.target.value">
<option v-for="opt in scheduleOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</GalaxySelect>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="savingSchedule || !scheduleDirty" @click="saveSchedule">
{{ savingSchedule ? 'Applying...' : 'Apply' }}
</button>
@@ -164,7 +164,7 @@ export const SystemMonitor = {
<section class="gx-card gx-monitor__metric"><span>eGPU VRAM</span><strong>{{ vramUsed == null ? '—' : number(vramUsed / 1073741824) + ' GiB' }}</strong><small v-if="vramTotal != null">{{ number(vramTotal / 1073741824) }} GiB total · {{ number(100 * vramUsed / vramTotal, '%') }}</small><progress v-if="vramTotal > 0 && vramUsed != null" :value="vramUsed" :max="vramTotal" aria-label="eGPU VRAM usage"></progress></section>
</div>
<details class="gx-card gx-monitor__cores"><summary>CPU cores</summary><div><span v-for="core in snapshot.cores" :key="core.name">{{ core.name.toUpperCase() }} <b>{{ number(core.percent, '%') }}</b><progress :value="core.percent || 0" max="100" :aria-label="core.name + ' usage'"></progress></span></div></details>
<div class="gx-monitor__filters"><input class="gx-field" type="search" v-model="query" aria-label="Search processes" placeholder="Search feature, process, PID or user…"/><select class="gx-field" v-model="scope" aria-label="Process group"><option value="comma">Comma processes</option><option value="users">Apps and services</option><option value="all">All processes</option></select></div>
<div class="gx-monitor__filters"><input class="gx-field" type="search" v-model="query" aria-label="Search processes" placeholder="Search feature, process, PID or user…"/><GalaxySelect class="gx-field" v-model="scope" aria-label="Process group"><option value="comma">Comma processes</option><option value="users">Apps and services</option><option value="all">All processes</option></GalaxySelect></div>
<p class="gx-note">{{ rows.length }} processes shown. {{ snapshot.cpuPercent == null ? 'Collecting the first CPU sample…' : '' }}</p>
<section class="gx-card gx-monitor__table" tabindex="0" aria-label="Process table; scroll horizontally for more columns">
<table><thead><tr><th v-for="column in [['name','Process'],['pid','PID'],['cpu','CPU'],['memoryMiB','Memory'],['user','User'],['state','Status']]" :key="column[0]" :aria-sort="ariaSort(column[0])"><button type="button" @click="sortBy(column[0])">{{ column[1] }}{{ arrow(column[0]) }}</button></th></tr></thead>
@@ -11,6 +11,7 @@ const TABS = {
errors: "Error Logs",
tmux: "Tmux Live Log",
monitor: "System Monitor",
discord: "Get Help via Discord",
}
function parseLogDate(filename) {
@@ -38,7 +39,7 @@ export const Logs = {
}
},
setup() {
return useTabRouting("/logs", { troubleshoot: "troubleshoot", errors: "errors", tmux: "tmux", monitor: "monitor" })
return useTabRouting("/logs", { troubleshoot: "troubleshoot", errors: "errors", tmux: "tmux", monitor: "monitor", discord: "discord" })
},
created() {
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
@@ -228,6 +229,21 @@ export const Logs = {
<SystemMonitor />
</template>
<template v-else-if="tab === 'discord'">
<section class="gx-card">
<div class="gx-section__header">
<i class="bi bi-discord"></i>
<span class="gx-section__title">{{ TABS.discord }}</span>
</div>
<div style="padding: var(--sp-4); display:grid; gap:12px;">
<p class="gx-note" style="margin:0;">StarPilot has a vibrant, welcoming community discord. Stop by to chat or ask questions!</p>
<a class="gx-btn" href="https://firestar.link/discord" target="_blank" rel="noopener" style="justify-self:start;">
<i class="bi bi-box-arrow-up-right"></i> Get Help via Discord
</a>
</div>
</section>
</template>
<template v-else>
<TroubleshootPanel />
</template>
@@ -237,18 +237,18 @@ export const ModelLaboratory = {
<label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Lateral model</strong>
<small style="color:var(--text-muted); font-size:var(--fs-xs);">Path shape, curvature, lane geometry, and driving desire</small>
<select class="gx-field" :value="configuration.lateralModel" @change="configuration.lateralModel = $event.target.value; onLateralChange()">
<GalaxySelect class="gx-field" :value="configuration.lateralModel" @change="configuration.lateralModel = $event.target.value; onLateralChange()">
<option value="">Choose a model</option>
<option v-for="m in readyModels" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option>
</select>
</GalaxySelect>
</label>
<label style="display:grid; gap:4px;">
<strong style="font-size:var(--fs-sm);">Longitudinal model</strong>
<small style="color:var(--text-muted); font-size:var(--fs-xs);">Speed, acceleration, stopping, leads, and scene confidence</small>
<select class="gx-field" :value="configuration.longitudinalModel" @change="configuration.longitudinalModel = $event.target.value; dirty = true">
<GalaxySelect class="gx-field" :value="configuration.longitudinalModel" @change="configuration.longitudinalModel = $event.target.value; dirty = true">
<option value="">Choose a model</option>
<option v-for="m in candidates" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option>
</select>
</GalaxySelect>
</label>
<div style="display:flex; flex-wrap:wrap; gap:6px; align-items:center;">
<strong>{{ modelLabel(configuration.lateralModel) }}</strong><span class="gx-row__desc" style="margin:0;">steers</span>
@@ -281,47 +281,47 @@ export const ModelManager = {
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">Active Small</span>
<select class="gx-field" style="flex:1;" :value="activeSmallModel" :disabled="!!busy || selectionUncertain || status.isOnroad" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
<GalaxySelect class="gx-field" style="flex:1;" :value="activeSmallModel" :disabled="!!busy || selectionUncertain || status.isOnroad" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
<option v-for="m in installedSmallModels" :key="m.value" :value="m.value">{{ m.label || m.value }}</option>
</select>
</GalaxySelect>
</div>
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">Active Big</span>
<select class="gx-field" style="flex:1;" :value="activeBigModel" :disabled="!!busy || selectionUncertain || status.isOnroad" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
<GalaxySelect class="gx-field" style="flex:1;" :value="activeBigModel" :disabled="!!busy || selectionUncertain || status.isOnroad" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
<option value="">None — always use Active Small</option>
<option v-for="m in installedBigModels" :key="m.value" :value="m.value">{{ m.label || m.value }}</option>
</select>
</GalaxySelect>
</div>
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">Sort</span>
<select class="gx-field" style="flex:1;" :value="sortMode" @change="sortMode = $event.target.value">
<GalaxySelect class="gx-field" style="flex:1;" :value="sortMode" @change="sortMode = $event.target.value">
<option value="release_date">Release Date</option>
<option value="alphabetical">Alphabetical</option>
</select>
</GalaxySelect>
</div>
<div class="gx-row" style="border-top:none;">
<label class="gx-row__label" for="gx-model-hardware">Model hardware</label>
<select id="gx-model-hardware" class="gx-field" style="flex:1; min-width:0;" :value="hardwareFilter" @change="setHardwareFilter($event.target.value)">
<GalaxySelect id="gx-model-hardware" class="gx-field" style="flex:1; min-width:0;" :value="hardwareFilter" @change="setHardwareFilter($event.target.value)">
<option value="both">Both</option>
<option value="gpu">GPU models only</option>
<option value="comma">Comma models only</option>
</select>
</GalaxySelect>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<select class="gx-field" style="flex:1; min-width:140px;" :value="userFilter" @change="userFilter = $event.target.value">
<GalaxySelect class="gx-field" style="flex:1; min-width:140px;" :value="userFilter" @change="userFilter = $event.target.value">
<option value="all">Your Favorite: All</option>
<option value="yes">Your Favorite: Yes</option>
<option value="no">Your Favorite: No</option>
</select>
<select class="gx-field" style="flex:1; min-width:140px;" :value="communityFilter" @change="communityFilter = $event.target.value">
</GalaxySelect>
<GalaxySelect class="gx-field" style="flex:1; min-width:140px;" :value="communityFilter" @change="communityFilter = $event.target.value">
<option value="all">Community: All</option>
<option value="yes">Community: Yes</option>
<option value="no">Community: No</option>
</select>
</GalaxySelect>
</div>
</div>
@@ -63,7 +63,6 @@ export const Recordings = {
playerError: "",
segments: [],
current: 0,
segmentMenuOpen: false,
cameras: [],
selectedCamera: "",
logsRoute: null,
@@ -213,7 +212,6 @@ export const Recordings = {
if (!cameras.length) throw new Error("No camera video for this route.")
this.segments = segments
this.current = 0
this.segmentMenuOpen = false
this.cameras = cameras
this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0]
this.$nextTick(() => this.playSegment())
@@ -246,14 +244,7 @@ export const Recordings = {
video.load()
video.play().catch(() => {})
},
toggleSegmentMenu() {
this.segmentMenuOpen = !this.segmentMenuOpen
if (this.segmentMenuOpen) {
this.$nextTick(() => this.$el.querySelector(".gx-segment-picker__item.active")?.scrollIntoView({ block: "nearest" }))
}
},
selectSegment(i) {
this.segmentMenuOpen = false
if (i === this.current) return
this.current = i
this.playSegment()
@@ -272,7 +263,6 @@ export const Recordings = {
this.playerLoading = false
this.playerError = ""
this.segments = []
this.segmentMenuOpen = false
this.cameras = []
},
async openLogs(route) {
@@ -368,12 +358,12 @@ export const Recordings = {
</div>
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
<input class="gx-field" style="flex:1; min-width:160px;" type="search" placeholder="Search routes, dates, or IDs..." v-model="searchQuery" />
<select class="gx-field" v-model="sortOrder">
<GalaxySelect class="gx-field" v-model="sortOrder">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="longest">Longest duration</option>
<option value="shortest">Shortest duration</option>
</select>
</GalaxySelect>
</div>
<div style="padding: 0 var(--sp-3) var(--sp-3);">
<GalaxyTabs :items="{ all: 'All', preserved: 'Preserved' }" :active="showPreservedOnly ? 'preserved' : 'all'" @select="setPreservedFilter" />
@@ -469,7 +459,7 @@ export const Recordings = {
<Teleport to="body">
<transition name="gx-fade">
<div v-if="sub === 'routes' && playerRoute" class="gx-scrim gx-scrim--bottomsheet" @click.self="closePlayer">
<div class="gx-sheet" role="dialog" aria-label="Route video player" @click="segmentMenuOpen = false">
<div class="gx-sheet" role="dialog" aria-label="Route video player">
<div class="gx-section__header gx-video-player-header" style="cursor:default;">
<i class="bi bi-camera-video"></i>
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
@@ -483,15 +473,9 @@ export const Recordings = {
<div style="display:flex; flex-direction:column; gap:8px; padding: var(--sp-3) 0 0;">
<div class="gx-video-segment-controls">
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" aria-label="Previous segment" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-chevron-left"></i></button>
<div class="gx-segment-picker gx-video-segment-select">
<button type="button" class="gx-segment-picker__trigger" :aria-expanded="segmentMenuOpen ? 'true' : 'false'" @click.stop="toggleSegmentMenu">
<span>Segment {{ current + 1 }} of {{ segments.length }}</span>
<i class="bi" :class="segmentMenuOpen ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
<div v-if="segmentMenuOpen" class="gx-segment-picker__menu" role="listbox" @click.stop>
<button v-for="(s,i) in segments" :key="i" type="button" class="gx-segment-picker__item" :class="{ active: i === current }" role="option" :aria-selected="i === current ? 'true' : 'false'" @click="selectSegment(i)">Segment {{ i + 1 }}</button>
</div>
</div>
<GalaxySelect class="gx-field gx-video-segment-select" aria-label="Video segment" :value="String(current)" :disabled="!segments.length" @change="selectSegment(Number($event.target.value))">
<option v-for="(s, i) in segments" :key="i" :value="String(i)">Segment {{ i + 1 }} of {{ segments.length }}</option>
</GalaxySelect>
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" aria-label="Next segment" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-chevron-right"></i></button>
</div>
<div class="gx-video-actions" style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
@@ -2,7 +2,6 @@ import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxySelect } from "../components/GalaxySelect.js"
import { VersionHistoryPicker, versionTitle, releaseVersions } from "../components/VersionHistoryPicker.js"
import { GxNotice } from "../components/GxNotice.js"
@@ -59,7 +58,7 @@ async function resolveDeviceScope() {
export const SystemTools = {
name: "SystemTools",
components: { GalaxySection, GxNotice, GalaxySelect, VersionHistoryPicker },
components: { GalaxySection, GxNotice, VersionHistoryPicker },
data() {
return {
branches: [],
@@ -214,9 +214,9 @@ export const TestingGround = {
<div class="gx-row" style="border-top:none;">
<span class="gx-row__label">View Slot</span>
<select class="gx-field" :value="selectedSlot" :disabled="busy" @change="onSelectSlot">
<GalaxySelect class="gx-field" :value="selectedSlot" :disabled="busy" @change="onSelectSlot">
<option v-for="slot in selectableSlots" :key="slotId(slot)" :value="slotId(slot)">{{ slotId(slot) }}. {{ slot.name }}</option>
</select>
</GalaxySelect>
</div>
<p class="gx-note">
Only one Testing Ground can be active at a time. Switching slots only changes what you're viewing; the active test stays enabled until you explicitly choose another mode.
@@ -90,10 +90,10 @@ export const Tsk = {
<div v-if="loading" class="gx-loading">Loading keys...</div>
<template v-else>
<label class="gx-row__label" style="font-size:var(--fs-xs);">Select Key</label>
<select class="gx-field gx-field--full" :value="selectedKeyName" @change="selectKey($event.target.value)">
<GalaxySelect class="gx-field gx-field--full" :value="selectedKeyName" @change="selectKey($event.target.value)">
<option value="">-- Select a saved key --</option>
<option v-for="k in keys" :key="k.name" :value="k.name">{{ k.name }}</option>
</select>
</GalaxySelect>
<label class="gx-row__label" style="font-size:var(--fs-xs);">Key Name</label>
<input class="gx-field gx-field--full" v-model="keyName" @input="selectedKeyName = ''" placeholder="Enter key name..." autocomplete="off" />
@@ -56,14 +56,17 @@ window.app=createApp(Settings); window.vm=window.app.mount('#app');
})
await page.goto('http://offline.invalid/')
await page.evaluate(scale=>{document.documentElement.style.zoom=String(scale)},cfg.scale)
const select=page.locator('#gx-longitudinal-mode'), manage=page.locator('[aria-controls="gx-longitudinal-children"]')
const waitMode=mode=>page.waitForFunction(mode=>{const e=document.querySelector('#gx-longitudinal-mode');return e?.value===mode&&!e.disabled},mode)
const trigger=page.locator('#gx-longitudinal-mode'), manage=page.locator('[aria-controls="gx-longitudinal-children"]')
const modeSelect=trigger.locator('xpath=..')
const nativeValue=()=>page.evaluate(()=>document.querySelector('#gx-longitudinal-mode')?.closest('.gx-select')?.querySelector('select')?.value)
const waitMode=mode=>page.waitForFunction(mode=>{const b=document.querySelector('#gx-longitudinal-mode');const s=b?.closest('.gx-select')?.querySelector('select');return s?.value===mode&&!b.disabled},mode)
const choose=async value=>{await (cfg.touch?trigger.tap():trigger.click());await page.locator(`.gx-select-menu[open] [role="option"][data-value="${value}"]`).click()}
await waitMode('conditional_experimental')
assert.equal(await page.locator('.gx-mode-select__label span').innerText(),'Conditional Experimental')
assert.equal(await page.locator('.gx-mode-select__label span').evaluate(e=>e.scrollWidth<=e.clientWidth+1),true)
await select.focus()
assert.equal(await page.locator('.gx-mode-select').evaluate(e=>getComputedStyle(e).outlineStyle),'solid')
assert.deepEqual(await select.locator('option').allTextContents(),['Chill','Experimental','Conditional Experimental','Conditional Chill'])
assert.equal(await page.locator('#gx-longitudinal-mode > span').innerText(),'Conditional Experimental')
assert.equal(await page.locator('#gx-longitudinal-mode > span').evaluate(e=>e.scrollWidth<=e.clientWidth+1),true)
await trigger.focus()
assert.equal(await modeSelect.evaluate(e=>getComputedStyle(e).outlineStyle),'solid')
assert.deepEqual(await modeSelect.locator('select option').allTextContents(),['Chill','Experimental','Conditional Experimental','Conditional Chill'])
assert.equal(await page.evaluate(()=>writes.length),0)
assert.equal(await manage.getAttribute('aria-expanded'),'false')
assert.equal(await page.locator('#gx-longitudinal-children').count(),0)
@@ -75,7 +78,7 @@ window.app=createApp(Settings); window.vm=window.app.mount('#app');
// Every direct classic child appears with identical description and native control.
for(const owner of ['ConditionalExperimental','ConditionalChill']) {
const target=owner==='ConditionalExperimental'?'conditional_experimental':'conditional_chill'
if(target!=='conditional_experimental') {await select.selectOption(target);await waitMode(target)}
if(target!=='conditional_experimental') {await choose(target);await waitMode(target)}
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-${target}.png`),fullPage:true})
for(const p of layout.flatMap(s=>s.params||[]).filter(p=>p.parent_key===owner)) {
assert.ok((await children.innerText()).includes(p.label),p.key)
@@ -99,23 +102,23 @@ window.app=createApp(Settings); window.vm=window.app.mount('#app');
await slider.focus();await slider.press('ArrowRight');await slider.press('Tab')
assert.ok(await page.evaluate(()=>paramWrites.length>0))
for(const target of ['chill','experimental']) {
await select.selectOption(target);await waitMode(target);assert.equal(await manage.count(),0);assert.equal(await children.count(),0)
await choose(target);await waitMode(target);assert.equal(await manage.count(),0);assert.equal(await children.count(),0)
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-${target}.png`),fullPage:true})
}
assert.equal(await page.evaluate(()=>writes.at(-1).acknowledged),true)
await page.evaluate(()=>{holdWrite=true});await select.selectOption('conditional_chill')
await page.evaluate(()=>{holdWrite=true});await choose('conditional_chill')
await page.waitForFunction(()=>!!window.releaseWrite)
assert.equal(await select.inputValue(),'experimental');assert.equal(await select.isDisabled(),true)
assert.equal(await nativeValue(),'experimental');assert.equal(await trigger.isDisabled(),true)
await page.evaluate(()=>{holdWrite=false;releaseWrite()});await waitMode('conditional_chill')
await page.evaluate(()=>{failWrite=true});await select.selectOption('chill');await waitMode('conditional_chill')
await page.evaluate(()=>{failWrite=true});await choose('chill');await waitMode('conditional_chill')
assert.ok((await page.locator('[role=alert]').innerText()).includes('Injected'))
await page.evaluate(()=>{failWrite=false;externalMode('conditional_experimental')});await waitMode('conditional_experimental')
// Routine polling never dims an available control; stale pre-write GET is ignored.
await page.evaluate(()=>{holdRead=true});await page.waitForFunction(()=>!!window.releaseRead)
assert.equal(await select.isEnabled(),true)
await select.selectOption('conditional_chill');await waitMode('conditional_chill')
assert.equal(await trigger.isEnabled(),true)
await choose('conditional_chill');await waitMode('conditional_chill')
await page.evaluate(()=>{holdRead=false;releaseRead()});await page.waitForTimeout(100)
assert.equal(await select.inputValue(),'conditional_chill')
assert.equal(await nativeValue(),'conditional_chill')
for(const reason of ['Locked by Safe Mode.','openpilot longitudinal unavailable.']) {
await page.evaluate(reason=>{state={...state,locked:true,reason}},reason)
await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').disabled)
@@ -130,13 +133,13 @@ window.app=createApp(Settings); window.vm=window.app.mount('#app');
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-dark.png`),fullPage:true})
await page.evaluate(()=>document.documentElement.setAttribute('data-theme','light'))
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-light.png`),fullPage:true})
await page.evaluate(()=>{failRead=true});await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').value==='')
assert.equal(await select.isDisabled(),true)
await page.evaluate(()=>{failRead=true});await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').closest('.gx-select').querySelector('select').value==='')
assert.equal(await trigger.isDisabled(),true)
await page.evaluate(()=>{failRead=false});await waitMode('conditional_chill')
// Search must use the same guarded selector, never generic Params for virtual key.
await page.evaluate(()=>{store.search='Longitudinal control mode'})
await page.locator('.gx-section__header').last().click()
await waitMode('conditional_chill');await select.selectOption('chill');await waitMode('chill')
await waitMode('conditional_chill');await choose('chill');await waitMode('chill')
assert.equal(await page.evaluate(()=>paramWrites.some(p=>p.key==='LongitudinalControlMode')),false)
assert.deepEqual(errors,[])
reports.push({...cfg,passed:true,writes:await page.evaluate(()=>writes.length)})
@@ -282,6 +282,8 @@ test('Latest explains normal OS handling and local-edit behavior; historical con
assert.match(confirmations[0].message, /required OS update/)
assert.match(confirmations[0].message, /Local code changes may be overwritten/)
assert.doesNotMatch(confirmations[0].message, /code changes are backed up/)
instance.rebootPending = false
instance.rebootStartedAt = 0
await instance.onVersionModeSelect({target:{value:'earlier'}})
instance.selectedCommit=SHA
await instance.installSelectedVersion()
@@ -54,13 +54,15 @@ for (const surface of ['classic','mobile']) for (const scenario of ['stale-poll'
new MutationObserver(records=>{for(const r of records) if(r.target.id==='snackbar_wrapper') for(const n of r.addedNodes) window.notices.push(n.textContent);}).observe(document,{childList:true,subtree:true});
});
const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select');
const trigger=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('.gx-select__button'):null;
const choose=async value=>{if(mobile){await trigger.click();await page.locator(`.gx-select-menu[open] [role="option"][data-value="${value}"]`).click();}else{await select.selectOption(value);}};
const until=async(fn,msg)=>{for(let i=0;i<80;i++){if(await fn())return;await tick();}throw new Error(msg);};
let failure=null;
try {
await page.goto('http://galaxy.invalid/manage_models');
await until(async()=>await select.count() && await select.inputValue()==='gpu-a','initial selection');
if(scenario==='stale-poll') {holdNext=true;await page.evaluate(()=>{window.poll()});await until(()=>heldGet,'held prewrite poll');}
await select.selectOption('');
await choose('');
await until(()=>writes.length===1,'selection PUT');
assert.deepEqual(writes,[{method:'PUT',path:'/api/models/active',body:{profile:'big',model:''}}]);
if(scenario==='stale-poll') {
@@ -45,13 +45,15 @@ try {
const rows=page.locator(rowSelector);
await rows.first().waitFor();
const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select');
const trigger=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('.gx-select__button'):null;
const choose=async value=>{if(mobile){await trigger.click();await page.locator(`.gx-select-menu[open] [role="option"][data-value="${value}"]`).click();}else{await select.selectOption(value);}};
assert.equal(await select.inputValue(),'fixture-0');
await select.selectOption('');
await choose('');
await new Promise(r=>setTimeout(r,350));
assert.deepEqual(writes,[{path:'/api/models/active',body:{profile:'big',model:''}}],`${surface}: None must disable Active Big through API`);
assert.equal(await select.inputValue(),process.env.MODEL_FAULT === 'reject' ? 'fixture-0' : '', 'Selection must match authoritative status after uncertain response');
if (process.env.MODEL_FAULT) assert.ok(polls >= 2, 'Uncertain write requires immediate authoritative readback');
await select.selectOption('fixture-2');
await choose('fixture-2');
await new Promise(r=>setTimeout(r,350));
assert.equal(writes.length,2);
assert.deepEqual(writes[1],{path:'/api/models/active',body:{profile:'big',model:'fixture-2'}});
@@ -36,9 +36,9 @@ vm=await reset();const pending=deferred();write=()=>pending.promise;const action
vm=await reset();const confirmation=deferred();prompt=()=>confirmation.promise;const downloading=vm.runAction('download',vm.models[0]);await vm.runAction('downloadAll');assert.deepEqual(requests,[]);component.beforeUnmount.call(vm);confirmation.resolve(true);await downloading;assert.deepEqual(requests,[]);
// Two concurrent polls resolve newest-first.
vm=await reset();const a=deferred(),b=deferred();status=()=>a.promise;const first=vm.refresh();status=()=>b.promise;const second=vm.refresh();b.resolve(payload({activeBigModel:'new'}));await second;a.resolve(payload({activeBigModel:'old'}));await first;assert.equal(vm.activeBigModel,'new');
assert.ok(!source.includes('GalaxySelect'));assert.ok(!source.includes('model_metrics'));assert.ok(!source.includes('model_stats'));assert.ok(source.includes('selectionUncertain || status.isOnroad'));
assert.ok(source.includes('<GalaxySelect'));assert.ok(!source.includes('model_metrics'));assert.ok(!source.includes('model_stats'));assert.ok(source.includes('selectionUncertain || status.isOnroad'));
console.log('PASS: 15 hardware/download/selection/lifecycle scenarios against shipped methods');
const {compile} = await import('../assets/vendor/vue/vue.esm-browser.js');
assert.equal(typeof compile(component.template, {onError(error) {throw error;}}), 'function');
console.log('PASS: shipped Vue compiler accepts the native-select Model Manager template');
console.log('PASS: shipped Vue compiler accepts the GalaxySelect Model Manager template');
@@ -78,7 +78,7 @@ def test_ui_device_picker_uses_gateway_directory_and_preserves_local_galaxy():
assert shell.index('<DevicePicker />') > shell.index('v-for="(links, section) in NAV"')
assert '"GalaxyDeviceName", {PERSISTENT | DONT_LOG, STRING' in param_keys
assert "localStorage" not in picker
assert ".gx-drawer.open ~ .liquid-glass-nav" in css
assert ".gx-drawer.open ~ .blur-nav" in css
assert "Local Galaxy instances do not have the gateway directory endpoint." in picker
@@ -331,11 +331,9 @@ def test_ui_developer_mode_banner_offers_unlock():
def test_ui_has_bottom_navigation_and_drawer():
shell = _read("js/components/AppShell.js")
# Exactly one navigation affordance: liquid-glass bottom nav (mobile) OR the
# drawer hamburger (desktop). Mobile shows a back button instead.
assert "liquid-glass-nav" in shell
assert "blur-nav" in shell
assert "nav-item" in shell
assert "gx-menu-btn" in shell
assert "gx-appbar__menu" in shell
assert "gx-back-btn" in shell
assert "goBack" in shell or "back()" in shell
assert "gx-drawer" in shell
@@ -351,23 +349,23 @@ def test_ui_search_visible_on_mobile_and_content_full_width():
# Search must NOT be hidden on mobile (regression: it was display:none <600px).
assert ".gx-appbar__search" in css
# A single breakpoint picks mobile (bottom nav + back) vs desktop (drawer).
assert ".liquid-glass-nav { display: flex; }" in css
assert ".gx-menu-btn { display: none; }" in css
assert ".blur-nav { display: flex; }" in css
assert ".gx-appbar__menu { display: inline-flex; }" in css
assert ".gx-back-btn { display: inline-flex; }" in css
# Content + embedded tools fill the available width (no 760px cap).
assert "max-width: none" in css
# Liquid Glass styling is present.
# Blur nav styling is present.
assert "--glass-bg" in css
assert "backdrop-filter" in css
def test_ui_glass_nav_single_breakpoint_no_dual_nav():
def test_ui_blur_nav_single_breakpoint_no_dual_nav():
css = _read("css/material.css")
assert ".liquid-glass-nav" in css
assert ".blur-nav" in css
assert "@media (min-width: 768px)" in css
assert ".liquid-glass-nav { display: none; }" in css
assert ".blur-nav { display: none; }" in css
assert ".gx-back-btn { display: none; }" in css
assert ".gx-menu-btn { display: inline-flex; }" in css
assert ".gx-appbar__menu { display: inline-flex; }" in css
def test_ui_settings_deep_links_and_dev_mode_updates():