fix(#1273): collapse QR overlay wrap to content height (#1277)

## Summary
Fixes #1273 — `.node-top-row .node-qr-wrap` was 2-3× taller than the QR
canvas inside it, leaving empty translucent space below the QR.

## Root cause
Three compounding issues:

1. **SVG intrinsic height not constrained.** `qrcode-generator` emits an
SVG with fixed `width`/`height` attributes (e.g. 147×147). The CSS rule
`.node-qr svg { max-width: 100px }` (and 72px mobile) constrains *width*
only, so the svg's intrinsic height (147px) is preserved and the wrap is
sized to that.
2. **Flex stretch.** `.node-top-row` is `display:flex` with default
`align-items:stretch`, so the QR column was forced to match the map
column's height (~280px) on desktop.
3. **Excess padding/margin** added another ~24px above and below the
visible QR.

## Fix
Three small CSS changes in `public/style.css`:

| change | effect |
|---|---|
| `.node-qr svg { height: auto; }` | svg height scales with constrained
width |
| `.node-top-row .node-qr-wrap { align-self: flex-start; }` | wrap sizes
to content, not column |
| `.node-top-row .node-qr-wrap { padding: 8px; }` + zero inner
`.node-qr` margin-top | tight hug |

## Measurements (real-data fixture, full node detail page)

| viewport | wrap.height before | wrap.height after | QR canvas |
|---|---|---|---|
| 375×800 (mobile overlay) | 165px | **82px** | 72×72 |
| 1280×800 (desktop side-by-side) | 217px | **154px** | 100×100 (+ 28px
caption) |

Overlay remains `position:absolute` top-right on mobile; the original
#1243 behavior is preserved.

## TDD
- **RED**: `test-issue-1273-qr-overlay-height-e2e.js` asserts wrap
height ≤ visible QR + caption + 32px at 375×800 and 1280×800. Failed on
master with deltas of 93px (mobile) and 89px (desktop).
- **GREEN**: both viewports pass after the CSS fix.

Wired into the deploy workflow alongside the other `test-issue-*-e2e.js`
runs.

## Acceptance checklist
- [x] Container height ≈ QR canvas height + 16-24px padding total
- [x] No empty translucent space below the QR
- [x] E2E asserts at 375×800 and 1280×800
- [x] Desktop layout unchanged (overlay position preserved; column no
longer stretches but the QR card is the same width)
- [x] All colors via CSS variables
- [x] #1243 overlay behavior preserved (still top-right on mobile, still
rendered)

## Commits
- `e9d75c92` test(#1273): RED
- `13899270` fix(#1273): collapse QR overlay wrap

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-18 22:51:29 -07:00
committed by GitHub
co-authored by openclaw-bot
parent 21b6eb0d63
commit c1d94f7db5
3 changed files with 131 additions and 4 deletions
+1
View File
@@ -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
+5 -4
View File
@@ -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; }
+125
View File
@@ -0,0 +1,125 @@
/**
* #1273 QR overlay container is 2-3× taller than the QR canvas on the
* full node detail page (`#/nodes/<pubkey>`).
*
* 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); });