From 5421a680a25db0b5e0e8c8b10d2438b543407c9e Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 18 Sep 2026 16:31:46 +0200 Subject: [PATCH 1/4] fix(packets): keep the Details summary on one line at every width The packets "Details" cell is a summary; the full message is shown in the detail pane (desktop), SlideOver (<=1023px) or the mobile bottom sheet. On desktop/tablet the cell was allowed to wrap, so whenever table-layout:auto narrowed the column the row grew to 2-4 lines. That breaks the bounded row height asserted by test-issue-1122-packets-filter-ux-e2e.js (seen on Linux CI at 1400px once the fixture was ~11.5 min old: 69/84px rows) and the virtual scroller's constant-row-height assumption (#1770). The same wrapping happens deterministically at 1030-1280px and 900px widths. Clamp the inner .col-details-clip to one line with line-clamp. The td keeps white-space:normal, so column intrinsic widths -- and therefore the table layout -- are unchanged (the reason #1805 moved the mobile clamp to the inner wrapper). The existing <=640px inline-block/nowrap clamp still overrides it on mobile. No JS, data or test-threshold changes. Adds test-issue-1122-details-row-clamp-e2e.js (desktop 1200, tablet 900, mobile 375): overflowing summaries stay on one line, rows stay < 60px, the full message is visible in the detail surface, and Details links stay hit-testable. Registered in the E2E step after the #1122 test. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 1 + public/style.css | 15 ++ test-issue-1122-details-row-clamp-e2e.js | 186 +++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 test-issue-1122-details-row-clamp-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8fbdff0bc..f8e046c7b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -442,6 +442,7 @@ jobs: BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1122-details-row-clamp-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/style.css b/public/style.css index 6642eb74d..4be47ab0a 100644 --- a/public/style.css +++ b/public/style.css @@ -1095,6 +1095,21 @@ img.brand-logo { max-width: 0; /* forces td to respect table width instead of expanding to content */ } .data-table td.col-details { white-space: normal; word-break: break-word; max-width: 480px; } +/* #1122/#1124: the packets "Details" cell is a one-line summary (the full + * message is in the detail pane / SlideOver). Letting it wrap made rows taller + * whenever table-layout:auto narrowed the column (e.g. 1030-1280px desktop + * widths), breaking the bounded row height and the virtual scroller's + * constant-row-height assumption (#1770). Clamp the INNER .col-details-clip to + * one line; the td keeps white-space:normal so the column's intrinsic widths + * (and thus the table layout) are unchanged -- see the #1805 note in the + * mobile block, whose inline-block/nowrap clip still overrides this. */ +.data-table td.col-details > .col-details-clip { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + line-clamp: 1; + overflow: hidden; +} /* Direct Neighbors' Configured Scope cell (observer-detail.js) can hold a * long comma-joined scope list (e.g. a #eu/#dk/#dk-sjl/... catch-all * observer) -- the generic max-width:0 ellipsis rule above truncated it diff --git a/test-issue-1122-details-row-clamp-e2e.js b/test-issue-1122-details-row-clamp-e2e.js new file mode 100644 index 000000000..18e49a3ed --- /dev/null +++ b/test-issue-1122-details-row-clamp-e2e.js @@ -0,0 +1,186 @@ +/** + * E2E (#1122/#1124 follow-up): the packets "Details" cell must not make rows + * taller by wrapping when table-layout:auto narrows its column. + * + * Background: test-issue-1122-packets-filter-ux-e2e.js asserts every path cell + * (= its row) stays < 60px at 1400px. On Linux CI that started failing once + * the fixture was ~11.5 min old: the auto table layout narrowed Details from + * ~184px to ~135px and long channel messages wrapped to 2-3 lines (69/84px). + * The same wrapping happens deterministically at ordinary desktop/tablet + * widths (e.g. 1030-1280px, 900px), so this test controls the column width + * via the viewport instead of waiting for fixture age. + * + * Contract asserted at each viewport: + * A. Rows whose Details text is longer than the column (proven present, so + * the test cannot pass vacuously) render the Details summary on ONE line, + * and every packet row stays < 60px. + * B. The full message stays reachable: selecting a long channel-message row + * shows the complete decoded text in a visible detail surface (#pktRight + * on desktop, SlideOver <=1023px, bottom sheet on small mobile). + * C. Links inside Details (advert node links) stay visible and hit-testable. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1122-details-row-clamp-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOT_DIR = 'e2e-screenshots'; + +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(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const VIEWPORTS = [ + { w: 1200, h: 900, name: 'desktop-1200' }, // Details column ~176px: wraps without the clamp + { w: 900, h: 1024, name: 'tablet-900' }, // above the 640px mobile clamp, below SlideOver BP + { w: 375, h: 812, name: 'mobile-375' }, // repo mobile convention (#1668 M6) +]; + +async function gotoPackets(page) { + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#packetFilterInput', { state: 'attached', timeout: 8000 }); + await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 }); + // All time, so the fixture's long channel messages are rendered (same as #1122). + await page.evaluate(() => { + const sel = document.getElementById('fTimeWindow'); + if (sel) { sel.value = '0'; sel.dispatchEvent(new Event('change', { bubbles: true })); } + }); + await page.waitForFunction( + () => document.querySelectorAll('#pktBody tr[data-hash] td.col-details .col-details-clip').length > 20, + { timeout: 8000 }); + await page.evaluate(() => document.fonts.ready); +} + +// Measures every rendered packet row. `overflowing` = rows whose Details text, +// laid out on a single line in the clip's own font, is wider than the cell's +// content box -- i.e. rows that WOULD wrap without a one-line clamp. +function measureRows() { + const ctx = document.createElement('canvas').getContext('2d'); + const rows = [...document.querySelectorAll('#pktBody tr[data-hash]')] + .filter(r => r.getBoundingClientRect().height > 0); + return rows.map(r => { + const td = r.querySelector('td.col-details'); + const clip = td && td.querySelector('.col-details-clip'); + if (!clip) return null; + const cs = getComputedStyle(clip); + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const tdcs = getComputedStyle(td); + const contentW = td.clientWidth - parseFloat(tdcs.paddingLeft) - parseFloat(tdcs.paddingRight); + const textW = ctx.measureText(clip.textContent.replace(/\s+/g, ' ').trim()).width; + const lineH = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2; + // Line boxes the clip actually occupies (works for inline and block clips). + const lineTops = new Set([...clip.getClientRects()].map(q => Math.round(q.top))); + return { + hash: r.getAttribute('data-hash'), + rowH: r.getBoundingClientRect().height, + clipH: clip.getBoundingClientRect().height, + lineH, lines: lineTops.size, textW: Math.round(textW), contentW: Math.round(contentW), + overflowing: textW > contentW + 1, + text: clip.textContent.trim().slice(0, 60), + }; + }).filter(Boolean); +} + +(async () => { + if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true }); + 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=== #1122 Details row clamp E2E against ${BASE} ===`); + + for (const vp of VIEWPORTS) { + const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + let rows = []; + await step(`[${vp.name}] navigate to /packets`, async () => { + await gotoPackets(page); + rows = await page.evaluate(measureRows); + assert(rows.length > 0, 'no packet rows rendered'); + }); + + await step(`[${vp.name}] long Details text is present (test is not vacuous)`, async () => { + const over = rows.filter(r => r.overflowing); + assert(over.length > 0, + 'no row has Details text wider than its column -- cannot exercise the clamp. Sample: ' + + JSON.stringify(rows.slice(0, 3))); + }); + + await step(`[${vp.name}] overflowing Details summaries stay on one line`, async () => { + const bad = rows.filter(r => r.overflowing && (r.lines > 1 || r.clipH > r.lineH * 1.5)); + assert(bad.length === 0, + `${bad.length} Details summaries wrap: ` + JSON.stringify(bad.slice(0, 4))); + }); + + await step(`[${vp.name}] every packet row stays < 60px`, async () => { + const tall = rows.filter(r => r.rowH >= 60); + assert(tall.length === 0, + `${tall.length} rows >= 60px: ` + JSON.stringify(tall.slice(0, 4).map(r => ({ h: r.rowH, text: r.text })))); + }); + + await step(`[${vp.name}] advert links in Details stay visible and clickable`, async () => { + const res = await page.evaluate(() => { + const links = [...document.querySelectorAll('#pktBody td.col-details .col-details-clip a.hop-link')]; + for (const a of links) { + const r = a.getBoundingClientRect(); + if (r.width < 2 || r.height < 2 || r.top < 0 || r.bottom > innerHeight) continue; + const td = a.closest('td').getBoundingClientRect(); + const x = Math.min(r.left + 4, td.right - 2), y = r.top + r.height / 2; + const hit = document.elementFromPoint(x, y); + return { found: true, hitIsLink: !!(hit && (hit === a || a.contains(hit))), text: a.textContent }; + } + return { found: false, count: links.length }; + }); + assert(res.found, 'no visible advert link in Details to check (' + JSON.stringify(res) + ')'); + assert(res.hitIsLink, 'advert link in Details is not hit-testable: ' + JSON.stringify(res)); + }); + + await step(`[${vp.name}] full message is shown when a long row is selected`, async () => { + // Pick an overflowing channel-message row and fetch its full decoded text. + const target = await page.evaluate(async (hashes) => { + for (const h of hashes) { + const res = await fetch('/api/packets/' + h); + if (!res.ok) continue; + const data = await res.json(); + const pkt = data && data.packet; + let d = pkt && pkt.decoded_json; + if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { d = null; } } + if (d && d.type === 'CHAN' && typeof d.text === 'string' && d.text.length > 20) return { hash: h, text: d.text }; + } + return null; + }, rows.filter(r => r.overflowing).map(r => r.hash)); + assert(target, 'no overflowing channel-message row with decoded text found'); + const row = page.locator(`#pktBody tr[data-hash="${target.hash}"]`).first(); + await row.scrollIntoViewIfNeeded(); + await row.click({ position: { x: 60, y: 10 } }); + // The full text must be VISIBLE in a detail surface (never the table): + // desktop split pane, SlideOver (<=1023px) or the small-mobile bottom + // sheet (which shows it in its header summary, #1471). innerText only + // includes rendered text, so display:none copies do not count. + await page.waitForFunction((t) => { + const norm = (s) => s.replace(/\s+/g, ' '); + return ['#pktRight', '.slide-over-panel', '#mobileDetailSheet.open'] + .map(sel => document.querySelector(sel)) + .some(el => el && el.getBoundingClientRect().width > 0 && norm(el.innerText).includes(norm(t))); + }, target.text, { timeout: 8000 }); + await page.screenshot({ path: path.join(SHOT_DIR, `issue-1122-details-clamp-${vp.name}.png`), fullPage: false }); + }); + + 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); }); From 78d8d1153f4a40fcc6f39680db8ffa7579cd7c6c Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 18 Sep 2026 16:53:42 +0200 Subject: [PATCH 2/4] fix(packets): keep focus rings and chan tags visible in the clamped Details Review follow-up to the one-line Details clamp. overflow:hidden on a one-line box clipped the 2px focus-visible outline (+2px offset) of advert links and the bottom of .chan-tag pills. Give the clip 4px block padding offset by a -4px block margin: room for the ring and pill padding, row heights unchanged (verified at 375-1920px against the previous commit). Test: judge wrapping by the clip's content-box height for every row (the getClientRects() line count could never exceed 1 for a block clip), and select rows by clicking the Time cell instead of a fixed x offset that landed in the expand column. Clarify the #1770 note in the mobile block. Co-Authored-By: Claude Opus 5 --- public/style.css | 7 ++++++- test-issue-1122-details-row-clamp-e2e.js | 23 +++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/public/style.css b/public/style.css index 4be47ab0a..d922ac721 100644 --- a/public/style.css +++ b/public/style.css @@ -1109,6 +1109,10 @@ img.brand-logo { -webkit-line-clamp: 1; line-clamp: 1; overflow: hidden; + /* Give the clip box room for focus rings (2px outline + 2px offset) and + * .chan-tag padding without changing the row height. */ + padding-block: 4px; + margin-block: -4px; } /* Direct Neighbors' Configured Scope cell (observer-detail.js) can hold a * long comma-joined scope list (e.g. a #eu/#dk/#dk-sjl/... catch-all @@ -2487,7 +2491,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* #1770: packets-view virtual-scroller assumes constant row height (VSCROLL_ROW_HEIGHT). The base rule at ~L1097 sets `.data-table td.col-details { white-space: normal; word-break: break-word }` - which lets the "Details" cell wrap on narrow viewports, producing + (since #1122 the inner clip is line-clamped to one line at every width, + see the rule right after it) which let the "Details" cell wrap on narrow viewports, producing variable row heights → visible jitter when scrolling past ~900px on iOS. Quick-fix (S path): clamp the cell to a single line on mobile. The proper L-path fix (per-row measurement) tracks separately. diff --git a/test-issue-1122-details-row-clamp-e2e.js b/test-issue-1122-details-row-clamp-e2e.js index 18e49a3ed..1802353bf 100644 --- a/test-issue-1122-details-row-clamp-e2e.js +++ b/test-issue-1122-details-row-clamp-e2e.js @@ -74,13 +74,17 @@ function measureRows() { const contentW = td.clientWidth - parseFloat(tdcs.paddingLeft) - parseFloat(tdcs.paddingRight); const textW = ctx.measureText(clip.textContent.replace(/\s+/g, ' ').trim()).width; const lineH = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2; - // Line boxes the clip actually occupies (works for inline and block clips). - const lineTops = new Set([...clip.getClientRects()].map(q => Math.round(q.top))); + // Visible height of the summary: the clip's content box (bounding box minus + // its vertical padding). A wrapped summary makes this a multiple of the + // line height; a one-line clamp keeps it at ~1 line. (getClientRects() is + // not used: a block-level clip always reports a single rect.) + const clipBox = clip.getBoundingClientRect(); + const clipH = clipBox.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); return { hash: r.getAttribute('data-hash'), rowH: r.getBoundingClientRect().height, - clipH: clip.getBoundingClientRect().height, - lineH, lines: lineTops.size, textW: Math.round(textW), contentW: Math.round(contentW), + clipH, + lineH, textW: Math.round(textW), contentW: Math.round(contentW), overflowing: textW > contentW + 1, text: clip.textContent.trim().slice(0, 60), }; @@ -117,10 +121,12 @@ function measureRows() { JSON.stringify(rows.slice(0, 3))); }); - await step(`[${vp.name}] overflowing Details summaries stay on one line`, async () => { - const bad = rows.filter(r => r.overflowing && (r.lines > 1 || r.clipH > r.lineH * 1.5)); + await step(`[${vp.name}] Details summaries stay on one line`, async () => { + // Every row, not just the overflowing ones; the overflowing ones are the + // rows that would wrap without the clamp (proven present above). + const bad = rows.filter(r => r.clipH > r.lineH * 1.5); assert(bad.length === 0, - `${bad.length} Details summaries wrap: ` + JSON.stringify(bad.slice(0, 4))); + `${bad.length} Details summaries wrap (${bad.filter(r => r.overflowing).length} overflowing): ` + JSON.stringify(bad.slice(0, 4))); }); await step(`[${vp.name}] every packet row stays < 60px`, async () => { @@ -163,7 +169,8 @@ function measureRows() { assert(target, 'no overflowing channel-message row with decoded text found'); const row = page.locator(`#pktBody tr[data-hash="${target.hash}"]`).first(); await row.scrollIntoViewIfNeeded(); - await row.click({ position: { x: 60, y: 10 } }); + // Click a plain data cell (not the expand column, not a link). + await row.locator('td.col-time').click(); // The full text must be VISIBLE in a detail surface (never the table): // desktop split pane, SlideOver (<=1023px) or the small-mobile bottom // sheet (which shows it in its header summary, #1471). innerText only From 453bb671f450a66b7cc0941458e22da84f497457 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 18 Sep 2026 17:10:47 +0200 Subject: [PATCH 3/4] fix(packets): replace clip padding with inset focus ring and 18px line box The 4px block padding added for focus rings exposed the top 4px of the clamped (hidden) second line below the summary: slivers of text/pills under long Details. overflow:hidden clips at the padding box, and any room below line 1 is where line 2 lives, so padding cannot work. Instead: - draw focus rings of links inside the Details clip inset (outline-offset -2px); the link box always fits in the one-line clip box; - above 768px, where the Path chips already make rows ~30px, give the clip an 18px line box so baseline-aligned 17px .chan-tag pills are not cut. Row heights are unchanged at 375-1920px vs the first clamp commit. At 641-768px pills keep the 1px bottom cut they already have at <=640px. Co-Authored-By: Claude Opus 5 --- public/style.css | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/public/style.css b/public/style.css index d922ac721..f4f209aaa 100644 --- a/public/style.css +++ b/public/style.css @@ -1109,10 +1109,16 @@ img.brand-logo { -webkit-line-clamp: 1; line-clamp: 1; overflow: hidden; - /* Give the clip box room for focus rings (2px outline + 2px offset) and - * .chan-tag padding without changing the row height. */ - padding-block: 4px; - margin-block: -4px; +} +/* The one-line clip box is only a line box tall, so anything drawn outside + * it is clipped (padding would expose the top of the hidden 2nd line). + * Draw link focus rings inside the link box instead of around it... */ +.data-table td.col-details > .col-details-clip a:focus-visible { outline-offset: -2px; } +/* ...and give the line box room for .chan-tag pills (17px, baseline-aligned) + * where that is height-neutral: above 768px the Path chips already make rows + * ~30px tall. (<=640px keeps the #1805 inline-block clip below.) */ +@media (min-width: 769px) { + .data-table td.col-details > .col-details-clip { line-height: 18px; } } /* Direct Neighbors' Configured Scope cell (observer-detail.js) can hold a * long comma-joined scope list (e.g. a #eu/#dk/#dk-sjl/... catch-all @@ -2354,7 +2360,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } answer. data-priority="high" links are pinned to the left via order:-1 and are kept inline as long as possible (low-priority links overflow first). Spacing/type still use #1054 fluid clamp() tokens. */ -@media (min-width: 768px) { +@media (min-width: 769px) { .nav-links { display: flex !important; flex-direction: row; gap: var(--space-xs); } .nav-more-wrap { display: flex; align-items: center; } .hamburger { display: none; } @@ -2491,8 +2497,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* #1770: packets-view virtual-scroller assumes constant row height (VSCROLL_ROW_HEIGHT). The base rule at ~L1097 sets `.data-table td.col-details { white-space: normal; word-break: break-word }` - (since #1122 the inner clip is line-clamped to one line at every width, - see the rule right after it) which let the "Details" cell wrap on narrow viewports, producing + which let the "Details" cell wrap on narrow viewports (since #1122 the + inner clip is also line-clamped at wider widths, see ~L1106), producing variable row heights → visible jitter when scrolling past ~900px on iOS. Quick-fix (S path): clamp the cell to a single line on mobile. The proper L-path fix (per-row measurement) tracks separately. From ee691bba58467b3d364c7ecc4ac44332ced982d4 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 18 Sep 2026 17:23:49 +0200 Subject: [PATCH 4/4] fix(style): restore the Priority+ nav breakpoint changed by accident The previous commit's 768px -> 769px substitution also hit the unrelated Priority+ nav block. Restore it to (min-width: 768px); only the Details line-box rule uses 769px. Co-Authored-By: Claude Opus 5 --- public/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index f4f209aaa..ac1a9aadf 100644 --- a/public/style.css +++ b/public/style.css @@ -2360,7 +2360,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } answer. data-priority="high" links are pinned to the left via order:-1 and are kept inline as long as possible (low-priority links overflow first). Spacing/type still use #1054 fluid clamp() tokens. */ -@media (min-width: 769px) { +@media (min-width: 768px) { .nav-links { display: flex !important; flex-direction: row; gap: var(--space-xs); } .nav-more-wrap { display: flex; align-items: center; } .hamburger { display: none; }