(Another third) round of Galaxy UI bug fixes

Centered loading states and unified page layouts, margins, and shared controls.
Added horizontal tab transitions and a moving glass navigation indicator.
Fixed mobile button overflow and compacted model summaries.
Removed unused code and styles.
This commit is contained in:
Prabhaav Pillai
2026-10-06 03:22:50 -04:00
parent b53e9201f3
commit 5ffc32f2a5
40 changed files with 474 additions and 277 deletions
@@ -0,0 +1,79 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/js/boot.js', route => route.fulfill({ body: '' }))
await page.route('**/api/**', route => route.fulfill({ status: 404, body: '' }))
await page.goto(process.env.GALAXY_URL || 'http://127.0.0.1:8765/')
await page.evaluate(() => { document.body.innerHTML = '<main class="gx-content"><div id="fixture"></div></main>' })
async function mount(kind) {
await page.evaluate(async kind => {
const { createApp } = await import('./vendor/vue/vue.esm-browser.js')
window.fixtureApp?.unmount()
document.querySelector('#fixture').innerHTML = ''
const component = kind === 'android' ? (await import('./js/android-auto.js')).AndroidAutoPage :
(await import('./js/record-history.js')).LocalRecordingsPage
const routeId = '2026-09-28--01-30-00'
const segments = [0, 1, 2].map(number => ({ number, segmentName: `${routeId}--${number}`, files: { fcamera: true } }))
const data = kind === 'android' ? {
setup: { enabled: false, bluetoothEnabled: true, parked: true, installReady: true, serviceReady: true,
identity: { installed: true, message: 'Package ready' }, import: { state: 'idle' }, maxUploadBytes: 200 * 1048576 },
packageFile: { name: 'android-auto-package.apk', size: 1048576 },
} : { status: 'ready', data: { routes: [] }, playing: { routeId, route: { segments }, segments,
index: 1, camera: 'fcamera', url: './api/fixture-video' } }
window.uploadClicks = 0
window.fixtureApp = createApp({ ...component,
data: () => ({ ...component.data(), ...data }),
created() {},
mounted() { if (kind === 'recording') this.$refs.playerPanel.showModal() },
beforeUnmount() {},
methods: { ...component.methods, upload() { window.uploadClicks++ }, videoError() {} },
}, { mode: 'local', localAccess: true, unauthorized() {} })
window.fixtureVM = window.fixtureApp.mount('#fixture')
}, kind)
}
async function checkActions(selector) {
const overflow = await page.locator(selector).evaluateAll(elements => elements
.filter(element => element.getClientRects().length)
.filter(element => element.scrollWidth > element.clientWidth + 1)
.map(element => element.className))
assert.deepEqual(overflow, [], 'action groups need no horizontal scrolling')
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
}
for (const width of [320, 360, 390, 600, 1280]) {
await page.setViewportSize({ width, height: 900 })
await mount('android')
await checkActions('.gx-driving__actions')
await page.getByRole('button', { name: 'Enable & upload', exact: true }).click()
assert.equal(await page.evaluate(() => window.uploadClicks), 1)
await page.evaluate(() => { window.fixtureVM.setup.enabled = true })
const upload = page.getByRole('button', { name: 'Upload package', exact: true })
await upload.click()
assert.equal(await page.evaluate(() => window.uploadClicks), 2, 'icon clicks reach the existing handler')
await page.evaluate(() => { window.fixtureVM.busy = true })
assert.equal(await upload.isDisabled(), true, 'disabled state reaches the native icon button')
await checkActions('.gx-driving__actions')
await mount('recording')
await checkActions('.gx-recordings__player-controls, .gx-recordings__actions')
const next = page.getByRole('button', { name: 'Next segment', exact: true })
await next.click()
assert.equal(await next.isDisabled(), true)
const previous = page.getByRole('button', { name: 'Previous segment', exact: true })
await previous.click()
await previous.click()
assert.equal(await previous.isDisabled(), true)
await page.getByRole('button', { name: 'Close recording player', exact: true }).click()
assert.equal(await page.getByRole('dialog', { name: 'Camera recording player' }).count(), 0)
}
assert.deepEqual(errors, [])
console.log('Android Auto and recording controls fit five widths; icon names, handlers and disabled states passed.')
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,78 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 390, height: 900 } })
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/data/runtime.json', route => route.fulfill({ json: { schemaVersion: 1, monitor: 'local' } }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const json = path === '/api/auth/session' ? { state: 'configured', authenticated: true, localAccess: true } :
path === '/api/device/state' ? { state: 'parked', maxAgeMs: 3000 } :
path.startsWith('/api/settings/pages/') ? { page: 'hub', view: 'test', parked: true, rows: [] } : null
return route.fulfill({ status: json ? 200 : 503, json: json || { error: 'Unavailable in this test' } })
})
const base = process.env.GALAXY_URL || 'http://127.0.0.1:8765/'
await page.goto(base)
const nav = page.getByRole('navigation', { name: 'Primary navigation' })
await nav.getByRole('button', { name: 'Home', exact: true }).waitFor()
async function checkSlide(name, direction) {
await nav.getByRole('button', { name, exact: true }).click()
await page.waitForFunction(() => !!document.querySelector('.gx-route-enter-active'))
const motion = await page.locator('.gx-route-enter-active').evaluate(element => {
const matrix = new DOMMatrix(getComputedStyle(element).transform)
return { x: matrix.m41, y: matrix.m42 }
})
assert.ok(motion.x * direction > 0, `${name} enters from the correct horizontal side`)
assert.equal(motion.y, 0)
await page.waitForTimeout(500)
const geometry = await nav.evaluate(element => {
const pill = element.querySelector('.gx-nav-indicator').getBoundingClientRect()
const button = element.querySelector('[aria-current="page"]').getBoundingClientRect()
return { delta: Math.abs(pill.x - button.x), widthDelta: Math.abs(pill.width - button.width) }
})
assert.ok(geometry.delta < 2 && geometry.widthDelta < 2, 'glass indicator settles over the selected tab')
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
}
await checkSlide('Tools', 1)
await checkSlide('Home', -1)
await checkSlide('Recordings', 1)
await checkSlide('Toggles', -1)
// Fast changes must leave only one interactive page and the correct selected tab.
await nav.getByRole('button', { name: 'Tools', exact: true }).click()
await nav.getByRole('button', { name: 'Home', exact: true }).click()
await page.waitForTimeout(600)
assert.equal(await page.locator('.gx-route:not([inert])').count(), 1)
assert.equal(await nav.getByRole('button', { name: 'Home', exact: true }).getAttribute('aria-current'), 'page')
await page.goto(base + '#/model_laboratory')
const heading = page.getByText('Available models', { exact: true })
await heading.waitFor()
for (const width of [320, 360, 390, 600, 1280]) {
await page.setViewportSize({ width, height: 900 })
const layout = await page.evaluate(() => {
const title = [...document.querySelectorAll('.gx-section__title')].find(element => element.textContent === 'Available models')
const summary = title.closest('.gx-card').querySelector('.gx-summary')
const cells = [...summary.children].map(element => element.getBoundingClientRect())
return { headerHeight: title.parentElement.getBoundingClientRect().height,
summaryHeight: summary.getBoundingClientRect().height,
equalRows: cells.every(cell => Math.abs(cell.top - cells[0].top) < 1),
overflow: document.documentElement.scrollWidth > innerWidth }
})
assert.ok(layout.headerHeight < 80 && layout.summaryHeight < 100, 'model summary stays compact')
assert.equal(layout.equalRows, true, 'all three counts share one row')
assert.equal(layout.overflow, false)
}
await page.setViewportSize({ width: 390, height: 900 })
await page.emulateMedia({ reducedMotion: 'reduce' })
await nav.getByRole('button', { name: 'Home', exact: true }).click()
await page.waitForTimeout(50)
assert.equal(await page.locator('.gx-route-enter-active, .gx-route-leave-active').count(), 0)
assert.equal(await page.locator('.gx-nav-indicator').evaluate(element => getComputedStyle(element).transitionDuration), '0s')
assert.deepEqual(errors, [])
console.log('Directional tab slides, moving glass selection, rapid navigation, compact model summary and reduced motion passed.')
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,80 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
const { readFileSync } = require('node:fs')
const { resolve } = require('node:path')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
const base = process.env.GALAXY_URL || 'http://127.0.0.1:8765/'
const catalog = JSON.parse(readFileSync(resolve(__dirname, '../../web/data/catalog.json')))
let local = false, delayed = false
const groups = [['aol', 'Always On Lateral'], ['lane_change', 'Lane Changes'],
['torque', 'Steering and Torque'], ['lane', 'Lane Centering']]
await page.route('**/data/runtime.json', route => route.fulfill({
json: { schemaVersion: 1, monitor: local ? 'local' : 'sample' },
}))
await page.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname
if (path === '/api/auth/session') return route.fulfill({
json: { state: 'configured', authenticated: true, localAccess: true },
})
if (path === '/api/device/state') return route.fulfill({ json: { state: 'parked', maxAgeMs: 3000 } })
if (path.startsWith('/api/settings/pages/')) {
if (delayed) await new Promise(resolve => setTimeout(resolve, 600))
const id = path.split('/').at(-1)
return route.fulfill({ json: { page: id, title: id, view: 'test', parked: true,
rows: id === 'hub' ? groups.map(([page, label]) => ({ page, label, value: 'Configure ' + label, available: true })) :
[{ label: 'Test preference', key: 'test', value: 'Off', choices: ['Off', 'On'], action: true, available: true }],
} })
}
return route.fulfill({ status: 503, json: { error: 'Device unavailable. Reconnect to read this tool.' } })
})
const paths = [...new Set(['/', '/tools', '/settings', '/developer/connect', '/cameras/events',
'/cameras/sentry-settings', '/cameras/pip', '/cameras/vasm', '/theme_maker/android_auto',
'/logs/crashes', '/logs/troubleshoot', '/logs/tmux', '/tuning/plots', '/tuning/flm',
'/driving/longitudinal-curves', '/appearance', '/driving/conditional', '/driving/curve', '/driving/slc', '/driving/profiles', '/driving/traffic',
...groups.map(([page]) => '/driving/' + page), ...catalog.tools.map(tool => tool.path)])]
for (const width of [360, 1280]) {
await page.setViewportSize({ width, height: 900 })
for (const path of paths) {
await page.goto(base + '#' + path)
await page.waitForTimeout(250)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false,
`${width}px ${path}: no page overflow`)
}
}
local = true
await page.reload()
await page.setViewportSize({ width: 390, height: 900 })
await page.goto(base + '#/settings')
const manage = page.getByRole('button', { name: 'Manage', exact: true }).first()
await manage.waitFor()
const widths = await page.locator('.gx-row').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().width))
assert.ok(widths.every(width => Math.abs(width - widths[0]) < 1), 'settings groups share one width')
await manage.click()
await page.waitForTimeout(100)
assert.equal(await page.locator('.gx-settings-tabs').count(), 0)
assert.equal(await page.getByRole('heading', { name: 'Toggles', exact: true }).count(), 0)
await page.getByRole('button', { name: 'Back', exact: true }).click()
await manage.waitFor()
delayed = true
await page.reload()
const loading = page.getByRole('status').filter({ hasText: 'Reading your device' })
await loading.waitFor()
const bounds = await loading.boundingBox()
assert.ok(Math.abs(bounds.x + bounds.width / 2 - 195) < 1, 'loading card is centered')
await page.waitForTimeout(750)
await page.goto(base + '#/android-auto')
await page.waitForTimeout(300)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
assert.deepEqual(errors, [], 'preview routes and unavailable Android Auto setup render without exceptions')
console.log(`Passed ${paths.length} routes at two widths, settings navigation, loading alignment and setup failure.`)
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -29,14 +29,15 @@ assert.equal(page.state.query, "")
assert.match(CamerasPage.template, /Adjust the camera crop with a live cabin preview/)
assert.doesNotMatch(CamerasPage.template, /live camera preview is unavailable/)
assert.match(CamerasPage.template, /Sentry[\s\S]*View motion events/)
assert.match(CamerasPage.template, /Saved motion settings/)
assert.match(CamerasPage.template, /V-ASM[\s\S]*Open saved settings/)
assert.match(CamerasPage.template, /saved motion settings/)
assert.doesNotMatch(CamerasPage.template, /go\('\/cameras\/sentry-settings'\)/)
assert.match(CamerasPage.template, /MenuTile[^>]*title="V-ASM"/)
assert.match(CamerasPage.template, /Preview the cabin camera/)
assert.match(CamerasPage.template, /snapshots.capture\(camera\)/)
assert.match(SettingsPage.template, /\{\{ title \}\}/)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
assert.match(app, /<PipPage v-else-if="route\.path === '\/cameras\/pip'"/)
assert.match(app, /route\.path === '\/cameras\/sentry-settings'[\s\S]*initial-page="sentry"/)
assert.match(app, /SentryEventsPage[^>]*\/cameras\/events[^>]*\/cameras\/sentry-settings/)
assert.match(app, /CamerasPage v-else-if="route\.path === '\/cameras'"/)
const captures = [], snapshots = [], revoked = []
@@ -10,7 +10,7 @@ assert.ok(ids.every(id => recordArt(id).every(path => typeof path === "string" &
assert.ok(recordArt("futureRecord").length)
assert.ok(Home.template.includes('recordArt(record.id)'))
assert.ok(Home.template.includes('aria-hidden="true"><svg'))
assert.ok(GalaxyPage.template.includes('>Galaxy Tunnel</h2>'))
assert.ok(GalaxyPage.template.includes('>Install Galaxy / Tunnel</h2>'))
assert.ok(!GalaxyPage.template.includes('Galaxy & App Install'))
assert.ok(GalaxyPage.template.includes('Progressive Web App'))
assert.ok(DriveStatePanel.template.includes('gx-card gx-force-drive'))
@@ -62,7 +62,7 @@ const canceling=download.feed.action('cancel');assert.equal(download.requests[3]
await download.reply(3,{message:'Cancelling'});await download.reply(4,p);await canceling;download.feed.stop()
assert.ok(LaboratoryPage.template.indexOf('Available models')<LaboratoryPage.template.indexOf('Compose a pair'))
assert.match(LaboratoryPage.template,/GalaxySelect/)
assert.match(LaboratoryPage.template,/catalog models/)
assert.match(LaboratoryPage.template,/Catalog models/)
assert.match(LaboratoryPage.template,/modelLabReason/)
assert.equal(laboratoryStatusLabel({modelLabStatus:"runtime-unavailable"}), "Downloaded")
assert.equal(laboratoryStatusLabel({modelLabStatus:"unsupported"}), "Not supported")
@@ -233,7 +233,7 @@ await disabledRandomizer
randomManagement.manager.stop()
console.log('Randomizer and exclusion interactions passed')
assert.match(ModelsPage.template, /Model changes and downloads require parked device status/)
assert.match(ModelsPage.template, /Park the vehicle before changing or downloading models/)
assert.doesNotMatch(ModelsPage.template, /Onroad: switching/)
assert.equal((ModelsPage.template.match(/gx-row gx-model-control/g) || []).length, 4)
const feedbackPage = { ...ModelsPage.data(), unauthorized() {}, canAction() { return true } }
@@ -19,7 +19,7 @@ assert.match(SentryEventsPage.template, /<img[^>]*event.images[^>]*api\/sentry\/
assert.doesNotMatch(SentryEventsPage.template, /v-html|deleteEvent|Download/)
assert.match(CamerasPage.template, /View motion events/)
const app = readFileSync(new URL('../web/js/app.js', import.meta.url), 'utf8')
assert.match(app, /route\.path === '\/cameras\/events'/)
assert.match(app, /SentryEventsPage[^>]*\/cameras\/events[^>]*\/cameras\/sentry-settings/)
function fixture() {
const requests = [], states = [], timers = new Map()
+58 -88
View File
@@ -1,5 +1,5 @@
/* Focused extract of frozen StarPilot Galaxy material.css, commit 678af78347d9656bc2f5dacd4204b012db861fa7. See ../LICENSE and README.md. */
.gx-settings { display: grid; gap: 18px; max-width: 1120px; margin: 0 auto; }
.gx-settings { display: grid; gap: var(--sp-4); width: 100%; min-width: 0; margin: 0 auto; }
.gx-settings__header, .gx-settings__subhead { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.gx-card.gx-settings__header, .gx-card.gx-settings__row, .gx-card.gx-settings__dialog { padding: 24px; }
.gx-settings__header h2, .gx-settings__subhead h3, .gx-settings__row h4 { margin: 0 0 8px; }
@@ -7,8 +7,7 @@
.gx-settings__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); gap: 16px; }
.gx-settings__row { display: flex; flex-direction: column; justify-content: space-between; gap: 16px; }
.gx-settings__row p { margin: 4px 0; }
.gx-settings__value { font-size: 1.12rem; color: var(--on-surface); }
.gx-settings__controls { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.gx-settings__controls { justify-content: flex-end; }
.gx-settings__controls button { min-height: var(--touch-target); }
.gx-settings__modal { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: 20px; background: rgba(0, 0, 0, .72); }
.gx-card.gx-settings__dialog { width: min(100%, 560px); max-height: calc(100dvh - 40px); overflow-y: auto; display: grid; gap: 18px; }
@@ -47,10 +46,6 @@
--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;
@@ -60,32 +55,24 @@
--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);
--card-scroll-bg: rgba(22, 18, 38, 0.95);
--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);
@@ -93,18 +80,16 @@
--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;
--sp-5: 24px; --sp-6: 32px;
--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;
--fs-lg: 1.2rem; --fs-xl: 1.45rem;
--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;
--bottomnav-clearance: calc(var(--bottomnav-height) + env(safe-area-inset-bottom, 0px) + var(--sp-5));
--touch-target: 44px;
@@ -132,9 +117,6 @@
--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;
@@ -144,22 +126,17 @@
--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);
--card-scroll-bg: rgba(245, 246, 252, 0.96);
--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);
@@ -295,7 +272,7 @@ body {
}
/* Keep decorative animation out of modal backdrops and drawer content. */
body:has(.gx-scrim, .gx-underlay, .gx-drawer.open) #galaxy-bg .galaxy-hero {
body:has(.gx-underlay, .gx-drawer.open) #galaxy-bg .galaxy-hero {
display: none;
}
@@ -315,7 +292,6 @@ body:has(.gx-scrim, .gx-underlay, .gx-drawer.open) #galaxy-bg .galaxy-hero {
[v-cloak] { display: none !important; }
a { color: inherit; text-decoration: none; }
.gx-link { color: var(--primary); text-decoration: underline; text-decoration-color: var(--primary-glow); text-underline-offset: 0.16em; }
button { font-family: var(--font-body); }
ul { list-style: none; margin: 0; padding: 0; }
@@ -399,12 +375,6 @@ ul { list-style: none; margin: 0; padding: 0; }
letter-spacing: -0.02em;
}
.gx-appbar__title .gx-logo {
height: 28px;
margin-right: var(--sp-2);
vertical-align: middle;
}
.gx-appbar__home {
flex: none;
min-height: var(--touch-target);
@@ -614,10 +584,11 @@ ul { list-style: none; margin: 0; padding: 0; }
contain: layout style;
z-index: 1000;
display: flex;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 6px;
padding: 6px 12px;
width: min(420px, calc(100vw - 32px));
padding: 6px;
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
@@ -631,6 +602,8 @@ ul { list-style: none; margin: 0; padding: 0; }
max-width: calc(100vw - 32px);
}
.gx-nav-indicator { position: absolute; inset: 6px auto 6px 6px; width: calc((100% - 12px) / 4); border-radius: var(--radius-full); background: linear-gradient(135deg, rgba(255, 255, 255, .2), transparent 60%), var(--glass-active-bg); border: 1px solid var(--glass-border); box-shadow: inset 0 1px 1px rgba(255, 255, 255, .3), 0 3px 12px rgba(0, 0, 0, .15); transform: translateX(calc(var(--active-tab, 0) * 100%)); transition: transform 420ms cubic-bezier(.22, 1, .36, 1), scale 180ms ease; pointer-events: none; }
.blur-nav:has(.nav-item:active) .gx-nav-indicator { scale: 1.035 1.06; }
.blur-nav .nav-item i { transition: transform var(--motion-base); }
.gx-app.gx-nav-hidden .gx-content { padding-bottom: var(--sp-4); }
@@ -639,11 +612,15 @@ ul { list-style: none; margin: 0; padding: 0; }
}
.blur-nav .nav-item {
position: relative;
z-index: 1;
min-width: 0;
min-height: var(--touch-target);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 16px;
padding: 8px 4px;
border-radius: var(--radius-full);
color: var(--on-surface-variant);
text-decoration: none;
@@ -671,7 +648,7 @@ ul { list-style: none; margin: 0; padding: 0; }
}
.blur-nav .nav-item.active {
background: var(--glass-active-bg);
background: transparent;
color: var(--primary);
font-weight: var(--fw-bold);
}
@@ -754,16 +731,8 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-plot-term-2 { stroke: #ad8cff; }
.gx-plot-term-3 { stroke: #58c9a3; }
.gx-tight-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.gx-tight-grid .gx-row { border-radius: var(--radius-md); }
.gx-driving__link:active, button.gx-crash-row:active { transform: scale(0.97); }
.gx-driving__link:hover, button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: translateY(-2px); }
button.gx-crash-row:active { transform: scale(0.97); }
button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: translateY(-2px); }
.gx-underlay {
background: rgba(0, 0, 0, 0.6);
@@ -885,7 +854,7 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-nav-item i { font-size: 1.3rem; width: 24px; }
@media (max-width: 767px) {
.gx-appbar__pill { padding-inline: 6px; gap: var(--sp-1); }
.gx-appbar__pill { padding-inline: 10px; gap: var(--sp-1); }
.gx-searchbox { flex: 0 0 var(--touch-target); justify-content: center; min-width: 0; }
.gx-searchbox.open { flex: 1; justify-content: stretch; }
.gx-searchbox.open .gx-searchwrap { flex: 1; }
@@ -895,7 +864,7 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-appbar:has(.gx-searchbox.open) .gx-back-btn,
.gx-appbar:has(.gx-searchbox.open) .gx-theme-toggle,
.gx-appbar:has(.gx-searchbox.open) .gx-appbar__menu { display: none; }
.blur-nav { display: flex; }
.blur-nav { display: grid; }
.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; }
@@ -912,7 +881,7 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-searchbox.open .gx-search-toggle { flex: none; order: 2; }
}
@media (max-width: 480px) { .gx-appbar__title { display: none; } }
@media (max-width: 359px) { .gx-appbar__title { display: none; } }
@media (min-width: 768px) {
.blur-nav { display: none; }
@@ -926,7 +895,7 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-search:disabled { cursor: not-allowed; opacity: .58; }
.gx-nav-item { width: 100%; border: 0; background: transparent; text-align: left; font: inherit; }
.gx-message { padding: var(--sp-5); max-width: 680px; }
.gx-message { padding: var(--sp-5); width: 100%; max-width: 680px; margin-inline: auto; text-align: center; }
.gx-message h2 { margin-top: 0; }
.gx-message p { color: var(--text-muted); line-height: 1.5; }
.gx-auth { max-width: 460px; margin: var(--sp-6) auto; padding: var(--sp-6); }
@@ -941,6 +910,7 @@ ul { list-style: none; margin: 0; padding: 0; }
/* Shared notice banner (ported from the original Galaxy GxNotice). */
.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-settings > .gx-alert:has(+ .gx-alert) { margin-bottom: 0; }
.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--danger { background: rgba(255, 82, 119, 0.12); border-left-color: var(--error); }
@@ -979,12 +949,10 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-menu-tile:disabled { cursor: default; opacity: .65; }
.gx-menu-tile:disabled:hover { background: var(--surface-container); transform: none; }
.gx-note--danger { color: var(--error); }
.gx-loading, .gx-empty { padding: var(--sp-4); color: var(--text-muted); }
.gx-empty { padding: var(--sp-4); color: var(--text-muted); }
.gx-chip { display: inline-block; padding: 4px 9px; border-radius: var(--radius-full); background: var(--primary-container); color: var(--on-primary-container); font-size: var(--fs-xs); }
.gx-field { min-height: 44px; padding: 9px 12px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--surface-container-high); color: var(--on-surface); font: inherit; }
.gx-btn--tonal { background: var(--primary-container); color: var(--on-primary-container); box-shadow: none; }
.gx-logs-back { margin-bottom: var(--sp-3); }
.gx-crash-controls { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); }
.gx-crash-controls .gx-field { flex: 1; min-width: 0; }
.gx-crash-list { display: grid; gap: var(--sp-2); }
@@ -1026,18 +994,17 @@ ul { list-style: none; margin: 0; padding: 0; }
@media(max-width:600px) { .gx-monitor__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .gx-monitor__metric { min-width: 0; padding: 12px; } .gx-monitor__metric strong { font-size: 22px; } }
/* Dashboard */
.gx-home { max-width: 1120px; margin: 0 auto; }
.gx-home { width: 100%; margin: 0 auto; }
.gx-home__hero { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); }
.gx-home__hero h1 { margin: 0 0 var(--sp-1); font-size: clamp(1.7rem, 3vw, 2.4rem); }
.gx-home__hero .gx-note { margin: 0; }
.gx-home__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-4); }
.gx-home__card { min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-5); }
.gx-home__card h2, .gx-home__pending h2 { margin: 0 0 var(--sp-2); font-size: var(--fs-lg); }
.gx-home__card h2 { margin: 0 0 var(--sp-2); font-size: var(--fs-lg); }
.gx-home__card h2 i { color: var(--primary); margin-right: var(--sp-2); }
.gx-home__card p { margin: 0; line-height: 1.5; overflow-wrap: anywhere; }
.gx-home__card small { color: var(--text-muted); overflow-wrap: anywhere; }
.gx-recordings { min-width: 0; }
.gx-recordings__actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.gx-recordings__segments, .gx-recordings__files { list-style: none; margin: 0; padding: 0; }
.gx-recordings__segments > li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); padding: var(--sp-4) 0; border-top: 1px solid var(--outline-variant); }
.gx-recordings__segment-info { flex: 1 1 17rem; min-width: 0; }
@@ -1045,7 +1012,9 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-recordings__files li { padding: .2rem .5rem; border-radius: var(--radius-sm); background: var(--surface-container-high); color: var(--text-muted); font-size: var(--fs-sm); }
.gx-recordings__actions .gx-btn { white-space: normal; }
.gx-recordings__player { display: grid; gap: var(--sp-4); padding: var(--sp-5); min-width: 0; }
.gx-recordings__player-head, .gx-recordings__player-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.gx-recordings__player-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.gx-recordings__player-head > div { flex: 1; min-width: 0; }
.gx-recordings__player-controls { display: grid; grid-template-columns: var(--touch-target) minmax(0, 1fr) var(--touch-target); align-items: center; gap: var(--sp-2); }
.gx-recordings__player-head h3, .gx-recordings__player-head p { margin: 0; overflow-wrap: anywhere; }
.gx-recordings__player video { display: block; width: 100%; max-height: 68vh; background: #000; }
.gx-recordings__player-controls span { color: var(--text-muted); }
@@ -1061,8 +1030,6 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-home__metrics span { color: var(--text-muted); font-size: var(--fs-sm); }
.gx-home__link { align-self: flex-start; margin-top: auto; padding: var(--sp-2) 0 0; border: 0; background: none; color: var(--primary); font: inherit; cursor: pointer; text-align: left; }
.gx-home__link:hover { text-decoration: underline; }
.gx-home__pending { margin-top: var(--sp-4); padding: var(--sp-5); }
.gx-home__pending p { margin: var(--sp-2) 0 0; color: var(--text-muted); }
.gx-home__last { padding: var(--sp-5); display: grid; gap: var(--sp-4); min-height: 180px; }
.gx-home__last h2, .gx-home__week h2, .gx-home__records h2, .gx-home__recent h2, .gx-home__models h2 { margin: 0; font-size: var(--fs-lg); }
.gx-home__last p { margin: 0; }
@@ -1124,19 +1091,13 @@ ul { list-style: none; margin: 0; padding: 0; }
@media (max-width: 850px) { .gx-home__grid { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .gx-home__pair, .gx-home__totals { grid-template-columns: 1fr; } .gx-home__drive { grid-template-columns: 1fr 1fr; } .gx-home__drive > div:last-child { grid-column: 1 / -1; } }
@media (max-width: 600px) { .gx-home__hero { align-items: flex-start; flex-direction: column; } .gx-home__four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.gx-driving { max-width: 1120px; margin: 0 auto; min-width: 0; }
.gx-driving { width: 100%; margin: 0 auto; min-width: 0; }
.gx-driving__intro { padding: var(--sp-5); }
.gx-driving__intro h2 { margin: 0 0 var(--sp-2); }
.gx-driving__intro p:last-child { margin-bottom: 0; color: var(--text-muted); }
.gx-driving__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-block: var(--sp-3); }
.gx-driving__actions { margin-block: var(--sp-3); }
.gx-driving__group { margin-top: var(--sp-5); }
.gx-driving__group h3 { margin: 0 0 var(--sp-3); }
.gx-driving__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); }
.gx-driving__link { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); min-width: 0; width: 100%; padding: var(--sp-4); text-align: left; color: var(--on-surface); font: inherit; cursor: pointer; }
.gx-driving__link span { min-width: 0; display: grid; gap: var(--sp-1); }
.gx-driving__link strong, .gx-driving__link small { overflow-wrap: anywhere; }
.gx-driving__link small { color: var(--text-muted); }
.gx-driving__link i { flex: 0 0 auto; color: var(--primary); }
.gx-driving__extras { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-5); }
.gx-card.gx-long-curves__header, .gx-card.gx-long-curves__body { padding: var(--sp-5); }
.gx-long-curves__header, .gx-long-curves__heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-4); }
@@ -1144,16 +1105,12 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-long-curves__tabs { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: var(--sp-3) 0; text-transform: capitalize; }
.gx-long-curves__tabs [aria-pressed="true"] { background: var(--primary); color: #fff; }
.gx-long-curves__body { display: grid; gap: var(--sp-4); }
.gx-long-curves__preset, .gx-long-curves__point-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.gx-long-curves__preset > div { display: grid; gap: var(--sp-1); text-transform: capitalize; }
.gx-long-curves__graph { width: 100%; max-width: 680px; height: auto; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: rgba(0, 0, 0, .15); }
.gx-long-curves__graph text { fill: var(--on-surface-variant); font-size: 11px; }
.gx-long-curves__axis { stroke: var(--on-surface-variant); stroke-width: 1; }
.gx-long-curves__line { fill: none; stroke: var(--primary); stroke-width: 2.5; }
.gx-long-curves__point { fill: var(--primary); }
.gx-long-curves__points { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2) var(--sp-4); }
.gx-long-curves__point-row { padding: var(--sp-2); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); }
.gx-long-curves__point-row strong { font-variant-numeric: tabular-nums; }
.gx-card.gx-vehicle__header, .gx-card.gx-vehicle__body { padding: var(--sp-5); }
.gx-vehicle__header h2 { margin: 0 0 var(--sp-2); }
.gx-vehicle__body { display: grid; gap: var(--sp-4); }
@@ -1169,8 +1126,6 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-vehicle__models small { color: var(--on-surface-variant); }
@media (max-width: 700px) { .gx-long-curves__points { grid-template-columns: 1fr; } .gx-long-curves__header, .gx-long-curves__heading { flex-direction: column; } }
@media (max-width: 520px) { .gx-vehicle__summary { grid-template-columns: 1fr; } .gx-card.gx-vehicle__header, .gx-card.gx-vehicle__body { padding: 16px; } }
@media (max-width: 480px) { .gx-long-curves__point-row { display: grid; grid-template-columns: 1fr auto; } .gx-long-curves__point-row .gx-settings__controls { grid-column: 1 / -1; } }
@media (max-width: 700px) { .gx-driving__grid { grid-template-columns: 1fr; } }
/* Navigation shares the same route and preferences as the onroad display. */
.gx-navigation__tabs,.gx-navigation__actions,.gx-navigation__search{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
@@ -1260,7 +1215,7 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-recordings__player { width: min(calc(100% - 2rem), 64rem); max-height: 92dvh; overflow: auto; border: 1px solid var(--outline-variant); color: var(--on-surface); margin: auto; }
.gx-recordings__player:not([open]) { display: none; }
.gx-recordings__player::backdrop { background: rgba(0, 0, 0, .72); }
.gx-recordings__player-controls .gx-field { flex: 1; width: auto; min-width: 8rem; }
.gx-recordings__player-controls .gx-field { width: 100%; min-width: 0; }
@media (max-width: 600px) {
.gx-recordings__row { align-items: stretch; flex-direction: column; }
.gx-recordings__row > .gx-recordings__actions { justify-content: flex-end; }
@@ -1279,10 +1234,13 @@ input[type="checkbox"]:not(.gx-switch input):checked::after { content: ""; displ
input[type="checkbox"]:not(.gx-switch input):focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
/* Shared compact control bars retain full touch targets without wrapping. */
.gx-btn { white-space: nowrap; }
.gx-actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: nowrap; overflow-x: auto; min-width: 0; max-width: 100%; padding-block: var(--sp-1); scrollbar-width: thin; }
.gx-actions > * { flex: 0 0 auto; }
/* Shared action groups wrap while keeping their controls within the page. */
.gx-btn { white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
.gx-actions, .gx-settings__controls, .gx-settings__actions, .gx-driving__actions, .gx-recordings__actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; min-width: 0; max-width: 100%; }
.gx-actions > * { max-width: 100%; }
.gx-driving__actions > .gx-field { flex: 1 1 14rem; min-width: 0; }
.gx-action-icon { flex: none; background: var(--primary-container); color: var(--primary); }
.gx-action-icon:disabled { opacity: .4; cursor: default; }
.gx-actions [aria-pressed="true"] { background: var(--primary); color: var(--on-primary); }
.gx-field-group, label:has(> .gx-select), label:has(> select) { display: grid; gap: var(--sp-2); min-width: 0; }
.gx-field-group > .gx-select { width: 100%; }
@@ -1305,12 +1263,24 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-boot--failed .gx-boot__orbit { display: none; }
.gx-boot [hidden] { display: none; }
@media (prefers-reduced-motion: reduce) { .gx-boot__orbit { animation: none; } }
@media (max-width: 600px) {
.gx-settings__controls, .gx-settings__actions, .gx-driving__actions, .gx-recordings__actions, .gx-recordings__player-controls { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; max-width: 100%; min-width: 0; padding-block: var(--sp-1); scrollbar-width: thin; }
.gx-settings__controls > *, .gx-settings__actions > *, .gx-driving__actions > *, .gx-recordings__actions > *, .gx-recordings__player-controls > * { flex-shrink: 0; }
.gx-settings__controls .gx-btn, .gx-settings__actions .gx-btn, .gx-driving__actions .gx-btn { flex-basis: auto; width: auto; }
}
@keyframes gx-spin { to { transform: rotate(360deg); } }
.gx-device-state__icon { color: var(--secondary); flex: none; }
.gx-device-state__icon.is-stale { color: var(--text-muted); }
.gx-route-stage { display: grid; min-width: 0; overflow-x: clip; }
.gx-route { grid-area: 1 / 1; align-self: start; width: 100%; min-width: 0; }
.gx-route > *, .gx-settings__body > *, .gx-android-auto-setup { min-width: 0; max-width: 100%; }
.gx-route .gx-card { min-width: 0; overflow-wrap: anywhere; }
.gx-route-enter-active, .gx-route-leave-active { transition: opacity 320ms ease, transform 320ms cubic-bezier(.22, 1, .36, 1); }
.gx-route-leave-active { pointer-events: none; }
.gx-route-enter-from { opacity: 0; transform: translateX(calc(var(--route-direction, 1) * 100%)); }
.gx-route-leave-to { opacity: 0; transform: translateX(calc(var(--route-direction, 1) * -100%)); }
.gx-nav-footer { margin-top: auto; }
@media (prefers-reduced-motion: reduce) { .gx-route-enter-active, .gx-route-leave-active, .gx-nav-indicator, .blur-nav .nav-item i { transition: none; } }
.gx-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2); margin: 0; padding: var(--sp-3) var(--sp-4); }
.gx-summary > div { display: flex; flex-direction: column; min-width: 0; text-align: center; }
.gx-summary dt { color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.35; overflow-wrap: anywhere; }
.gx-summary dd { order: -1; margin: 0 0 var(--sp-1); font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--primary); overflow-wrap: anywhere; }
.gx-model-selection { margin: 0; padding: 0 var(--sp-4) var(--sp-3); font-size: var(--fs-sm); overflow-wrap: anywhere; }
@@ -1,9 +1,7 @@
/* Settings controls adapted from the current StarPilot Vue Galaxy. */
.gx-settings { gap: var(--sp-4); max-width: none; }
.gx-settings__header h2 { margin: 0; }
.gx-settings__header p { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.45; max-width: 760px; }
.gx-settings__header .gx-note { margin: var(--sp-2) 0 0; }
.gx-settings__actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.gx-settings__actions .gx-btn { min-height: 40px; }
.gx-settings__search { padding: var(--sp-3) var(--sp-4) 0; }
.gx-settings__search .gx-field { width: 100%; }
@@ -14,11 +12,8 @@
@media (prefers-reduced-motion: reduce) { .gx-settings__body, .gx-settings-tabs { animation: none; } }
.gx-settings__section { padding: 0; }
.gx-settings__section .gx-section__header { cursor: default; }
.gx-settings__developer { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
.gx-settings__developer-body { display: grid; gap: var(--sp-3); }
.gx-settings__developer-provider { display: grid; gap: var(--sp-2); font-weight: var(--fw-medium); }
.gx-settings__developer-body .gx-message { margin: 0; }
.gx-settings__developer-body .gx-card { margin: 0; }
.gx-settings__save-status { position: fixed; bottom: calc(var(--bottomnav-clearance) + 8px); left: 50%; transform: translateX(-50%); z-index: var(--z-snackbar); padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-full); background: var(--surface-container-high); box-shadow: var(--elev-2); }
.gx-slider-header { display: flex; align-items: baseline; gap: var(--sp-2); min-height: 24px; }
.gx-slider-hint { color: var(--text-muted); font-size: var(--fs-xs); }
@@ -34,6 +29,7 @@ input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; }
align-items: center;
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
min-height: 56px;
padding: var(--sp-3) var(--sp-4);
@@ -46,7 +42,9 @@ input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; }
}
.gx-section__title {
flex: 1;
flex: 1 1 8rem;
min-width: 0;
overflow-wrap: anywhere;
font-weight: var(--fw-bold);
letter-spacing: -0.01em;
}
@@ -65,13 +63,6 @@ input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; }
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);
@@ -113,11 +104,7 @@ input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; }
white-space: nowrap;
}
.gx-row__actions { align-self: flex-end; display: flex; flex-wrap: wrap; gap: 6px; }
.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,
@@ -257,27 +244,6 @@ input[type="range"].gx-slider::-moz-range-thumb {
input[type="range"].gx-slider:active::-moz-range-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);
}
.gx-chip {
display: inline-flex;
align-items: center;
@@ -416,8 +382,6 @@ button.gx-chip:hover {
.gx-layout__device-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
.gx-layout__device-frame span { padding: 16px; text-align: center; }
.gx-layout__scenes { display: flex; flex-wrap: wrap; gap: 8px; margin-inline: 20px; }
.gx-layout__preview-status { min-height: 24px; margin-inline: 20px; display: flex; align-items: center; gap: 10px; color: var(--text-muted); }
.gx-layout__preview-status:empty { min-height: 0; }
.gx-layout__widget { cursor: grab; outline: none; }
.gx-layout__widget:active { cursor: grabbing; }
.gx-layout-widget-art { pointer-events: none; font-family: var(--font-body); }
@@ -481,3 +445,6 @@ button.gx-chip:hover {
.gx-settings__header h2 { font-size: clamp(1.2rem, 4vw, 1.5rem); }
.gx-settings__header > div:first-child { flex: 1; min-width: 0; }
.gx-settings__header > .gx-actions, .gx-settings__header > .gx-icon-btn { flex: none; }
.gx-section__header > i { flex: none; }
.gx-section__header > .gx-chip, .gx-section__count { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
@@ -23,7 +23,7 @@
"availability": "local-only"
},
{
"name": "Galaxy Tunnel",
"name": "Install Galaxy / Tunnel",
"path": "/galaxy",
"icon": "bi-globe2",
"description": "Remote access and device pairing",
+1 -1
View File
@@ -18,7 +18,7 @@
<body>
<div id="galaxy-bg" aria-hidden="true"></div>
<section id="galaxy-boot" class="gx-boot" role="status">
<img class="gx-boot__logo" src="./assets/galaxy-icon.svg" width="160" height="160" alt="" /><h1>Galaxy</h1><div class="gx-boot__orbit" aria-hidden="true"></div><p>Opening Galaxy…</p><button type="button" class="gx-btn" hidden>Try again</button>
<img class="gx-boot__logo" src="./assets/galaxy-icon.svg" width="160" height="160" alt="" /><h1>Galaxy</h1><div class="gx-boot__orbit" aria-hidden="true"></div><p>Plotting the stars...</p><button type="button" class="gx-btn" hidden>Try again</button>
<noscript>Enable JavaScript to open Galaxy.</noscript>
</section>
<div id="galaxy-app" v-cloak></div>
@@ -1,3 +1,4 @@
import { GxIconButton } from "./icon-button.js"
import { requestJson } from "./startup.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
@@ -281,7 +282,7 @@ export class AndroidAutoFeed {
}
export const AndroidAutoPage = {
components: { GxNotice },
components: { GxIconButton, GxNotice },
props: { mode: { type: String, required: true }, localAccess: { type: Boolean, required: true },
unauthorized: { type: Function, required: true } },
data: () => ({ setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
@@ -363,7 +364,7 @@ export const AndroidAutoPage = {
<div class="gx-android-auto-setup">
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<div v-if="!setup && !error" class="gx-card gx-message" role="status">{{ error ? "Setup could not be loaded." : "Checking Android Auto setup…" }} </div>
<template v-else>
<template v-if="setup">
<div class="gx-card gx-driving__intro"><h3>1. Prepare</h3>
<p v-if="!setup.installReady" role="status">Android Auto display and encoder are not installed on this build. Projection setup is unavailable.</p>
<p v-else-if="setup.enabled && !setup.serviceReady" role="status">Starting the Android Auto service. Pairing is unavailable until it is ready.</p>
@@ -376,7 +377,7 @@ export const AndroidAutoPage = {
<ol>
<li>Open <a href="https://www.apkmirror.com/apk/google-inc/android-auto/" target="_blank" rel="noopener noreferrer">Android Auto by Google LLC on APKMirror</a>, a third-party download site.</li>
<li>Download the Android Auto app package to this phone or computer. Choose the app's APK or bundle download; you do not need the APKMirror Installer app.</li>
<li>Return here, choose the downloaded APK, XAPK, or APKM file, and press Upload Package. Leave bundles zipped.</li>
<li>Return here, choose the downloaded APK, XAPK, or APKM file, and use the upload button (or Enable &amp; upload). Leave bundles zipped.</li>
</ol>
<p>You do not install an Android app on the comma. Galaxy imports the information needed to connect to your car and checks that it is usable.</p>
</details>
@@ -384,8 +385,9 @@ export const AndroidAutoPage = {
<p v-if="setup.import?.state === 'running'" role="status">Checking your package…</p>
<GxNotice tone="danger" v-if="setup.import?.state === 'failed'">Package verification failed: {{ setup.import.error || 'Try another package.' }}</GxNotice>
<p v-if="setup.import?.state === 'done' && setup.identity.installed" role="status">Package verified and ready.</p>
<div class="gx-driving__actions"><input class="gx-field" type="file" accept=".apk,.xapk,.apkm" style="max-width:100%;min-width:0" aria-label="Android Auto APK, XAPK, or APKM" @change="choosePackage" />
<button class="gx-btn" :disabled="!!uploadReason" @click="upload">{{ setup.enabled ? 'Upload Package' : 'Enable and Upload Package' }}</button></div>
<div class="gx-driving__actions"><input class="gx-field" type="file" accept=".apk,.xapk,.apkm" aria-label="Android Auto APK, XAPK, or APKM" @change="choosePackage" />
<GxIconButton v-if="setup.enabled" label="Upload package" icon="bi-upload" :disabled="!!uploadReason" @click="upload" />
<button v-else class="gx-btn" :disabled="!!uploadReason" @click="upload">Enable &amp; upload</button></div>
<p v-if="packageFile" role="status">Selected: {{ packageFile.name }} ({{ Math.ceil(packageFile.size / 1048576) }} MB).</p>
<div v-if="uploadProgress" role="status" aria-live="polite">
<progress :value="uploadProgress.loaded" :max="uploadProgress.total"></progress>
+20 -20
View File
@@ -1,3 +1,4 @@
import { BottomNav, NAV, primaryTab } from "./bottom-nav.js"
import { GalaxyLoading } from "./loading-screen.js"
import { GxNotice } from "./notice.js"
import { createApp, reactive, defineAsyncComponent } from "../vendor/vue/vue.esm-browser.js"
@@ -31,7 +32,6 @@ const OnroadLayoutPage = page(() => import("./onroad-layout.js").then((module) =
const GalaxyPage = page(() => import("./galaxy.js").then((module) => module.GalaxyPage))
import { DevicePicker } from "./device-picker.js"
import { DeviceState } from "./device-state.js"
import { InstallApp } from "./install-app.js"
import { spawnAmbientStars } from "./ambient-stars.js"
function page(loader) {
@@ -65,19 +65,15 @@ const authState = reactive({ status: "checking", error: "", localAccess: false,
const authForm = reactive({ password: "" })
const auth = new LocalAuth({ publish: (update) => Object.assign(authState, update) })
const NAV = [
{ name: "Home", path: "/", icon: "bi-house-fill" },
{ name: "Toggles", path: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", path: "/tools", icon: "bi-tools" },
{ name: "Recordings", path: "/recordings", icon: "bi-camera-reels" },
]
createApp({
components: { GalaxyLoading, Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, InstallApp, MenuTile },
data: () => ({ state, authState, authForm, route, NAV }),
components: { BottomNav, GalaxyLoading, Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile },
data: () => ({ state, authState, authForm, route, NAV, routeDirection: 1 }),
computed: {
visibleTools() { return state.tools.filter(tool => tool.visibility !== "authenticated" ||
(state.monitorMode === "local" && authState.status === "authenticated")) },
(state.monitorMode === "local" && authState.status === "authenticated")).sort((a, b) =>
Number(a.path === "/galaxy") - Number(b.path === "/galaxy")) },
currentTool() { return state.tools.find((tool) => route.path === tool.path || route.path.startsWith(tool.path + "/")) },
drivingSettingsPage() { return drivingPage(route.path) },
deviceSettingsPage() { return devicePage(route.path) },
@@ -93,13 +89,16 @@ createApp({
},
},
errorCaptured() { state.pageError = "This page could not finish loading."; return false },
watch: { 'route.path'() { state.pageError = "" } },
watch: { 'route.path'(path, previous) {
state.pageError = ""
this.routeDirection = primaryTab(path) < primaryTab(previous) ? -1 : 1
} },
methods: {
setPageActive(element, active) { element.inert = !active; element.setAttribute("aria-hidden", String(!active)) },
reloadPage() { location.reload() },
go(path) { navigate(path, () => { state.drawerOpen = false; state.searchPage = "" }) },
openSearchHit(hit) { navigate("/settings", () => { state.drawerOpen = false; state.searchPage = hit.page }) },
returnFromSearch() { state.searchPage = "" },
returnToCameras() { this.go("/cameras") },
returnToDriving() { this.go("/driving") },
returnToDevice() { this.go("/device-preferences") },
routeBack() { state.drawerOpen = false; navigateBack() },
@@ -157,13 +156,15 @@ createApp({
</div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Recordings</div><button type="button" class="gx-nav-item" :class="{active:isActive('/recordings')}" @click="go('/recordings')"><i class="bi bi-camera-reels"></i><span>Recordings</span></button></div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Tools</div>
<button v-for="tool in visibleTools" :key="tool.path" type="button" class="gx-nav-item" :class="{active:isActive(tool.path)}" @click="go(tool.path)"><i class="bi" :class="tool.icon"></i><span>{{ tool.name }}</span></button>
<button v-for="tool in visibleTools.filter(tool => tool.path !== '/galaxy')" :key="tool.path" type="button" class="gx-nav-item" :class="{active:isActive(tool.path)}" @click="go(tool.path)"><i class="bi" :class="tool.icon"></i><span>{{ tool.name }}</span></button>
</div>
<InstallApp />
<DevicePicker />
<div class="gx-nav-footer"><DevicePicker />
<button type="button" class="gx-nav-item" :class="{active:isActive('/galaxy')}" @click="go('/galaxy')"><i class="bi bi-globe"></i><span>Install Galaxy / Tunnel</span></button>
</div>
</aside>
<main class="gx-content">
<main class="gx-content" :style="{'--route-direction': routeDirection}"><div class="gx-route-stage">
<Transition name="gx-route" @before-enter="setPageActive($event, true)" @before-leave="setPageActive($event, false)"><div class="gx-route" :key="route.path">
<template v-if="state.loading && !state.error"><GalaxyLoading v-if="state.startupPending" message="Connecting to your device…" /></template>
<GxNotice v-else-if="state.error" tone="danger">{{ state.error }}</GxNotice>
<section v-else-if="state.monitorMode === 'local' && authState.status !== 'authenticated'" class="gx-card gx-auth" aria-label="Galaxy sign in">
@@ -180,8 +181,7 @@ createApp({
<Home v-else-if="route.path === '/'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<LocalRecordingsPage v-else-if="route.path === '/recordings'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<CamerasPage v-else-if="route.path === '/cameras'" :mode="state.monitorMode" :go="go" :unauthorized="sessionExpired" />
<SentryEventsPage v-else-if="route.path === '/cameras/events'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<SettingsPage ref="activeSettings" v-else-if="route.path === '/cameras/sentry-settings'" :mode="state.monitorMode" :unauthorized="sessionExpired" initial-page="sentry" title="Sentry motion settings" :return-to="returnToCameras" />
<SentryEventsPage v-else-if="['/cameras/events', '/cameras/sentry-settings'].includes(route.path)" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<PipPage v-else-if="route.path === '/cameras/pip'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<VasmPage v-else-if="route.path === '/cameras/vasm'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<Tools v-else-if="route.path === '/tools'" :tools="visibleTools" :mode="state.monitorMode" />
@@ -215,8 +215,8 @@ createApp({
<PlotsPage v-else-if="route.path === '/tuning/plots'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<FlmPage v-else-if="route.path === '/tuning/flm'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<div v-else class="gx-card gx-message" role="status"><h2>{{ pageName }}</h2><p>This capability is unavailable in this build. No operation was attempted.</p></div>
</main>
<nav v-if="route.path !== '/navigation'" class="blur-nav" aria-label="Primary navigation"><button v-for="item in NAV" :key="item.path" type="button" class="nav-item" :class="{active:isActive(item.path)}" @click="go(item.path)"><i class="bi" :class="item.icon"></i><span>{{ item.name }}</span></button></nav>
</div></Transition></div></main>
<BottomNav v-if="route.path !== '/navigation'" :path="route.path" @navigate="go" />
</div>
`,
}).component("GxNotice", GxNotice).mount("#galaxy-app")
@@ -0,0 +1,27 @@
export const NAV = Object.freeze([
{ name: "Home", path: "/", icon: "bi-house-fill" },
{ name: "Toggles", path: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", path: "/tools", icon: "bi-tools" },
{ name: "Recordings", path: "/recordings", icon: "bi-camera-reels" },
])
export function primaryTab(path) {
if (path === "/") return 0
if (path === "/settings" || path.startsWith("/settings/")) return 1
if (path === "/recordings" || path.startsWith("/recordings/")) return 3
return 2
}
export const BottomNav = {
props: { path: { type: String, required: true } },
emits: ["navigate"],
computed: { activeTab() { return primaryTab(this.path) } },
data: () => ({ tabs: NAV }),
template: `<nav class="blur-nav" aria-label="Primary navigation" :style="{'--active-tab': activeTab}">
<span class="gx-nav-indicator" aria-hidden="true"></span>
<button v-for="(item, index) in tabs" :key="item.path" type="button" class="nav-item"
:class="{active: index === activeTab}" :aria-current="index === activeTab ? 'page' : null" @click="$emit('navigate', item.path)">
<i class="bi" :class="item.icon" aria-hidden="true"></i><span>{{ item.name }}</span>
</button>
</nav>`,
}
+8 -18
View File
@@ -1,3 +1,4 @@
import { MenuTile } from "./menu-tile.js"
import { GxNotice } from "./notice.js"
import { GalaxySelect } from "./galaxy-select.js"
@@ -62,7 +63,7 @@ export class CameraSnapshotFeed {
export const CamerasPage = {
name: "CamerasPage",
components: { GxNotice, GalaxySelect },
components: { GxNotice, GalaxySelect, MenuTile },
props: { mode: { type: String, required: true }, go: { type: Function, required: true },
unauthorized: { type: Function, required: true } },
data: () => ({ camera: "cabin", image: "", capturing: false, error: "" }),
@@ -77,23 +78,12 @@ export const CamerasPage = {
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.snapshots.stop() },
watch: { mode() { this.snapshots.stop() } },
template: `
<div class="gx-view gx-home" aria-label="Cameras and Monitoring">
<div class="gx-home__hero"><div><h1>Cameras &amp; Monitoring</h1>
<p class="gx-note">Camera preferences and availability</p></div></div>
<div class="gx-home__grid">
<section class="gx-card gx-home__card"><h2><i class="bi bi-camera-video"></i> Blind Spot Camera and Preview</h2>
<p>Adjust the camera crop with a live cabin preview.</p>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/pip')">Open saved preferences <i class="bi bi-arrow-right"></i></button>
<small v-else>Saved preferences are unavailable in preview.</small></section>
<section class="gx-card gx-home__card"><h2><i class="bi bi-shield"></i> Sentry</h2>
<p>View captured motion events and configure Sentry notifications.</p>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/events')">View motion events <i class="bi bi-arrow-right"></i></button>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/sentry-settings')">Saved motion settings <i class="bi bi-arrow-right"></i></button>
<small v-else>Motion events are unavailable in preview.</small></section>
<section class="gx-card gx-home__card"><h2><i class="bi bi-eye"></i> V-ASM</h2>
<p>Preview the cabin camera, draw window regions, and adjust visual spot-monitoring choices.</p>
<button v-if="mode === 'local'" type="button" class="gx-home__link" @click="go('/cameras/vasm')">Open saved settings <i class="bi bi-arrow-right"></i></button>
<small v-else>Saved settings are unavailable in preview.</small></section>
<div class="gx-view" aria-label="Cameras and Monitoring">
<h2>Cameras &amp; Monitoring</h2>
<div class="gx-grid">
<MenuTile icon="bi-camera-video" title="Blind Spot Camera and Preview" description="Adjust the camera crop with a live cabin preview." :disabled="mode !== 'local'" @select="go('/cameras/pip')" />
<MenuTile icon="bi-shield" title="Sentry" description="View motion events, configure saved motion settings, and manage notifications." :disabled="mode !== 'local'" @select="go('/cameras/events')" />
<MenuTile icon="bi-eye" title="V-ASM" description="Preview the cabin camera, draw window regions, and adjust visual spot-monitoring choices." :disabled="mode !== 'local'" @select="go('/cameras/vasm')" />
</div>
<section class="gx-card gx-home__card"><h2>Camera Snapshot</h2>
<p>Turn off the vehicle, choose a camera, then take a snapshot.</p>
@@ -56,13 +56,13 @@ export const CloudProviderPage = {
template: `<div class="gx-settings__developer-body">
<section v-if="mode !== 'local'" class="gx-card gx-message">Open this Developer page on your device to choose a cloud provider.</section>
<template v-else>
<p>Cloud accounts and uploads stay separate. Your older comma recordings keep their comma links.</p>
<section class="gx-card gx-settings__section"><div class="gx-section__header"><i class="bi bi-code-slash" aria-hidden="true"></i><span class="gx-section__title">Developer</span></div><div class="gx-row"><div class="gx-row__info">
<label class="gx-settings__developer-provider">Cloud provider
<GalaxySelect class="gx-field gx-field--full" aria-label="Cloud provider" :value="status?.selected || ''"
:disabled="busy || !status?.canSelect" @change="select($event.target.value)">
<option v-for="provider in status?.providers || []" :key="provider.id" :value="provider.id">{{ provider.label }}</option>
</GalaxySelect>
</label>
</label></div></div></section>
<GxNotice v-if="selected" :tone="active && selected.id === active.id ? 'info' : 'warn'">
{{ selected.label }}{{ active && selected.id === active.id ? ' is currently active.' : ' applies after the next reboot.' }}
</GxNotice>
@@ -1,5 +1,4 @@
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const HEX = /^[0-9a-f]{64}$/
@@ -1,3 +1,4 @@
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
import { connectionError } from "./polling.js"
@@ -92,23 +93,23 @@ const methods = {
const link = document.createElement("a"); link.href = url; link.download = "starpilot-console.txt"; link.click(); URL.revokeObjectURL(url) },
}
export const TroubleshootPage = {
components: { GxNotice, LocalAccess }, props, ...lifecycle,
components: { GxIconButton, GxNotice, LocalAccess }, props, ...lifecycle,
setup: props => setup(props, false), methods: { ...methods, report: troubleshootReport },
template: `<section class="gx-settings"><div class="gx-settings__header"><h2>Troubleshoot</h2></div>
<p>Read-only device, vehicle and selected settings. Change settings in Toggles.</p>
<div class="gx-settings__controls"><button class="gx-btn gx-btn--tonal" :disabled="!state.data" @click="copyText(report(state.data))">Copy Report</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/settings')">Toggles</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/monitor')">System Monitor</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/crashes')">Crash Reports</button></div>
<p v-if="state.loading" role="status">Loading report…</p><GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice><p v-if="state.notice" role="status">{{ state.notice }}</p>
<div class="gx-settings__controls"><GxIconButton label="Copy report" icon="bi-clipboard" :disabled="!state.data" @click="copyText(report(state.data))" /><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/settings')">Toggles</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/monitor')">System Monitor</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/crashes')">Crash Reports</button></div>
<p v-if="state.loading" role="status" class="gx-card gx-message">Loading report…</p><GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice><p v-if="state.notice" role="status">{{ state.notice }}</p>
<template v-if="state.data"><section class="gx-card" style="padding:var(--sp-4)"><h3>Device Snapshot</h3><p>State: {{ state.data.device.state || 'Unavailable' }}</p><p>Vehicle: {{ state.data.vehicle.available ? state.data.vehicle.fingerprint : 'Unavailable' }}</p><p>Brand: {{ state.data.vehicle.brand || 'Unavailable' }} · Longitudinal: {{ state.data.vehicle.longitudinal ? 'Available' : 'Unavailable' }} · Steering: {{ state.data.vehicle.steering || 'Unavailable' }}</p><dl><template v-for="row in state.data.snapshot" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section>
<section v-for="section in state.data.sections" :key="section.title" class="gx-card" style="padding:var(--sp-4)"><h3>{{ section.title }}</h3><dl><template v-for="row in section.rows" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section><p class="gx-note">{{ state.data.note }}</p></template>
<LocalAccess v-if="state.error" :mode="mode" :on-unauthorized="unauthorized" /></section>`,
}
export const TmuxPage = {
components: { GxNotice, LocalAccess }, props, ...lifecycle,
components: { GxIconButton, GxNotice, LocalAccess }, props, ...lifecycle,
setup: props => setup(props, true), methods,
template: `<section class="gx-settings"><div class="gx-settings__header"><h2>tmux Live View</h2></div>
<p>Read-only launcher console tail. No commands or keyboard input are sent to the comma.</p>
<div class="gx-settings__controls"><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local'" @click="feed.setLive(!state.live)">{{ state.live ? 'Pause' : 'Resume' }}</button><button class="gx-btn gx-btn--tonal" :disabled="!state.data?.text" @click="copyText(state.data.text)">Copy</button><button class="gx-btn gx-btn--tonal" :disabled="!state.data?.text" @click="saveText(state.data.text)">Download</button></div>
<p v-if="state.loading" role="status">Loading console…</p><GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice><p v-if="state.notice" role="status">{{ state.notice }}</p>
<div class="gx-settings__controls"><GxIconButton :label="state.live ? 'Pause console updates' : 'Resume console updates'" :icon="state.live ? 'bi-pause-fill' : 'bi-play-fill'" :disabled="mode !== 'local'" @click="feed.setLive(!state.live)" /><GxIconButton label="Copy console text" icon="bi-clipboard" :disabled="!state.data?.text" @click="copyText(state.data.text)" /><GxIconButton label="Download console text" icon="bi-download" :disabled="!state.data?.text" @click="saveText(state.data.text)" /></div>
<p v-if="state.loading" role="status" class="gx-card gx-message">Loading console…</p><GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice><p v-if="state.notice" role="status">{{ state.notice }}</p>
<section v-if="state.data" class="gx-card gx-crash-preview" style="padding:var(--sp-4)"><p v-if="!state.data.available">{{ state.data.reason }}</p><p v-else>Pane: {{ state.data.pane }}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">{{ state.data.text }}</pre></section>
<p class="gx-note gx-tool-footer" role="status">{{ state.live ? 'Live · every 2 seconds while visible' : 'Paused' }}<span v-if="state.data?.truncated"> · showing the latest console lines</span></p>
<LocalAccess v-if="state.error || state.data?.available === false" :mode="mode" :on-unauthorized="unauthorized" /></section>`,
@@ -1,4 +1,3 @@
import { connectionError } from "./polling.js"
const ROUTE = /^[a-zA-Z0-9_+|.-]{1,128}$/
const DATE = /^\d{4}-\d{2}-\d{2}$/
const number = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0
+4 -5
View File
@@ -1,3 +1,4 @@
import { MenuTile } from "./menu-tile.js"
export const DRIVING_PAGES = Object.freeze({
aol: "aol",
conditional: "conditional",
@@ -32,6 +33,7 @@ export function drivingPage(path) {
}
export const DrivingPage = {
components: { MenuTile },
props: { mode: { type: String, required: true }, go: { type: Function, required: true } },
data: () => ({ groups: DRIVING_GROUPS }),
template: `
@@ -42,11 +44,8 @@ export const DrivingPage = {
<template v-else>
<section v-for="group in groups" :key="group.title" class="gx-driving__group">
<h3>{{ group.title }}</h3>
<div class="gx-driving__grid">
<button v-for="item in group.items" :key="item.page" type="button" class="gx-card gx-driving__link"
@click="go('/driving/' + item.page)">
<span><strong>{{ item.title }}</strong><small>{{ item.detail }}</small></span><i class="bi bi-chevron-right" aria-hidden="true"></i>
</button>
<div class="gx-grid">
<MenuTile v-for="item in group.items" :key="item.page" icon="bi-sliders" :title="item.title" :description="item.detail" @select="go('/driving/' + item.page)" />
</div>
</section>
<div class="gx-driving__extras"><button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/longitudinal-curves')">Edit longitudinal curves</button>
+1 -1
View File
@@ -240,7 +240,7 @@ export const FlmPage = {
<p v-if="mode !== 'local'" class="gx-card gx-message">Offline analysis requires authenticated local Galaxy access.</p>
<template v-else>
<p v-if="inventoryStatus === 'loading'" role="status">Reading local recordings…</p><GxNotice tone="danger" v-if="inventoryStatus === 'unavailable'">{{ inventoryError }}</GxNotice>
<p v-if="inventoryStatus === 'loading'" role="status" class="gx-card gx-message">Reading local recordings…</p><GxNotice tone="danger" v-if="inventoryStatus === 'unavailable'">{{ inventoryError }}</GxNotice>
<p v-if="inventory?.scanIncomplete" role="status">This recording scan was incomplete. More local segments may exist.</p>
<section class="gx-card gx-flm__panel"><h3>Choose Full Logs</h3><p class="gx-note">Select 1–5 closed local segments. Quick logs alone cannot provide this report.</p>
<div class="gx-flm__actions" style="position:sticky;top:0;z-index:1;background:var(--gx-surface, #181526);padding:0.75rem 0;display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
+5 -4
View File
@@ -1,10 +1,11 @@
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
import { InstallApp } from "./install-app.js"
import { LocalAccess } from "./local-access.js"
export const GalaxyPage = {
name: "GalaxyPage",
components: { GxNotice, LocalAccess },
components: { GxNotice, LocalAccess, InstallApp },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
legacyPairingAvailable: false, password: "", busy: false, error: "" }),
@@ -56,8 +57,8 @@ export const GalaxyPage = {
},
},
template: `
<div class="gx-settings gx-tunnel"><h2>Galaxy Tunnel</h2>
<GxNotice tone="info" title="Your comma, wherever you are">Pair your device for secure remote Galaxy access.</GxNotice>
<div class="gx-settings gx-tunnel"><h2>Install Galaxy / Tunnel</h2>
<GxNotice tone="info" title="Your StarPilot, wherever you are">Pair your device for secure remote Galaxy access.</GxNotice>
<section v-if="mode !== 'local'" class="gx-card gx-message">Pairing is available on your comma.</section>
<section v-else-if="loading" class="gx-card gx-message">Checking pairing status…</section>
<section v-else class="gx-card" style="padding:var(--sp-4)">
@@ -80,7 +81,7 @@ export const GalaxyPage = {
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
</section>
<section class="gx-card gx-info-card">
<h3><i class="bi bi-phone" aria-hidden="true"></i> Install Galaxy</h3>
<h3><i class="bi bi-phone" aria-hidden="true"></i> Install Galaxy</h3><InstallApp />
<p>Open your paired link, then choose <strong>Install app</strong> or <strong>Add to Home Screen</strong>.</p>
<ul class="gx-install-benefits"><li><i class="bi bi-window" aria-hidden="true"></i> Your own full-screen window</li><li><i class="bi bi-lightning-charge" aria-hidden="true"></i> Launch from your home screen</li><li><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Automatic updates, no app store</li></ul>
<p class="gx-note">Galaxy is a Progressive Web App and needs an active connection.</p>
@@ -0,0 +1,9 @@
// Familiar actions share a full touch target, accessible name and tooltip.
export const GxIconButton = {
name: "GxIconButton",
props: {
label: { type: String, required: true },
icon: { type: String, required: true },
},
template: `<button type="button" class="gx-icon-btn gx-action-icon" :aria-label="label" :title="label"><i class="bi" :class="icon" aria-hidden="true"></i></button>`,
}
@@ -1,4 +1,4 @@
export const GalaxyLoading = {
props: { message: { type: String, default: 'Opening Galaxy…' } },
props: { message: { type: String, default: 'Just a moment!' } },
template: `<section class="gx-boot" role="status"><img class="gx-boot__logo" src="./assets/galaxy-icon.svg" width="160" height="160" alt="" /><h1>Galaxy</h1><div class="gx-boot__orbit" aria-hidden="true"></div><p>{{ message }}</p></section>`,
}
+1 -1
View File
@@ -58,7 +58,7 @@ export const Logs = {
<div class="gx-crash-controls"><input class="gx-field" type="search" v-model="search" placeholder="Search report names" aria-label="Search crash reports"></div>
<p v-if="crashes.scanIncomplete" class="gx-note">Directory scan is incomplete; newer reports may be omitted.</p>
<p v-else-if="crashes.listLimited" class="gx-note">Showing the 200 newest reports; older reports are omitted.</p>
<p v-if="crashes.status === 'loading'">Loading crash reports…</p>
<p v-if="crashes.status === 'loading'" class="gx-card gx-message" role="status">Loading crash reports…</p>
<GxNotice tone="danger" v-else-if="crashes.status === 'unavailable' && !crashes.error">Crash reports are unavailable.</GxNotice>
<p v-else-if="crashes.status === 'ready' && !visibleReports.length">{{ search ? 'No matching reports.' : 'No crash reports.' }}</p>
<div v-if="crashes.status === 'ready'" class="gx-crash-list">
@@ -1,4 +1,4 @@
// One submenu entry used by Tools, Logs & Diagnostics and Plots & Analysis so
// Shared submenu entry for tool groups, driving preferences and cameras so
// the icon alignment, hover and press feedback are identical everywhere.
export const MenuTile = {
name: "MenuTile",
@@ -1,3 +1,4 @@
import { GxSummary } from "./summary.js"
import { GxNotice } from "./notice.js"
import { GalaxySelect } from "./galaxy-select.js"
import { ModelManagerFeed } from "./models.js"
@@ -95,7 +96,7 @@ export class LaboratoryFeed extends ModelManagerFeed {
}
export const LaboratoryPage = {
components: { GxNotice, GalaxySelect },
components: { GxSummary, GxNotice, GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, status: null, error: "", message: "", busy: false, dirty: false, trackingProgress: false,
configuration: { enabled: false, lateralModel: "", longitudinalModel: "" } }),
@@ -159,13 +160,14 @@ export const LaboratoryPage = {
<div class="gx-section__header"><i class="bi bi-bezier2"></i><span class="gx-section__title">Model Laboratory</span></div>
<div style="padding:var(--sp-4);display:grid;gap:var(--sp-3)"><span class="gx-row__desc">Use lateral judgment from one model and longitudinal judgment from another.</span><div style="display:flex;gap:8px;flex-wrap:wrap"><span class="gx-chip">{{ status ? (status.chestnutReady ? 'Chestnut ready' : 'Chestnut required') : 'Waiting for device status' }}</span><span v-if="status" class="gx-chip">{{ status.isOnroad ? 'Onroad' : 'Parked' }}</span></div></div>
<p v-if="mode !== 'local'" class="gx-note" style="margin:var(--sp-4)">Model Laboratory is available on the device. Preview does not configure a pair.</p>
<p v-else-if="loading" class="gx-note" style="margin:var(--sp-4)">Loading Model Laboratory…</p>
<p v-else-if="loading" class="gx-card gx-message" role="status">Loading Model Laboratory…</p>
<GxNotice tone="danger" v-if="error" style="margin:var(--sp-4)">{{ error }}</GxNotice>
<p v-if="status?.configurationError" class="gx-note gx-note--danger">{{ status.configurationError }}</p>
<p v-if="message" class="gx-note" style="margin:var(--sp-4)" role="status">{{ message }}</p>
</section>
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-download"></i><span class="gx-section__title">Available models</span><span class="gx-chip">{{ availableModels.length }} catalog models · {{ status?.summary?.published || 0 }} Chestnut versions published · {{ readyModels.length }} downloads verified</span></div>
<div class="gx-section__header"><i class="bi bi-download"></i><span class="gx-section__title">Available models</span></div>
<GxSummary :items="[{label: 'Catalog models', value: availableModels.length}, {label: 'Published versions', value: status?.summary?.published || 0}, {label: 'Verified downloads', value: readyModels.length}]" />
<p class="gx-note" style="margin:var(--sp-4)">Browse Small and Big models here. Combining models requires compatible Chestnut downloads and support for running them together.</p>
<p v-if="status && !availableModels.length" class="gx-note" style="margin:var(--sp-4)">The model catalog is unavailable. Refresh to retry.</p>
<article v-for="m in availableModels" :key="m.value" class="gx-row" style="flex-wrap:wrap;gap:12px"><div class="gx-row__info" style="flex:1 1 180px;min-width:0"><span class="gx-row__label">{{ m.label }}</span><span class="gx-row__desc">{{ m.value }} · {{ m.small ? 'Small' : 'Big' }} · {{ m.series || 'Unknown series' }} · {{ m.version }}</span><span class="gx-row__desc">{{ m.modelLabReason || 'Pair artifact status unavailable' }}</span></div><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0"><span class="gx-chip">{{ statusLabel(m) }}</span><button v-if="m.small && m.modelLabEligible && m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" class="gx-btn gx-btn--tonal" :disabled="!can('download',m)" @click="act('download',m)">Download eGPU variant</button><button v-else-if="m.modelLabArtifactInstalled" class="gx-btn gx-btn--tonal" :disabled="!can('delete',m)" @click="act('delete',m)">Delete eGPU variant</button></div></article>
+11 -26
View File
@@ -1,3 +1,6 @@
import { GxSummary } from "./summary.js"
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { GalaxySelect } from "./galaxy-select.js"
@@ -313,7 +316,7 @@ export class ModelManagerFeed {
export const ModelsPage = {
name: "ModelsPage",
components: { GalaxySelect },
components: { GxSummary, GxIconButton, GalaxySelect, GxNotice },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data() {
return { loading: true, error: "", message: "", trackingProgress: false, busy: "", selectionUncertain: true, disposed: false,
@@ -432,9 +435,7 @@ export const ModelsPage = {
template: `
<div class="gx-view gx-model-manager">
<div v-if="mode !== 'local'" class="gx-card gx-message">Model Manager is unavailable in the offline preview.</div>
<div v-else-if="loading" class="gx-card">
<div class="gx-loading" style="padding: var(--sp-4);">Loading models...</div>
</div>
<div v-else-if="loading" class="gx-card gx-message" role="status">Reading installed models and available downloads…</div>
<template v-else>
<section class="gx-card">
@@ -442,29 +443,13 @@ export const ModelsPage = {
<i aria-hidden="true" class="bi bi-cpu"></i>
<span class="gx-section__title">Model Manager</span>
</div>
<div style="padding: var(--sp-3); display:flex; flex-wrap:wrap; gap:6px;">
<span class="gx-chip">{{ summary.installed }} installed</span>
<span class="gx-chip">{{ summary.missing }} missing</span>
<span class="gx-chip">{{ summary.total }} total</span>
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Selected: {{ currentLabel }}</span>
</div>
<GxSummary :items="[{label: 'Installed', value: summary.installed}, {label: 'Missing', value: summary.missing}, {label: 'Total', value: summary.total}]" />
<p class="gx-model-selection">Selected: <strong>{{ currentLabel }}</strong></p>
<div style="padding: 0 var(--sp-3) var(--sp-3);">
<p v-if="message" role="status">{{ message }}</p>
<div v-if="error" role="alert" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
<i aria-hidden="true" class="bi bi-exclamation-triangle-fill gx-alert__icon"></i>
<div class="gx-alert__body"><strong>Model Manager</strong><span>{{ error }}</span></div>
</div>
<div v-if="status.isOnroad" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
<i aria-hidden="true" class="bi bi-car-front-fill gx-alert__icon"></i>
<div class="gx-alert__body"><span>Model changes and downloads require parked device status.</span></div>
</div>
<div v-if="status.downloading" class="gx-alert gx-alert--info" style="border:none; margin:0;">
<i aria-hidden="true" class="bi bi-arrow-repeat gx-spin gx-alert__icon"></i>
<div class="gx-alert__body">
<strong>Downloading {{ downloadTargetLabel }}</strong>
<span v-if="status.progress">{{ status.progress }}</span>
</div>
</div>
<GxNotice v-if="error" tone="danger" title="Model Manager">{{ error }}</GxNotice>
<GxNotice v-if="status.isOnroad" tone="warn">Park the vehicle before changing or downloading models.</GxNotice>
<GxNotice v-if="status.downloading" :title="'Downloading ' + downloadTargetLabel">{{ status.progress || 'Keep the device connected until the download finishes.' }}</GxNotice>
</div>
</section>
@@ -606,7 +591,7 @@ export const ModelsPage = {
<button v-if="!m.builtin" type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="!canAction('delete', m)" @click="runAction('delete', m)"><i aria-hidden="true" class="bi bi-trash"></i> Delete</button>
</template>
<template v-else>
<button type="button" class="gx-btn" :disabled="!canAction('download', m)" @click="runAction('download', m)"><i aria-hidden="true" class="bi bi-download"></i> Download</button>
<GxIconButton :label="'Download ' + m.label" icon="bi-download" :disabled="!canAction('download', m)" @click="runAction('download', m)" />
</template>
</div>
</section>
+2 -2
View File
@@ -178,8 +178,8 @@ export const PipPage = {
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local saved settings are unavailable in preview.</div>
<template v-else>
<GxNotice v-if="state.data && !state.data.parked" tone="warn">Turn the vehicle off to change these settings.</GxNotice>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved settings…</div>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">Saved settings are unavailable.</GxNotice>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Reading blind-spot camera preferences and crop positions…</div>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read blind-spot camera preferences or crop positions. Reconnecting automatically…</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}
</GxNotice>
<div v-if="state.data" class="gx-settings__body">
+3 -2
View File
@@ -1,3 +1,4 @@
import { GxIconButton } from "./icon-button.js"
// Bounded, read-only local control history. No layout or telemetry is persisted.
export const MAX_POINTS = 240
export const POLL_MS = 750
@@ -188,7 +189,7 @@ export const PlotGraph = {
export const PlotsPage = {
name: 'PlotsPage',
components: { PlotGraph },
components: { PlotGraph, GxIconButton },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: 'idle', data: null, history: [], error: '', paused: false, advanced: false }),
created() { this.feed = new PlotsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
@@ -225,7 +226,7 @@ export const PlotsPage = {
<a class="gx-btn gx-btn--tonal" href="#/tuning/flm">Offline tracking analysis</a>
<div v-if="mode !== 'local'" class="gx-card gx-message">Live plots require the authenticated local Galaxy service.</div>
<template v-else>
<div class="gx-plots__toolbar"><button type="button" class="gx-btn gx-btn--tonal" @click="togglePause">{{ paused ? 'Resume' : 'Pause' }}</button>
<div class="gx-plots__toolbar"><GxIconButton :label="paused ? 'Resume live plots' : 'Pause live plots'" :icon="paused ? 'bi-play-fill' : 'bi-pause-fill'" @click="togglePause" />
<button type="button" class="gx-btn gx-btn--tonal" :aria-pressed="advanced" @click="advanced=!advanced">{{ advanced ? 'Hide' : 'Show' }} controller terms</button>
<span role="status">{{ paused ? 'Paused' : data?.bootStabilizing ? 'Starting vehicle systems' : status === 'current' ? 'Live' : status === 'stale' ? 'Reconnecting · last received values' : status === 'unavailable' ? 'Unavailable' : 'Connecting' }}</span></div>
<p class="gx-note" role="status" style="min-height:2.8em">{{ !paused && error ? error : 'Missing samples leave a gap in the graph.' }}</p>
@@ -1,3 +1,4 @@
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { SnapshotFeed } from "./snapshot-feed.js"
import { RecordingActions } from "./recording-actions.js"
@@ -103,7 +104,7 @@ export class LocalHistoryFeed extends SnapshotFeed {
export const LocalRecordingsPage = {
name: "LocalRecordingsPage",
components: { GxNotice, RecordingActions, GalaxySelect },
components: { GxIconButton, GxNotice, RecordingActions, GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ sortOrder: "newest", logsRoute: null, query: "", preservedOnly: false, status: "idle", data: null, error: "", playing: null, playerError: "", playerGeneration: 0,
details: null, detailsName: null, detailsStatus: "idle", detailsError: "", detailsGeneration: 0 }),
@@ -285,7 +286,7 @@ export const LocalRecordingsPage = {
<div class="gx-recordings__tabs" aria-label="Filter recordings"><button class="gx-btn gx-btn--tonal" :aria-pressed="!preservedOnly" @click="preservedOnly=false">All</button><button class="gx-btn gx-btn--tonal" :aria-pressed="preservedOnly" @click="preservedOnly=true">Preserved · {{ stats.preserved }}</button></div>
</div>
</section>
<p v-if="status === 'loading'" role="status">Finding local drives…</p><GxNotice tone="danger" v-if="status === 'unavailable'">{{ error }}</GxNotice>
<p v-if="status === 'loading'" role="status" class="gx-card gx-message">Finding local drives…</p><GxNotice tone="danger" v-if="status === 'unavailable'">{{ error }}</GxNotice>
<template v-if="status === 'ready' && data">
<p v-if="data.scanIncomplete" role="status">This scan was incomplete. More local segments may exist.</p>
<section class="gx-card gx-recordings__list">
@@ -311,9 +312,9 @@ export const LocalRecordingsPage = {
</li></ul>
</section>
<section v-if="detailsName" ref="detailsPanel" class="gx-card gx-recordings__details" aria-label="Recorded segment details">
<div class="gx-recordings__player-head"><h3>Recorded Segment Details</h3><button type="button" class="gx-btn gx-btn--tonal" @click="closeDetails">Close</button></div>
<div class="gx-recordings__player-head"><h3>Recorded Segment Details</h3><GxIconButton label="Close segment details" icon="bi-x-lg" @click="closeDetails" /></div>
<p class="gx-note">{{ detailsName }} · For this saved segment only. Distance is estimated from recorded speed.</p>
<p v-if="detailsStatus === 'loading'" role="status">Reading closed full log…</p>
<p v-if="detailsStatus === 'loading'" role="status" class="gx-card gx-message">Reading closed full log…</p>
<GxNotice tone="danger" v-if="detailsStatus === 'unavailable'">{{ detailsError }}</GxNotice>
<div v-if="detailsStatus === 'ready' && details" class="gx-recordings__metrics">
<div><strong>{{ detailMetric(details.observedCarSpanSeconds) }}</strong><span>Recorded time span</span></div>
@@ -331,16 +332,16 @@ export const LocalRecordingsPage = {
<p class="gx-note">Duration is approximate from saved one-minute segments. Older footage may have been removed to free space.</p>
<Teleport to="body"><dialog v-if="playing" ref="playerPanel" class="gx-card gx-recordings__player" aria-label="Camera recording player" @cancel.prevent="closePlayer">
<div class="gx-recordings__player-head"><div><h3>{{ cameraLabel(playing.camera) }}</h3><p class="gx-note">{{ playing.camera === "qcamera" ? "Quick preview" : "Full-resolution recording" }} · {{ playing.routeId }} · segment {{ playing.segments[playing.index].number }}</p></div>
<button type="button" class="gx-btn gx-btn--tonal" @click="closePlayer">Close</button></div>
<GxIconButton label="Close recording player" icon="bi-x-lg" @click="closePlayer" /></div>
<div class="gx-recordings__actions"><button v-for="camera in playerCameras" :key="camera" class="gx-btn gx-btn--tonal"
:aria-pressed="playing.camera === camera" @click="selectCamera(camera)">{{ cameraLabel(camera) }}</button></div>
<video :key="playerGeneration" ref="quickVideo" :data-player-generation="playerGeneration" controls playsinline autoplay preload="metadata" :src="playing.url" @error="videoError($event)" @ended="chooseSegment(1)"></video>
<GxNotice tone="danger" v-if="playerError">{{ playerError }}</GxNotice>
<div class="gx-recordings__actions"><a class="gx-btn gx-btn--tonal" :href="playing.url" :download="playing.routeId + '-' + playing.camera + '-' + playing.segments[playing.index].number + '.mp4'">Download segment</a>
<a class="gx-btn gx-btn--tonal" :href="driveVideoUrl(playing.routeId, playing.camera)" :download="playing.routeId + '-' + playing.camera + '.mp4'">Download drive video</a></div>
<div class="gx-recordings__player-controls"><button type="button" class="gx-btn gx-btn--tonal" :disabled="playing.index === 0" @click="chooseSegment(-1)">Previous segment</button>
<GalaxySelect class="gx-field" aria-label="Video segment" :value="String(playing.index)" @change="chooseSegment(Number($event.target.value) - playing.index)"><option v-for="(segment, index) in playing.segments" :key="segment.number" :value="String(index)">Segment {{ segment.number }} · {{ index + 1 }} of {{ playing.segments.length }}</option></GalaxySelect>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="playing.index === playing.segments.length - 1" @click="chooseSegment(1)">Next segment</button></div>
<a class="gx-btn gx-btn--tonal" :href="driveVideoUrl(playing.routeId, playing.camera)" :download="playing.routeId + '-' + playing.camera + '.mp4'">Download drive</a></div>
<div class="gx-recordings__player-controls"><GxIconButton label="Previous segment" icon="bi-skip-backward-fill" :disabled="playing.index === 0" @click="chooseSegment(-1)" />
<GalaxySelect class="gx-field" aria-label="Video segment" :value="String(playing.index)" @change="chooseSegment(Number($event.target.value) - playing.index)"><option v-for="(segment, index) in playing.segments" :key="segment.number" :value="String(index)" :data-collapsed-label="'Segment ' + segment.number">Segment {{ segment.number }} · {{ index + 1 }} of {{ playing.segments.length }}</option></GalaxySelect>
<GxIconButton label="Next segment" icon="bi-skip-forward-fill" :disabled="playing.index === playing.segments.length - 1" @click="chooseSegment(1)" /></div>
</dialog></Teleport>
</template>
+4 -2
View File
@@ -1,3 +1,4 @@
import { SettingsPage } from "./settings.js"
import { GxNotice } from "./notice.js"
import { SentryNotifications } from "./sentry-notifications.js"
import { SnapshotFeed } from "./snapshot-feed.js"
@@ -34,7 +35,7 @@ export class SentryEventsFeed extends SnapshotFeed {
export const SentryEventsPage = {
name: "SentryEventsPage",
components: { GxNotice, SentryNotifications },
components: { GxNotice, SentryNotifications, SettingsPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({ status: "idle", data: null, error: "" }),
@@ -52,11 +53,12 @@ export const SentryEventsPage = {
<p class="gx-note">Local motion records only · System clock times may be inaccurate</p></div>
</div>
<SettingsPage :mode="mode" :unauthorized="unauthorized" initial-page="sentry" title="Motion monitoring settings" />
<SentryNotifications :mode="mode" :unauthorized="unauthorized" />
<p v-if="mode !== 'local'" class="gx-card gx-message">Local motion records are unavailable in preview.</p>
<template v-else>
<p v-if="status === 'loading'" role="status">Reading local motion events…</p>
<p v-if="status === 'loading'" role="status" class="gx-card gx-message">Reading local motion events…</p>
<GxNotice tone="danger" v-if="status === 'unavailable'">{{ error }}</GxNotice>
<template v-if="status === 'ready' && data">
<p v-if="data.scanIncomplete" role="status">This scan was incomplete. More local motion events may exist.</p>
+11 -13
View File
@@ -28,7 +28,7 @@ export const SETTINGS_SECTIONS = Object.freeze([
export const isPipCropRow = (row) => row.label === "Visual camera crop editor" || /^Vehicle (left|right) crop/.test(row.label || "")
const SECTION_LINKS = {
device: [{ label: "Display", page: "display" }, { label: "Data Uploads", page: "data" }],
device: [{ label: "Display", page: "display", value: "Adjust screen brightness and display timing" }, { label: "Data Uploads", page: "data", value: "Choose mobile-data uploads and usage-statistics sharing" }],
visual: [{ label: "Driving Screen Widgets", page: "appearance" }, { label: "Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera and Preview", page: "pip" }],
}
@@ -115,7 +115,7 @@ export class SettingsFeed {
error.stale = true
throw error
}
if (!response.ok) throw new Error(response.status === 503 ? "Saved settings are unavailable." : "Settings request failed.")
if (!response.ok) throw new Error(response.status === 503 ? "The device could not read these preferences. Reconnecting automatically…" : "The device could not complete the settings request. Check your connection and try again.")
const data = await response.json().catch(() => { throw new Error("Galaxy could not load settings. Refresh to reconnect.") })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
return data
@@ -264,7 +264,7 @@ export const SettingsPage = {
const inHub = this.initialPage === "hub" && this.state.data?.page === "hub"
const section = this.activeSection
const entries = inHub && SECTION_LINKS[section.id]
? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: "" }, index }))
? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: row.value || "Customize " + row.label.toLocaleLowerCase() }, index }))
: rows.map((row, index) => ({ row, index })).filter(({ row }) => !inHub || section.pages.includes(row.page))
return entries.filter(({ row }) => !isPipCropRow(row)).filter(({ row, index }) => {
const previous = rows[index - 1]
@@ -349,9 +349,10 @@ export const SettingsPage = {
<OnroadLayoutPage ref="layoutEditor" v-if="state.layoutOpen" :mode="mode" :unauthorized="unauthorized" @close="closeLayout" />
<FavoritesPage ref="favoritesEditor" v-else-if="state.favoritesOpen" :mode="mode" :unauthorized="unauthorized" @close="closeFavorites" />
<section v-else class="gx-settings" :aria-label="title">
<div class="gx-settings__header"><div><h2>{{ title }}</h2></div></div>
<div v-if="initialPage !== 'hub' || atSectionRoot" class="gx-settings__header"><h2>{{ title }}</h2></div>
<GxNotice v-if="state.data && state.data.page !== 'hub' && !state.data.parked && state.data.rows.some(row => !row.available)" tone="warn">Turn the vehicle off to change these settings.</GxNotice>
<GxNotice v-if="initialPage === 'pip'" tone="info">Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.</GxNotice>
<GxNotice v-if="state.developerOpen">Choose where cloud accounts and uploads go. Older comma recordings keep their comma links.</GxNotice>
<GxNotice v-else-if="initialPage === 'pip'" tone="info">Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.</GxNotice>
<GxNotice v-else-if="(state.data?.page || initialPage) === 'sounds'" tone="info">Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.</GxNotice>
<GxNotice v-else-if="(state.data?.page || initialPage) === 'display'" tone="info">Adjust brightness and screen timing when custom display settings are on.</GxNotice>
<GxNotice v-else-if="initialPage === 'appearance'" tone="info">Choose which driving information to show. Use Colors &amp; Layout to arrange widgets and change colors.</GxNotice>
@@ -359,7 +360,7 @@ export const SettingsPage = {
<GxNotice v-else-if="state.data?.page === 'conditional' || state.data?.page.startsWith('conditional/')" tone="info">Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'profiles'" tone="info">Braking response works without saved personality curves. A selected personality braking preset or Traffic takes priority.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'traffic'" tone="info">Traffic follow and jerk blend toward saved Relaxed values at higher speeds. Saved curves require Use saved profiles and the Traffic profile switch.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'aol'" tone="info">Keep StarPilot lateral control active without holding the steering wheel.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'aol'" tone="info">Keep steering assistance active independently of cruise control.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'torque'" tone="info">Tune how StarPilot applies steering torque.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'lane'" tone="info">Choose lane-centering behavior and alerts.</GxNotice>
<GxNotice v-else-if="state.data?.page === 'curve'" tone="info">Set how the vehicle slows for curves ahead.</GxNotice>
@@ -370,16 +371,13 @@ export const SettingsPage = {
<GxNotice v-else-if="initialPage === 'sentry'" tone="info">{{ state.data?.subtitle || "Checking motion monitor…" }}</GxNotice>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local settings are unavailable in preview.</div>
<template v-else>
<div v-if="initialPage === 'hub'" class="gx-settings-tabs" aria-label="Settings sections">
<div v-if="initialPage === 'hub' && atSectionRoot" class="gx-settings-tabs" aria-label="Settings sections">
<button v-for="section in sections" :key="section.id" type="button" class="gx-chip" :aria-pressed="section.id === activeSection.id" :disabled="busy" @click="selectSection(section)">{{ section.label }}</button>
</div>
<section v-if="state.developerOpen" class="gx-card gx-settings__section gx-settings__developer" aria-label="Developer">
<div class="gx-section__header"><i class="bi bi-code-slash" aria-hidden="true"></i><span class="gx-section__title">Developer</span></div>
<div class="gx-settings__developer-body"><CloudProviderPage :mode="mode" :unauthorized="unauthorized" /></div>
</section>
<CloudProviderPage v-if="state.developerOpen" :mode="mode" :unauthorized="unauthorized" />
<p v-if="state.status === 'saving' || state.status === 'updating'" class="gx-settings__save-status" role="status">Saving preference…</p>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved settings…</div>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">Saved settings are unavailable.</GxNotice>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Reading your device’s saved preferences…</div>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read these preferences. Reconnecting automatically…</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}
</GxNotice>
<div v-if="state.data" class="gx-settings__body">
@@ -435,7 +435,7 @@ export const SoftwarePage = {
<template v-else>
<div class="gx-software-actions">
<button type="button" class="gx-btn" :disabled="actionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button></div>
<p v-if="status === 'loading' && !data" role="status">Loading software updates…</p>
<p v-if="status === 'loading' && !data" role="status" class="gx-card gx-message">Loading software updates…</p>
<GxNotice tone="danger" v-if="error">{{ error }}
</GxNotice>
<p v-if="notice" class="gx-card gx-message" role="status">{{ notice }}</p>
@@ -1,3 +1,4 @@
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
@@ -123,7 +124,7 @@ export class SoundPacksFeed {
}
export const SoundPacks = {
components: { GxNotice },
components: { GxIconButton, GxNotice },
props: { unauthorized: { type: Function, required: true }, disabled: { type: Boolean, default: false } },
emits: ["installed"],
setup(props, { emit }) {
@@ -155,7 +156,7 @@ export const SoundPacks = {
</div>
<div class="gx-settings__grid" style="padding: 16px">
<p v-if="state.status === 'loading'" role="status">Loading sound packs…</p>
<p v-if="state.status === 'loading'" role="status" class="gx-card gx-message">Loading sound packs…</p>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable'">Sound packs are unavailable.</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
<p v-if="state.snapshot && !state.snapshot.packs.length" class="gx-note">No sound packs are available.</p>
@@ -163,7 +164,7 @@ export const SoundPacks = {
<strong>{{ pack.name }}</strong>
<div class="gx-settings__controls">
<span v-if="pack.installed" class="gx-chip">Installed</span>
<button v-else type="button" class="gx-btn gx-btn--tonal" :disabled="!canDownload(pack)" @click="feed.download(pack.id)">Download</button>
<GxIconButton v-else :label="'Download ' + pack.name" icon="bi-download" :disabled="!canDownload(pack)" @click="feed.download(pack.id)" />
</div>
</div>
<div v-if="job" role="status">
@@ -0,0 +1,5 @@
// Numeric summaries get equal space instead of squeezing the section heading.
export const GxSummary = {
props: { items: { type: Array, required: true } },
template: `<dl class="gx-summary"><div v-for="item in items" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl>`,
}
@@ -38,7 +38,7 @@ export const SystemMonitor = {
<p v-if="mode === 'sample'" class="gx-note">Illustrative values only. No device was read.</p>
<p v-else class="gx-note">Read-only system activity. Updates every few seconds.</p>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<div v-else-if="!snapshot" class="gx-loading">Loading system activity…</div>
<div v-else-if="!snapshot" class="gx-card gx-message" role="status">Loading system activity…</div>
<template v-else>
<div class="gx-monitor__summary">
<section class="gx-card gx-monitor__metric"><span>CPU</span><strong>{{ number(snapshot.cpuPercent, '%') }}</strong><small>{{ snapshot.cores.length }} cores · overall usage</small></section>
+2 -2
View File
@@ -164,8 +164,8 @@ export const VasmPage = {
<GxNotice v-if="mode === 'local' && state.data && !state.data.parked" tone="warn">Turn the vehicle off to change these settings.</GxNotice>
<div v-if="mode !== 'local'" class="gx-card gx-message" role="status">Local saved settings are unavailable in preview.</div>
<template v-else>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Loading saved settings…</div>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">Saved settings are unavailable.</GxNotice>
<div v-if="state.status === 'loading'" class="gx-card gx-message" role="status">Reading camera regions and spot-monitoring preferences…</div>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read camera regions or spot-monitoring preferences. Reconnecting automatically…</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}
</GxNotice>
<div v-if="state.data" class="gx-settings__body">
@@ -582,19 +582,19 @@ class FeatureSettingsOwner:
"Send StarPilot your branch, device, vehicle, driving model, regional location, and feature settings " +
"while offroad. Excludes passwords, precise GPS, recordings, and VIN.")]
elif page == FeaturePage.HUB:
rows = [FeatureRow("", "Speed Limit Controller", "Saved settings", page=FeaturePage.SLC, available=True),
FeatureRow("", "Lane Centering", "Saved settings", page=FeaturePage.LANE, available=True),
FeatureRow("", "Lane Changes", "Lane change settings", page=FeaturePage.LANE_CHANGE, available=True),
FeatureRow("", "Long Planner", "Saved profiles", page=FeaturePage.PROFILES, available=True),
FeatureRow("", "Conditional Driving Modes", "Saved settings", page=FeaturePage.CONDITIONAL, available=True),
FeatureRow("", "Curve Speed Controller", "Saved settings", page=FeaturePage.CURVE, available=True),
FeatureRow("", "Steering and Torque", "Saved settings", page=FeaturePage.TORQUE, available=True),
FeatureRow("", "Always On Lateral", "Saved settings", page=FeaturePage.AOL, available=True),
FeatureRow("", "Wheel Controls", "Button assignments", page=FeaturePage.WHEEL, available=True)]
rows = [FeatureRow("", "Speed Limit Controller", "Choose speed-limit sources, offsets, and confirmation behavior", page=FeaturePage.SLC, available=True),
FeatureRow("", "Lane Centering", "Adjust lane positioning, lane detection, and steering alerts", page=FeaturePage.LANE, available=True),
FeatureRow("", "Lane Changes", "Configure lane-change speed, automatic timing, and driver nudges", page=FeaturePage.LANE_CHANGE, available=True),
FeatureRow("", "Long Planner", "Tune acceleration, braking, and following distances", page=FeaturePage.PROFILES, available=True),
FeatureRow("", "Conditional Driving Modes", "Choose when to switch between Chill and Experimental", page=FeaturePage.CONDITIONAL, available=True),
FeatureRow("", "Curve Speed Controller", "Adjust how speed is reduced for curves ahead", page=FeaturePage.CURVE, available=True),
FeatureRow("", "Steering and Torque", "Select the steering controller and tune its response", page=FeaturePage.TORQUE, available=True),
FeatureRow("", "Always On Lateral", "Keep steering assistance active independently of cruise control", page=FeaturePage.AOL, available=True),
FeatureRow("", "Wheel Controls", "Assign steering-wheel buttons and cruise behavior", page=FeaturePage.WHEEL, available=True)]
if (self._long_pitch_capability() is not None or self._bolt_disable_capability() is not None or
self._pedal_setup_capability() is not None or self._gm_stop_capability("VoltSNG") is not None or
self._gm_stop_capability("GMAutoHold") is not None or self.tesla_screen.capability() is not None):
rows.append(FeatureRow("", "Vehicle Settings", "Saved preferences", page=FeaturePage.VEHICLE, available=True))
rows.append(FeatureRow("", "Vehicle Settings", "Configure features supported by your vehicle", page=FeaturePage.VEHICLE, available=True))
rows.extend(FeatureRow("", name.title() + " Personality", "", page=name, available=True)
for name in (*PROFILE_NAMES, "traffic"))
elif page == FeaturePage.VEHICLE: