From 7a8e17dda58e7ab52999d4b959221a900265fd87 Mon Sep 17 00:00:00 2001
From: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Date: Fri, 23 Oct 2026 02:54:43 -0400
Subject: [PATCH] fix(galaxy): expand timeline for readable history
---
.../galaxy/tests/browser/replay-timeline.cjs | 92 ++++++++++++++-----
.../galaxy/tests/test_replay_timeline.mjs | 12 +--
openpilot/starpilot/galaxy/web/css/galaxy.css | 9 +-
prebuilt.json | 2 +-
4 files changed, 82 insertions(+), 33 deletions(-)
diff --git a/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs b/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs
index 165d88d34d..008779c3e7 100644
--- a/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs
+++ b/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs
@@ -24,7 +24,9 @@ const assert = require('node:assert/strict')
history.append({ schemaVersion: 1, ownerId, sessionId, rowOffset: 0, totalRows: 7, nextCursor: null, source: 'qlog', legend,
rows: [[0, 20, 'chill'], [20, 40, 'aol'], [40, 70, 'experimental'], [55, 56, 'error'],
[55.5, 55.5, 'bookmark'], [80, 120, 'chill'], [100, 100, 'bookmark']] }, status)
- document.body.innerHTML = ''
+ document.body.innerHTML = ``
window.replayControls = []
window.fixtureVM = createApp({ ...ReplayViewer,
data: () => ({ ...ReplayViewer.data(), replay: status, replayTimeline: markRaw(history), replayVideoReady: true,
@@ -38,22 +40,58 @@ const assert = require('node:assert/strict')
},
beforeUnmount() {},
}, { clientId: 'c'.repeat(32), routeId: 'fixture', unauthorized() {} }).mount('#fixture')
+ window.loadLongReplay = () => {
+ const vm = window.fixtureVM, status = { ...vm.replay, end: 1968, position: 5, seekRanges: [[0, 1968]] }
+ const rows = Array.from({ length: 328 }, (_, i) => [i * 6, (i + 1) * 6, i % 3 ? 'chill' : 'aol'])
+ const history = new ReplayHistory(status.ownerId, status.sessionId)
+ for (let offset = 0; offset < rows.length; offset += 256) history.append({ schemaVersion: 1, ownerId: status.ownerId,
+ sessionId: status.sessionId, rowOffset: offset, totalRows: rows.length, source: 'qlog', legend,
+ rows: rows.slice(offset, offset + 256), nextCursor: offset + 256 < rows.length ? offset + 256 : null }, status)
+ vm.replay = status; vm.replayTimeline = markRaw(history)
+ }
})
- for (const width of [1280, 390]) {
- await page.setViewportSize({ width, height: 900 })
+ const geometry = async () => page.locator('.gx-replay__media-controls').evaluate(media => {
+ const bounds = element => {
+ const { x, y, width, height } = element.getBoundingClientRect()
+ return { x, y, width, height }
+ }
+ const track = media.querySelector('.gx-replay__track'), canvas = track.querySelector('canvas'), style = getComputedStyle(media)
+ return { media: bounds(media), caption: bounds(media.querySelector('.gx-replay__timeline > span')),
+ track: bounds(track), canvas: bounds(canvas), input: bounds(track.querySelector('input')),
+ controls: bounds(media.querySelector('.gx-replay__controls')), pixels: canvas.width,
+ available: media.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
+ overflow: document.documentElement.scrollWidth > innerWidth }
+ })
+ const assertLayout = async width => {
await page.waitForFunction(width => {
- const canvas = document.querySelector('.gx-replay__track canvas')
- return canvas?.height === (width <= 600 ? 44 : 54)
+ const canvas = document.querySelector('.gx-replay__rail canvas')
+ return canvas?.height === (width <= 600 ? 52 : 62) && Math.abs(canvas.width - canvas.getBoundingClientRect().width * 2) <= 1
}, width)
- const geometry = await page.locator('.gx-replay__track').evaluate(track => {
- const canvas = track.querySelector('canvas'), input = track.querySelector('input')
- const bounds = element => ({ width: element.getBoundingClientRect().width, height: element.getBoundingClientRect().height })
- return { canvas: bounds(canvas), input: bounds(input), pixels: canvas.width, overflow: document.documentElement.scrollWidth > innerWidth }
- })
- assert.equal(geometry.canvas.height, width <= 600 ? 22 : 27)
- assert.ok(geometry.input.height >= 44, 'the rail keeps its generous seek hit area')
- assert.ok(Math.abs(geometry.pixels - geometry.canvas.width * 2) <= 1)
- assert.equal(geometry.overflow, false)
+ const g = await geometry()
+ assert.equal(g.canvas.height, width <= 600 ? 26 : 31)
+ assert.equal(g.input.height, 48)
+ assert.ok(Math.abs(g.track.width - g.available) <= 1, 'timeline uses the full control area')
+ assert.ok(Math.abs(g.canvas.width - (g.track.width - 16)) <= 1)
+ assert.ok(g.track.y >= g.caption.y + g.caption.height - 1, 'caption is above the track')
+ assert.ok(g.controls.y >= g.track.y + g.track.height - 1, 'buttons are below the track')
+ assert.ok(Math.abs(g.pixels - g.canvas.width * 2) <= 1)
+ assert.equal(g.overflow, false)
+ }
+ for (const [embedded, width] of [[false, 1280], [true, 1280], [true, 1000], [true, 390]]) {
+ await page.evaluate(embedded => {
+ const workspace = document.getElementById('workspace'), body = workspace.firstElementChild, viewer = body.firstElementChild
+ workspace.className = embedded ? 'gx-route-workspace' : ''
+ body.className = embedded ? 'gx-route-workspace__body' : ''
+ viewer.className = embedded ? 'gx-card gx-route-workspace__viewer' : ''
+ body.lastElementChild.hidden = !embedded
+ }, embedded)
+ await page.setViewportSize({ width, height: 900 })
+ await assertLayout(width)
+ if (embedded && width === 1280) {
+ const viewer = await page.locator('.gx-route-workspace__viewer').boundingBox()
+ const files = await page.locator('.gx-route-workspace__files').boundingBox()
+ assert.ok(files.x >= viewer.x + viewer.width, 'desktop fixture includes the real files sidebar')
+ }
const slider = page.getByRole('slider', { name: 'Replay route position' })
const box = await slider.boundingBox()
const x = box.x + box.width * .5, y = box.y + box.height / 2
@@ -77,7 +115,7 @@ const assert = require('node:assert/strict')
assert.ok(bounds.y + bounds.height < box.y - 20, 'preview stays above the touch target')
assert.equal(await page.locator('.gx-replay__rail canvas').evaluate(canvas => canvas.toDataURL()), bitmap, 'scrubbing reuses the canvas')
assert.equal(await page.evaluate(() => window.replayPaints), 0, 'scrubbing never repaints the history')
- if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-scrub-${width}.png` })
+ if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-scrub-${embedded ? 'workspace' : 'standalone'}-${width}.png` })
await page.mouse.up()
assert.equal(await preview.count(), 0)
assert.equal(await page.evaluate(() => window.replayControls.length), 1, 'release commits exactly once')
@@ -117,16 +155,16 @@ const assert = require('node:assert/strict')
assert.match(await slider.getAttribute('aria-valuetext'), /Experimental/)
await page.locator('summary', { hasText: 'Timeline colors' }).click()
assert.equal(await page.locator('.gx-replay__legend span').count(), 5)
- if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-${width}.png` })
+ if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-${embedded ? 'workspace' : 'standalone'}-${width}.png` })
await page.locator('summary', { hasText: 'Timeline colors' }).click()
await page.getByRole('button', { name: 'Fullscreen video' }).click()
- await page.waitForFunction(() => document.fullscreenElement &&
- document.querySelector('.gx-replay__track canvas').height === (innerWidth <= 600 ? 44 : 54))
+ await page.waitForFunction(() => document.fullscreenElement)
+ await assertLayout(width)
const fullscreenHeight = await page.locator('.gx-replay__track canvas').evaluate(canvas => ({
- actual: canvas.getBoundingClientRect().height, expected: innerWidth <= 600 ? 22 : 27,
+ actual: canvas.getBoundingClientRect().height, expected: innerWidth <= 600 ? 26 : 31,
}))
assert.equal(fullscreenHeight.actual, fullscreenHeight.expected, 'fullscreen preserves restrained rail sizing')
- if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-fullscreen-${width}.png` })
+ if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-fullscreen-${embedded ? 'workspace' : 'standalone'}-${width}.png` })
await page.evaluate(() => document.exitFullscreen())
}
await page.setViewportSize({ width: 390, height: 900 })
@@ -142,7 +180,19 @@ const assert = require('node:assert/strict')
assert.equal(await page.locator('.gx-replay__preview').count(), 0)
assert.equal(await page.evaluate(() => window.replayControls.length), cancel ? 0 : 1, 'touch end commits once; cancellation never commits')
}
+ await page.evaluate(() => window.loadLongReplay())
+ for (const width of [1280, 390, 320]) {
+ await page.setViewportSize({ width, height: 900 })
+ await assertLayout(width)
+ assert.match(await page.locator('.gx-replay__timeline').textContent(), /32:48/)
+ if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-long-route-${width}.png` })
+ }
+ await page.evaluate(() => {
+ window.fixtureVM.replayTimeline = null
+ window.fixtureVM.replayHistoryNotice = 'History unavailable; the recorded route can still be replayed.'
+ })
+ await assertLayout(320)
assert.deepEqual(errors, [])
await browser.close()
- console.log('Replay timeline browser: sizing, cached drag preview, edges, touch, cancellation, authority changes, fullscreen and keyboard passed')
+ console.log('Replay timeline browser: embedded/standalone layout, dense long routes, cached dragging, touch, cancellation, fullscreen and keyboard passed')
})().catch(error => { console.error(error); process.exit(1) })
diff --git a/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs b/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs
index 042a070d0b..d804203066 100644
--- a/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs
@@ -29,25 +29,25 @@ assert.throws(() => {
}, /History unavailable/, 'a late page cannot change the declared total')
const painted = []
-let height = 27, tick = 5
+let height = 31, tick = 5
globalThis.getComputedStyle = () => ({getPropertyValue: key => key === '--replay-tick' ? String(tick) : '2'})
const canvas = {getBoundingClientRect: () => ({width: 100, height}), getContext: () => ({
setTransform() {}, set fillStyle(value) { this.color = value }, fillRect(x, y, w, h) { painted.push({x, y, w, h, color: this.color}) },
})}
drawReplayTimeline(canvas, status, history, 2)
assert.equal(canvas.width, 200)
-assert.equal(canvas.height, 54)
+assert.equal(canvas.height, 62)
const red = painted.findIndex(row => row.color === 'rgb(13 20 30)')
const mark = painted.findIndex(row => row.color === 'rgb(14 20 30)')
assert.ok(red > painted.findIndex(row => row.color === 'rgb(12 20 30)'), 'red is painted after all mode blocks')
assert.ok(mark > red && painted[mark].y < painted[red].y, 'bookmark occupies its own lane')
assert.ok(painted[red].w >= .5, 'a brief error occupies at least one physical pixel at DPR2')
assert.equal(painted[mark].w, 3, 'bookmark remains three CSS pixels wide at DPR2')
-assert.equal(painted[red].h, 20)
-height = 22; tick = 4
+assert.equal(painted[red].h, 24)
+height = 26; tick = 4
drawReplayTimeline(canvas, status, history, 1)
-assert.equal(canvas.height, 22)
-assert.equal(painted.at(-2).h, 16)
+assert.equal(canvas.height, 26)
+assert.equal(painted.at(-2).h, 20)
console.log('Replay timeline: atomic pages, validation, independent alerts, gaps, pixel precedence and compact sizing passed')
// A late page must not restore history after ownership or offroad authority disappears.
diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css
index e72adf5641..61f59ad7b9 100644
--- a/openpilot/starpilot/galaxy/web/css/galaxy.css
+++ b/openpilot/starpilot/galaxy/web/css/galaxy.css
@@ -1318,9 +1318,8 @@ button.gx-chip:hover {
.gx-route-workspace__job progress { flex: 1 1 8rem; max-width: 16rem; accent-color: var(--primary); }
.gx-route-workspace .gx-replay { display: grid; gap: var(--sp-2); }
.gx-route-workspace .gx-replay > .gx-notice, .gx-route-workspace .gx-replay > .gx-note, .gx-route-workspace .gx-replay > .gx-btn, .gx-route-workspace .gx-replay__gaps { margin-inline: var(--sp-3); }
-.gx-route-workspace .gx-replay__media-controls { position: static; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: var(--sp-1) var(--sp-3) var(--sp-2); }
-.gx-route-workspace .gx-replay__timeline { flex: 1 1 16rem; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--sp-2); }
-.gx-route-workspace .gx-replay__controls { flex: 1 1 18rem; gap: var(--sp-1); }
+.gx-route-workspace .gx-replay__media-controls { position: static; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
+.gx-route-workspace .gx-replay__controls { gap: var(--sp-1); }
.gx-route-workspace .gx-replay__controls .gx-btn { font-size: var(--fs-sm); }
.gx-route-workspace .gx-replay__video { aspect-ratio: 2 / 1; max-height: 50dvh; object-fit: contain; }
.gx-route-workspace .gx-replay__player:fullscreen .gx-replay__screen { flex: 1; min-height: 0; display: flex; align-items: center; }
@@ -1743,7 +1742,7 @@ progress { max-width: 100%; accent-color: var(--primary); }
.gx-replay__controls .gx-btn:focus-visible, .gx-replay__fullscreen:focus-visible { outline: 2px solid var(--replay-accent); outline-offset: 2px; }
.gx-replay__fullscreen { position: absolute; top: 8px; right: 8px; width: var(--touch-target); background: rgb(8 8 10 / 82%); }
.gx-replay__timeline { display: grid; gap: 0; font-size: clamp(12px, 1.3vw, 16px); }
-.gx-replay__track { position: relative; display: block; height: var(--touch-target); --replay-thumb: 16px; --replay-rail: 20px; --replay-tick: 5px; --replay-gap: 2px; }
+.gx-replay__track { position: relative; display: block; height: 48px; --replay-thumb: 16px; --replay-rail: 24px; --replay-tick: 5px; --replay-gap: 2px; }
.gx-replay__rail { position: absolute; left: calc(var(--replay-thumb) / 2); right: calc(var(--replay-thumb) / 2); height: calc(var(--replay-rail) + var(--replay-tick) + var(--replay-gap)); top: 50%; transform: translateY(-50%); pointer-events: none; }
.gx-replay__rail canvas { display: block; width: 100%; height: 100%; }
.gx-replay__playhead { position: absolute; left: var(--replay-position); top: calc(var(--replay-tick) + var(--replay-gap) - 1px); width: 2px; height: calc(var(--replay-rail) + 2px); transform: translateX(-50%); background: #fff; }
@@ -1765,7 +1764,7 @@ progress { max-width: 100%; accent-color: var(--primary); }
.gx-replay__player:fullscreen { display: flex; flex-direction: column; justify-content: center; border-radius: 0; }
.gx-replay__player:fullscreen .gx-replay__video { max-height: 100dvh; }
@media (max-width: 600px) {
- .gx-replay__track { --replay-rail: 16px; --replay-tick: 4px; }
+ .gx-replay__track { --replay-rail: 20px; --replay-tick: 4px; }
.gx-replay__media-controls { position: static; padding-inline: 4px; }
.gx-replay__controls { gap: 4px; }
.gx-replay__player:fullscreen .gx-replay__video { min-height: 0; flex: 1; }
diff --git a/prebuilt.json b/prebuilt.json
index 4b13cd68b5..2afaf9ce8b 100644
--- a/prebuilt.json
+++ b/prebuilt.json
@@ -1 +1 @@
-{"protected_inventory_sha256": "2a9adededf0b923acb27758cbd871bab480655d96dd4de529570442019412162", "schema_version": 1}
+{"protected_inventory_sha256": "0461dbb098afa599b01e97b034e3abb4ce64f2cff182d133c7c29fddad5ec0dd", "schema_version": 1}