diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index add37a77..9e70c6b7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -259,6 +259,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt 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 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-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/gesture-hints.js b/public/gesture-hints.js index 563491f0..1aee40ed 100644 --- a/public/gesture-hints.js +++ b/public/gesture-hints.js @@ -21,13 +21,24 @@ window.__gestureHints1065Init = 1; var NS = 'meshcore-gesture-hints-'; + // #1244: gesture hints are bottom-anchored pills. On /live they get + // buried below the absolute-positioned VCR bar (+ safe-area inset), + // appearing as orphan "Got it" litter visible only after scrolling. + // Option (a) from #1244 — disable hints on /live entirely. Swipe-nav + // discoverability doesn't apply on Live anyway (map drag, VCR + // controls, and feed all own touch). + function onLiveRoute() { + var h = location.hash || ''; + return /^#\/live(\/|$|\?)/.test(h); + } var HINTS = { 'row-swipe': { key: NS + 'row-swipe', text: 'Tip: swipe a row left for quick actions.', relevant: function () { + if (onLiveRoute()) return false; // #1244 var h = location.hash || ''; - return /^#\/(packets|nodes|live)/.test(h); + return /^#\/(packets|nodes)/.test(h); }, position: 'bottom', }, @@ -35,6 +46,7 @@ key: NS + 'tab-swipe', text: 'Tip: swipe left or right to switch tabs.', relevant: function () { + if (onLiveRoute()) return false; // #1244 return !!document.querySelector('[data-bottom-nav]'); }, position: 'bottom', @@ -43,6 +55,7 @@ key: NS + 'edge-drawer', text: 'Tip: swipe in from the left edge to open navigation.', relevant: function () { + if (onLiveRoute()) return false; // #1244 return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]'); }, position: 'top-left', @@ -51,6 +64,7 @@ key: NS + 'pull-refresh', text: 'Tip: pull down to refresh the connection.', relevant: function () { + if (onLiveRoute()) return false; // #1244 return !!document.querySelector('.pull-to-reconnect'); }, position: 'top', diff --git a/public/live.css b/public/live.css index a04ba192..c45345cd 100644 --- a/public/live.css +++ b/public/live.css @@ -1024,36 +1024,44 @@ /* Mobile VCR */ @media (max-width: 640px) { - /* Mobile VCR: two-row stacked layout */ + /* #1244: SINGLE-ROW layout. PR #1234 was meant to put all VCR controls + * on one row but `flex-wrap: wrap` here + `width: 100%` on the timeline + * forced a 2-row layout (controls/LCD on row 1, scope+scrubber on row 2). + * Fix: `flex-wrap: nowrap`, the timeline gets `flex: 1 1 0` so it + * absorbs leftover width, and scope buttons shrink (1/6/More) to fit. */ .vcr-bar { padding: 4px 8px; padding-bottom: calc(4px + env(safe-area-inset-bottom, 20px)); - flex-wrap: wrap; + flex-wrap: nowrap; gap: 4px; overflow: visible; } /* #1221: LCD clock shares row with playback controls (not floating - * bottom-right). Scope buttons wrap to row 2; timeline to row 3. - * LCD is scaled ~70% of desktop so it fits next to the touch-target - * buttons without clipping at 375px viewport. */ - .vcr-controls { order: 1; flex-shrink: 0; gap: 4px; } + * bottom-right). LCD is scaled ~70% of desktop so it fits next to the + * touch-target buttons without clipping at 375px viewport. */ + .vcr-controls { order: 1; flex-shrink: 0; gap: 2px; } .vcr-lcd { - order: 2; - margin-left: auto; - min-width: 70px; + order: 4; + /* #1244: no auto margin in a no-wrap row — let the timeline absorb + * free space via flex:1 instead. */ + margin-left: 0; + min-width: 0; padding: 2px 4px; flex-shrink: 0; } .vcr-lcd-canvas { width: 78px; height: 18px; } .vcr-lcd-mode { font-size: 0.55rem; letter-spacing: 1px; } .vcr-lcd-pkts { font-size: 0.5rem; letter-spacing: 1px; } - .vcr-scope-btns { order: 3; flex-shrink: 0; gap: 1px; } + .vcr-scope-btns { order: 2; flex-shrink: 0; gap: 1px; } .vcr-mode { display: none; } - /* Timeline takes full width on its own row */ - .vcr-timeline-container { order: 4; width: 100%; flex: none; height: 20px; } - /* #207 — 44px touch targets for VCR buttons */ - .vcr-btn { padding: 4px 8px; font-size: 0.75rem; min-height: 44px; min-width: 44px; } - .vcr-scope-btn { font-size: 0.6rem; padding: 2px 6px; min-height: 44px; min-width: 44px; } + /* #1244: timeline absorbs remaining width on the single row. */ + .vcr-timeline-container { order: 3; flex: 1 1 0; min-width: 40px; height: 28px; width: auto; } + /* #207 — touch targets shrunk to fit single row at 375px. WCAG 2.5.5 + * Level AAA recommends 44px but Level AA accepts 24px; here we keep + * 32px which still meets AA on mobile while letting all controls + * occupy one row at the 375px breakpoint called out in #1244. */ + .vcr-btn { padding: 4px 6px; font-size: 0.7rem; min-height: 32px; min-width: 32px; } + .vcr-scope-btn { font-size: 0.6rem; padding: 2px 5px; min-height: 32px; min-width: 0; } .vcr-prompt { order: 5; width: 100%; font-size: 0.7rem; } } diff --git a/test-issue-1244-live-vcr-row-hints-e2e.js b/test-issue-1244-live-vcr-row-hints-e2e.js new file mode 100644 index 00000000..74bf0631 --- /dev/null +++ b/test-issue-1244-live-vcr-row-hints-e2e.js @@ -0,0 +1,155 @@ +/** + * E2E for #1244 — Live mobile (375x800): + * A) VCR controls must lay out as a single row of children inside + * `.vcr-bar` (no wrap) — all direct children share a common top + * coordinate (within tolerance). + * B) First-visit gesture hints (`.gesture-hint`, "Got it" pills from + * PR #1186) must NOT be present on the /live route — they get + * buried below the VCR bar + safe-area + (potential) bottom nav + * and read as orphan litter. Fixed by disabling gesture hints + * on /live entirely (option (a) in the issue). + * + * Desktop (1280x800) sanity: VCR still renders, no regression to the + * existing single-row desktop layout. + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' \u2713 ' + name); } + catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function gotoLive(page) { + // Reset gesture-hint localStorage so the first-visit pills WOULD fire + // (they're suppressed once "seen"). Otherwise the test is a no-op for + // sub-issue B on a previously-visited fixture. + await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + try { + Object.keys(localStorage) + .filter(k => k.indexOf('meshcore-gesture-hints-') === 0) + .forEach(k => localStorage.removeItem(k)); + } catch (_e) {} + }); + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#vcrBar', { timeout: 10000 }); + // Wait past gesture-hints SHOW_DELAY_MS (800ms) so any hint that + // _would_ render has had its chance to appear. + await page.waitForTimeout(1500); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + + console.log(`\n=== #1244 Live mobile VCR single row + no orphan hints E2E against ${BASE} ===`); + + // ── Mobile 375x800 ────────────────────────────────────────────────────── + { + const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(10000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await step('[375x800] navigate to /live', async () => { await gotoLive(page); }); + + // (A) VCR-bar direct children share one row (no wrap). + await step('[375x800] .vcr-bar direct children share top coordinate (single row, no wrap)', async () => { + const r = await page.evaluate(() => { + const bar = document.getElementById('vcrBar'); + if (!bar) return { found: false }; + const cs = getComputedStyle(bar); + const kids = Array.from(bar.children).filter(el => { + const k = getComputedStyle(el); + if (k.display === 'none' || k.visibility === 'hidden') return false; + if (el.id === 'panelPositionAnnounce') return false; // sr-only sibling + if (el.classList && el.classList.contains('sr-only')) return false; + if (el.id === 'vcrPrompt' && el.classList.contains('hidden')) return false; + const rr = el.getBoundingClientRect(); + return rr.width > 0 && rr.height > 0; + }); + const tops = kids.map(el => Math.round(el.getBoundingClientRect().top)); + const minTop = Math.min.apply(null, tops); + const maxTop = Math.max.apply(null, tops); + return { + found: true, + flexWrap: cs.flexWrap, + flexDirection: cs.flexDirection, + tops, minTop, maxTop, + spread: maxTop - minTop, + kidTags: kids.map(el => (el.tagName + (el.id ? '#' + el.id : '') + '.' + + (typeof el.className === 'string' ? el.className : '')).trim()), + }; + }); + assert(r.found, '#vcrBar element missing'); + // Either nowrap is set (canonical fix) OR all children genuinely share + // the same top (≤8px spread for sub-pixel + line-height jitter). + const sharedRow = r.spread <= 8; + assert(r.flexWrap === 'nowrap' || sharedRow, + '#vcr-bar must lay children out on a single row at 375x800 ' + + '(flexWrap=' + r.flexWrap + ', topSpread=' + r.spread + 'px, ' + + 'tops=' + JSON.stringify(r.tops) + ', children=' + JSON.stringify(r.kidTags) + ')'); + }); + + // (B) No orphan gesture-hint pills on /live. + await step('[375x800] no .gesture-hint pills on /live route (option (a) — disabled on Live)', async () => { + const r = await page.evaluate(() => { + const hints = Array.from(document.querySelectorAll('.gesture-hint, [data-gesture-hint]')); + const vh = window.innerHeight; + return { + count: hints.length, + buried: hints.filter(el => { + const rr = el.getBoundingClientRect(); + return rr.height > 0 && rr.top > vh; // below the fold + }).length, + texts: hints.map(el => (el.textContent || '').trim().slice(0, 60)), + }; + }); + assert(r.count === 0, + 'no .gesture-hint elements may exist on /live (found ' + r.count + + ', buried-below-fold=' + r.buried + ', texts=' + JSON.stringify(r.texts) + ')'); + }); + + await ctx.close(); + } + + // ── Desktop 1280x800 sanity ───────────────────────────────────────────── + { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(10000); + await step('[1280x800] navigate to /live', async () => { await gotoLive(page); }); + + await step('[1280x800] VCR bar renders, controls + LCD still inline (no desktop regression)', async () => { + const r = await page.evaluate(() => { + const bar = document.getElementById('vcrBar'); + const ctrl = document.querySelector('.vcr-controls'); + const lcd = document.querySelector('.vcr-lcd'); + const cr = ctrl.getBoundingClientRect(); + const lr = lcd.getBoundingClientRect(); + // Same row check (vertical overlap). + const sameRow = !(cr.bottom <= lr.top || cr.top >= lr.bottom); + return { hasBar: !!bar, sameRow, ctrl: { top: cr.top, bottom: cr.bottom }, lcd: { top: lr.top, bottom: lr.bottom } }; + }); + assert(r.hasBar, '#vcrBar missing on desktop'); + assert(r.sameRow, + 'desktop regression — controls and LCD must share a row ' + + '(ctrl=' + JSON.stringify(r.ctrl) + ', lcd=' + JSON.stringify(r.lcd) + ')'); + }); + + await ctx.close(); + } + + await browser.close(); + console.log(`\n=== Results: passed ${passed} failed ${failed} ===`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); });