mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 19:43:49 +08:00
(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:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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 & 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 & 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>`,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user