From 878d162b71de92249e5454b2b72a4418b35dff3d Mon Sep 17 00:00:00 2001 From: efiten Date: Sun, 31 May 2026 23:54:24 +0200 Subject: [PATCH] fix(live): persist nav-pin state across refresh (#1510) (#1515) ## What was broken The nav-pin button state was not persisted across page loads. Every refresh reset the nav to unpinned regardless of what the user had set, forcing them to re-pin on every visit. ## What was added - On init: reads `localStorage.getItem('live-nav-pinned')` and restores the pinned state into `_navCleanup.pinned` before the button is created; if pinned, the button gets the `pinned` class, `aria-pressed="true"`, and `nav-autohide` is removed from the nav. - On click: after toggling, writes `localStorage.setItem('live-nav-pinned', _navCleanup.pinned)` inside a `try/catch` (quota guard, consistent with other live.js localStorage writes). localStorage key: `live-nav-pinned` Closes #1510 --------- Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/deploy.yml | 1 + public/live.js | 9 ++- test-issue-1510-live-nav-pin-e2e.js | 112 ++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 test-issue-1510-live-nav-pin-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index be879b5a..7bc4a707 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -363,6 +363,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/live.js b/public/live.js index 80859b97..80d9fd7f 100644 --- a/public/live.js +++ b/public/live.js @@ -2132,7 +2132,8 @@ // Auto-hide nav with pin toggle (#62) const topNav = document.querySelector('.top-nav'); if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; } - _navCleanup = { timeout: null, fn: null, pinned: false }; + const _savedPin = localStorage.getItem('live-nav-pinned') === 'true'; + _navCleanup = { timeout: null, fn: null, pinned: _savedPin }; // Add pin button to nav (guard against duplicate) if (topNav && !document.getElementById('navPinBtn')) { const pinBtn = document.createElement('button'); @@ -2146,6 +2147,7 @@ _navCleanup.pinned = !_navCleanup.pinned; pinBtn.classList.toggle('pinned', _navCleanup.pinned); pinBtn.setAttribute('aria-pressed', _navCleanup.pinned); + try { localStorage.setItem('live-nav-pinned', _navCleanup.pinned); } catch (_) {} if (_navCleanup.pinned) { clearTimeout(_navCleanup.timeout); topNav.classList.remove('nav-autohide'); @@ -2153,6 +2155,11 @@ _navCleanup.timeout = setTimeout(() => { topNav.classList.add('nav-autohide'); }, 4000); } }); + if (_navCleanup.pinned) { + pinBtn.classList.add('pinned'); + pinBtn.setAttribute('aria-pressed', 'true'); + topNav.classList.remove('nav-autohide'); + } topNav.appendChild(pinBtn); } function showNav() { diff --git a/test-issue-1510-live-nav-pin-e2e.js b/test-issue-1510-live-nav-pin-e2e.js new file mode 100644 index 00000000..1d248180 --- /dev/null +++ b/test-issue-1510-live-nav-pin-e2e.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/** + * E2E for #1510 — live-page nav pin must persist across refresh. + * + * Symptom: clicking #navPinBtn pinned the nav, but reloading the page reset + * pinned=false regardless. Fix: store 'live-nav-pinned' in localStorage and + * restore on init. + * + * Acceptance: + * 1. Clicking #navPinBtn adds class 'pinned' and sets localStorage. + * 2. After page reload the pin button still has class 'pinned' (restored from storage). + * 3. Clicking again removes 'pinned'; reload leaves button unpinned. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const LS_KEY = 'live-nav-pinned'; + +let passed = 0, failed = 0; + +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } + +async function gotoLive(page) { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#navPinBtn', { timeout: 10000 }); + await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); +} + +async function main() { + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (process.env.CHROMIUM_REQUIRE === '1') { + console.error('test-issue-1510-live-nav-pin-e2e.js: FAIL — Chromium unavailable: ' + err.message); + process.exit(1); + } + console.log('test-issue-1510-live-nav-pin-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + page.setDefaultTimeout(12000); + + // Clear any prior state. + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.evaluate((k) => localStorage.removeItem(k), LS_KEY); + + // (1) Initial state: pin button exists and is unpinned. + await step('pin button renders without pinned class on first load', async () => { + await gotoLive(page); + const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned')); + assert(!pinned, 'pin button must not have class "pinned" before first click'); + const stored = await page.evaluate((k) => localStorage.getItem(k), LS_KEY); + assert(stored !== 'true', 'localStorage must not be "true" before first click'); + }); + + // (2) Click pin → class added, localStorage updated. + await step('click sets pinned class and persists to localStorage', async () => { + await page.$eval('#navPinBtn', btn => btn.click()); + await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); + const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned')); + assert(pinned, 'pin button must have class "pinned" after click'); + const stored = await page.evaluate((k) => localStorage.getItem(k), LS_KEY); + assert(stored === 'true', `localStorage["${LS_KEY}"] must be "true", got "${stored}"`); + }); + + // (3) Reload → pin state restored from localStorage. + await step('pin state survives page reload', async () => { + await gotoLive(page); + const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned')); + assert(pinned, 'pin button must have class "pinned" after reload (restored from localStorage)'); + }); + + // (4) Click again to unpin → class removed, localStorage updated. + await step('second click removes pinned class and updates localStorage', async () => { + await page.$eval('#navPinBtn', btn => btn.click()); + await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); + const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned')); + assert(!pinned, 'pin button must not have class "pinned" after unpin click'); + const stored = await page.evaluate((k) => localStorage.getItem(k), LS_KEY); + assert(stored === 'false', `localStorage["${LS_KEY}"] must be "false", got "${stored}"`); + }); + + // (5) Reload after unpin → button is not pinned. + await step('unpinned state survives page reload', async () => { + await gotoLive(page); + const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned')); + assert(!pinned, 'pin button must not have class "pinned" after reload in unpinned state'); + }); + + await browser.close(); + const total = passed + failed; + console.log(`\ntest-issue-1510-live-nav-pin-e2e.js: ${failed === 0 ? 'OK' : 'FAIL'} — ${passed}/${total} passed`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error('test-issue-1510-live-nav-pin-e2e.js: ERROR', err); + process.exit(1); +});