From 32f0babe14d24e3c8e947c030e8ed5830fe03d1f Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 28 Apr 2026 12:24:31 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(live):=20slow-mo=20playback=20?= =?UTF-8?q?=E2=80=94=20extend=20VCR=20speed=20to=20sub-1x=20(closes=20#771?= =?UTF-8?q?=20M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 0.25x and 0.5x speeds to the VCR speed cycle [0.25, 0.5, 1, 2, 4, 8]. Speed button shows ¼x / ½x labels. Animation duration scales with speed (drawAnimatedLine step interval, drawMatrixLine DURATION_MS). Speed preference persisted to localStorage and restored on page load. Co-Authored-By: Claude Sonnet 4.6 --- public/live.js | 23 +++++++++++++++++------ test-live.js | 29 ++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/public/live.js b/public/live.js index c9391cf8..72216209 100644 --- a/public/live.js +++ b/public/live.js @@ -22,6 +22,8 @@ let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true'; let matrixMode = localStorage.getItem('live-matrix-mode') === 'true'; let matrixRain = localStorage.getItem('live-matrix-rain') === 'true'; + const _savedSpeed = parseFloat(localStorage.getItem('live-vcr-speed')); + const _initialSpeed = [0.25, 0.5, 1, 2, 4, 8].includes(_savedSpeed) ? _savedSpeed : 1; let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null; const propagationBuffer = new Map(); // hash -> {timer, packets[]} let _onResize = null; @@ -393,10 +395,17 @@ if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; } } + function speedLabel(s) { + if (s === 0.25) return '¼x'; + if (s === 0.5) return '½x'; + return s + 'x'; + } + function vcrSpeedCycle() { - const speeds = [1, 2, 4, 8]; + const speeds = [0.25, 0.5, 1, 2, 4, 8]; const idx = speeds.indexOf(VCR.speed); VCR.speed = speeds[(idx + 1) % speeds.length]; + localStorage.setItem('live-vcr-speed', VCR.speed); updateVCRUI(); // If replaying, restart with new speed if (VCR.mode === 'REPLAY' && VCR.replayTimer) { @@ -532,7 +541,7 @@ if (pauseBtn) { pauseBtn.textContent = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); } if (missedEl) missedEl.classList.add('hidden'); } - if (speedBtn) { speedBtn.textContent = VCR.speed + 'x'; speedBtn.setAttribute('aria-label', 'Speed ' + VCR.speed + 'x'); } + if (speedBtn) { speedBtn.textContent = speedLabel(VCR.speed); speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed)); } updateVCRLcd(); } @@ -1865,6 +1874,7 @@ window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml; window._liveResolveHopPositions = resolveHopPositions; window._liveVcrSpeedCycle = vcrSpeedCycle; + window._liveSpeedLabel = speedLabel; window._liveVcrPause = vcrPause; window._liveVcrResumeLive = vcrResumeLive; window._liveVcrSetMode = vcrSetMode; @@ -2504,7 +2514,7 @@ const matrixGreen = '#00ff41'; const TRAIL_LEN = Math.min(6, bytes.length); - const DURATION_MS = 1100; // total hop duration + const DURATION_MS = 1100 / VCR.speed; const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress const charMarkers = []; let nextCharAt = CHAR_INTERVAL; @@ -2623,8 +2633,9 @@ return; } const elapsed = now - lastStep; - if (elapsed >= 33) { - const ticks = Math.min(Math.floor(elapsed / 33), 4); + const stepMs = 33 / VCR.speed; + if (elapsed >= stepMs) { + const ticks = Math.min(Math.floor(elapsed / stepMs), 4); lastStep = now; for (let t = 0; t < ticks && step < steps; t++) { step++; @@ -2947,7 +2958,7 @@ packetCount = 0; activeAnims = 0; nodeActivity = {}; pktTimestamps = []; feedDedup.clear(); - VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1; VCR.replayGen = 0; + VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = _initialSpeed; VCR.replayGen = 0; } let _themeRefreshHandler = null; diff --git a/test-live.js b/test-live.js index a827a48f..ba161f17 100644 --- a/test-live.js +++ b/test-live.js @@ -402,9 +402,13 @@ console.log('\n=== live.js: VCR state machine ==='); assert.strictEqual(VCR().mode, 'PAUSED', 'mode should stay PAUSED after second call'); }); - test('vcrSpeedCycle cycles through 1,2,4,8', () => { + test('vcrSpeedCycle cycles through 0.25, 0.5, 1, 2, 4, 8 and wraps', () => { vcrSetMode('LIVE'); - VCR().speed = 1; + VCR().speed = 0.25; + vcrSpeedCycle(); + assert.strictEqual(VCR().speed, 0.5); + vcrSpeedCycle(); + assert.strictEqual(VCR().speed, 1); vcrSpeedCycle(); assert.strictEqual(VCR().speed, 2); vcrSpeedCycle(); @@ -412,7 +416,26 @@ console.log('\n=== live.js: VCR state machine ==='); vcrSpeedCycle(); assert.strictEqual(VCR().speed, 8); vcrSpeedCycle(); - assert.strictEqual(VCR().speed, 1); // wraps around + assert.strictEqual(VCR().speed, 0.25); // wraps around + }); + + test('vcrSpeedCycle saves speed to localStorage', () => { + VCR().speed = 1; + vcrSpeedCycle(); + assert.strictEqual(ctx.localStorage.getItem('live-vcr-speed'), '2'); + vcrSpeedCycle(); + assert.strictEqual(ctx.localStorage.getItem('live-vcr-speed'), '4'); + }); + + const speedLabel = ctx.window._liveSpeedLabel; + assert.ok(speedLabel, '_liveSpeedLabel must be exposed'); + + test('speedLabel returns fraction strings for sub-1x speeds', () => { + assert.strictEqual(speedLabel(0.25), '¼x'); + assert.strictEqual(speedLabel(0.5), '½x'); + assert.strictEqual(speedLabel(1), '1x'); + assert.strictEqual(speedLabel(2), '2x'); + assert.strictEqual(speedLabel(8), '8x'); }); const vcrResumeLive = ctx.window._liveVcrResumeLive; From 2391ae4e3dd41cafc43091c5d2a4d1c7a3eedf79 Mon Sep 17 00:00:00 2001 From: efiten Date: Mon, 27 Apr 2026 13:47:54 +0200 Subject: [PATCH 2/2] test(e2e): fix detached-element race in node detail tests page.$() captures an element handle at query time. When the nodes page WebSocket auto-refresh fires between the querySelector and the .click() call, the table is re-rendered and the element is detached, causing "Element is not attached to the DOM". Replace both occurrences with page.click(selector), which re-queries the DOM at click time and retries until the element is stable. Co-Authored-By: Claude Sonnet 4.6 --- test-e2e-playwright.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index c1963964..284a3871 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -224,10 +224,9 @@ async function run() { // Test 5: Node detail loads (reuses nodes page from test 2) await test('Node detail loads', async () => { await page.waitForSelector('table tbody tr'); - // Click first row - const firstRow = await page.$('table tbody tr'); - assert(firstRow, 'No node rows found'); - await firstRow.click(); + // Use page.click() instead of an element handle to avoid detached-element races + // when the WebSocket auto-refresh re-renders the table between querySelector and click. + await page.click('table tbody tr'); // Wait for detail pane to appear await page.waitForSelector('.node-detail'); const html = await page.content(); @@ -240,10 +239,8 @@ async function run() { await test('Node side panel Details link navigates', async () => { await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('table tbody tr'); - // Click first row to open side panel - const firstRow = await page.$('table tbody tr'); - assert(firstRow, 'No node rows found'); - await firstRow.click(); + // Use page.click() to avoid detached-element race with WebSocket auto-refresh. + await page.click('table tbody tr'); await page.waitForSelector('.node-detail'); // Find the Details link in the side panel const detailsLink = await page.$('#nodesRight a.btn-primary[href^="#/nodes/"]');