diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b827d226..7e672815 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -264,6 +264,7 @@ jobs: 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 + BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-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 CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/style.css b/public/style.css index fb302327..7b356b23 100644 --- a/public/style.css +++ b/public/style.css @@ -2170,12 +2170,12 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } } .observer-selector { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; } .node-qr { text-align: center; margin-top: 8px; } -.node-qr svg { max-width: 100px; border-radius: 4px; } +.node-qr svg { max-width: 100px; height: auto; border-radius: 4px; } [data-theme="dark"] .node-qr svg rect[fill="#ffffff"] { fill: var(--card-bg); } [data-theme="dark"] .node-qr svg rect[fill="#000000"] { fill: var(--text); } .node-map-qr-wrap { position: relative; } .node-map-qr-overlay { position: absolute; bottom: 8px; right: 8px; z-index: 400; background: rgba(255,255,255,0.5); border-radius: 4px; padding: 4px; line-height: 0; margin: 0; text-align: center; } -.node-map-qr-overlay svg { max-width: 56px !important; display: block; margin: 0; } +.node-map-qr-overlay svg { max-width: 56px !important; height: auto; display: block; margin: 0; } [data-theme="dark"] .node-map-qr-overlay { background: rgba(255,255,255,0.4); } /* Replay on Live Map button in packet detail */ @@ -2462,7 +2462,8 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .node-top-row { display: flex; gap: 16px; margin-bottom: 12px; } .node-top-row .node-map-wrap { flex: 3; min-height: 200px; border-radius: 8px; overflow: hidden; } .node-top-row .node-map-wrap .node-detail-map { height: 100%; } -.node-top-row .node-qr-wrap { flex: 1; min-width: 120px; max-width: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; } +.node-top-row .node-qr-wrap { flex: 1; align-self: flex-start; min-width: 120px; max-width: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; } +.node-top-row .node-qr-wrap .node-qr { margin-top: 0; } .node-qr-wrap--full { max-width: 240px; margin: 0 auto; } .node-stats-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 12px; } .node-stats-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); } @@ -2500,7 +2501,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } line-height: 0; margin: 0; } - .node-top-row .node-qr-wrap .node-qr svg { max-width: 72px; } + .node-top-row .node-qr-wrap .node-qr svg { max-width: 72px; height: auto; } /* Hide the redundant pubkey caption inside the overlay QR — it's already shown in the card above and would push the overlay too large. */ .node-top-row .node-qr-wrap .mono { display: none; } diff --git a/test-issue-1273-qr-overlay-height-e2e.js b/test-issue-1273-qr-overlay-height-e2e.js new file mode 100644 index 00000000..d8e237f4 --- /dev/null +++ b/test-issue-1273-qr-overlay-height-e2e.js @@ -0,0 +1,125 @@ +/** + * #1273 — QR overlay container is 2-3× taller than the QR canvas on the + * full node detail page (`#/nodes/`). + * + * On mobile (<=640px) and desktop, `.node-top-row .node-qr-wrap` (the QR + * overlay box) must NOT have meaningful empty translucent space below the + * QR canvas. The wrap's bounding-rect height must be ≤ the inner QR + * canvas/svg height + 32px (covers padding + caption + minor rounding). + * + * Asserted on: + * - 375x800 (mobile — overlay style applies) + * - 1280x800 (desktop guard — existing flex layout must not regress) + * + * RED on master (mobile): the absolute-positioned overlay inherits the + * column flex layout with `justify-content: center` and the caption hidden + * but still allocates space because the wrap doesn't have a content-fit + * height. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-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 pickPubkey(page) { + await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' }); + return await page.evaluate(async () => { + const r = await fetch('/api/nodes?limit=20'); + const d = await r.json(); + return (d.nodes || [])[0] && (d.nodes || [])[0].public_key; + }); +} + +async function measureOverlay(page, pubkey) { + await page.goto(BASE + '/#/nodes/' + encodeURIComponent(pubkey), + { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.node-top-row .node-qr-wrap', { timeout: 10000 }); + // Wait until the QR svg is actually painted inside the wrap. + await page.waitForFunction(() => { + const wrap = document.querySelector('.node-top-row .node-qr-wrap'); + return wrap && wrap.querySelector('.node-qr svg'); + }, { timeout: 10000 }); + await page.waitForTimeout(150); // allow layout to settle + return await page.evaluate(() => { + const wrap = document.querySelector('.node-top-row .node-qr-wrap'); + const svg = wrap.querySelector('.node-qr svg'); + const wr = wrap.getBoundingClientRect(); + const sr = svg.getBoundingClientRect(); + const cap = wrap.querySelector('.mono'); + const capH = cap && getComputedStyle(cap).display !== 'none' + ? Math.round(cap.getBoundingClientRect().height) : 0; + // QR is always square — the visible/intended QR height is the SMALLER + // of svg width vs svg height. Any "extra" svg height beyond that is + // wasted intrinsic-sizing space that bloats the wrap. + const qrVisibleH = Math.min(Math.round(sr.width), Math.round(sr.height)); + return { + wrapH: Math.round(wr.height), + wrapW: Math.round(wr.width), + svgH: Math.round(sr.height), + svgW: Math.round(sr.width), + qrVisibleH, + capH, + position: getComputedStyle(wrap).position, + top: Math.round(wr.top), + right: Math.round(window.innerWidth - wr.right), + }; + }); +} + +(async () => { + const launchOpts = { headless: true, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] }; + if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH; + const browser = await chromium.launch(launchOpts); + + console.log(`\n=== #1273 QR overlay height E2E against ${BASE} ===`); + + // ── pick a real pubkey from the API ── + const ctxBoot = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const probe = await ctxBoot.newPage(); + const pubkey = await pickPubkey(probe); + await ctxBoot.close(); + assert(pubkey, 'No pubkey returned from /api/nodes'); + console.log(' → probe pubkey: ' + pubkey.slice(0, 12) + '…'); + + // ── Mobile 375x800 ── + const m = await browser.newContext({ viewport: { width: 375, height: 800 } }); + const mp = await m.newPage(); + await step('mobile 375x800: .node-qr-wrap height \u2264 visible QR + 32px', async () => { + const d = await measureOverlay(mp, pubkey); + console.log(' mobile measurements: ' + JSON.stringify(d)); + assert(d.qrVisibleH > 0, 'QR svg has zero visible square (not rendered)'); + assert(d.position === 'absolute', + 'mobile overlay must remain position:absolute, got ' + d.position); + assert(d.wrapH <= d.qrVisibleH + d.capH + 32, + `wrap height ${d.wrapH}px must be \u2264 visible QR ${d.qrVisibleH}px + caption ${d.capH}px + 32px (delta ${d.wrapH - d.qrVisibleH - d.capH}px)`); + }); + await m.close(); + + // ── Desktop 1280x800 (regression guard) ── + const dctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const dp = await dctx.newPage(); + await step('desktop 1280x800: .node-qr-wrap height \u2264 visible QR + caption + 32px', async () => { + const d = await measureOverlay(dp, pubkey); + console.log(' desktop measurements: ' + JSON.stringify(d)); + assert(d.qrVisibleH > 0, 'QR svg has zero visible square (not rendered)'); + assert(d.wrapH <= d.qrVisibleH + d.capH + 32, + `wrap height ${d.wrapH}px must be \u2264 visible QR ${d.qrVisibleH}px + caption ${d.capH}px + 32px (delta ${d.wrapH - d.qrVisibleH - d.capH}px)`); + }); + await dctx.close(); + + await browser.close(); + + console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' + + (failed ? ', ' + failed + ' failed' : '')); + process.exit(failed > 0 ? 1 : 0); +} +)().catch(err => { console.error('Fatal:', err); process.exit(1); });