diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d5801be3..42733f05 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -157,6 +157,7 @@ jobs:
node test-a11y-axe-1668-selftest.js
node test-a11y-1716-rf-range-btn-active.js
node test-issue-1705-subpath-contrast.js
+ node test-issue-1770-mobile-row-clamp.js
node test-a11y-axe-routes-coverage.js
- name: 🛡️ Preflight XSS gate — actual --diff check (PR only)
diff --git a/public/packets.js b/public/packets.js
index ff3a7822..f55977a9 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -2252,7 +2252,7 @@
${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)} |
${groupPathStr} |
${p.observation_count > 1 ? ' ' + p.observation_count + '' : (isSingle ? '' : p.count)} |
- ${getDetailPreview(getParsedDecoded(p))} |
+ ${getDetailPreview(getParsedDecoded(p))} |
`;
if (isExpanded && p._children) {
let visibleChildren = p._children;
@@ -2281,7 +2281,7 @@
${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)} |
${childPathStr} |
|
- ${getDetailPreview(getParsedDecoded(c))} |
+ ${getDetailPreview(getParsedDecoded(c))} |
`;
}
}
@@ -2314,7 +2314,7 @@
${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)} |
${pathStr} |
|
- ${detail} |
+ ${detail} |
`;
}
diff --git a/public/style.css b/public/style.css
index 262c8c61..fe964c95 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2433,6 +2433,32 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.data-table .col-time { min-width: 64px; }
.panel-left { overflow-x: auto; }
+ /* #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
+ 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.
+
+ v2 (#1805 follow-up): the clamp lives on an INNER `.col-details-clip`
+ wrapper, not the `` itself. Reason: with `white-space: nowrap` on
+ the td and `table-layout: auto`, the cell's min-content becomes the
+ full single-line text width, blowing past the `max-width: 100px`
+ mobile hint. Rows then grow wider than the 360px viewport — which
+ breaks `test-gestures-1062-e2e.js` (a)/(h) because the swipe coords
+ fall outside the viewport and `elementFromPoint` no longer hits the
+ row. Inline-block + fixed `max-width` on the clip caps the column's
+ preferred width so the row stays viewport-bounded and gestures work. */
+ .data-table td.col-details > .col-details-clip {
+ display: inline-block;
+ max-width: 140px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: bottom;
+ }
+
/* Filters: collapse on mobile */
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
.filter-toggle-btn { display: inline-flex !important; }
diff --git a/test-all.sh b/test-all.sh
index 57e56c78..d51a150c 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -63,6 +63,7 @@ node test-issue-1532-live-fullscreen.js
node test-naive-banner-tone.js
node test-issue-1473-reserved-prefixes.js
node test-issue-1473-prefix-generator.js
+node test-issue-1770-mobile-row-clamp.js
echo ""
echo "═══════════════════════════════════════"
diff --git a/test-issue-1770-mobile-row-clamp.js b/test-issue-1770-mobile-row-clamp.js
new file mode 100644
index 00000000..a1015e8a
--- /dev/null
+++ b/test-issue-1770-mobile-row-clamp.js
@@ -0,0 +1,104 @@
+/**
+ * #1770 — Packets view jitters on mobile because variable row heights
+ * break the virtual-scroller's constant-row-height assumption.
+ *
+ * S quick-fix path: under the mobile breakpoint, clamp .col-details
+ * (the packet "Details" cell in the packets table) to a single line via
+ * white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ * The base rule at line ~1097 currently sets `white-space: normal` which
+ * allows wrapping → variable row heights → virtual-scroll jitter.
+ *
+ * This is a CSS-grep test: it parses public/style.css, locates the
+ * `@media (max-width: 640px)` block used by the packets view on mobile,
+ * and asserts that block declares a `.col-details` clamp rule with
+ * `white-space: nowrap`. Cheap to run, no browser required.
+ *
+ * Pattern borrowed from test-issue-1364-pill-no-clamp.js.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+let passed = 0, failed = 0;
+function assert(cond, msg) {
+ if (cond) { passed++; console.log(' ✓ ' + msg); }
+ else { failed++; console.error(' ✗ ' + msg); }
+}
+
+const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
+
+// Pull the existing mobile breakpoint block. The packets-table mobile
+// overrides (.data-table font-size, .data-table td max-width, etc.) live
+// inside `@media (max-width: 640px)` at ~line 2361.
+// Pull every `@media (max-width: 640px)` block. There are several in
+// style.css (the packets-view block is at ~L2362 but many feature
+// sections add their own); the clamp can live in any of them.
+function extractAllMediaBlocks(src, header) {
+ const out = [];
+ let from = 0;
+ while (true) {
+ const idx = src.indexOf(header, from);
+ if (idx === -1) break;
+ let depth = 0, started = false, end = -1;
+ for (let i = idx; i < src.length; i++) {
+ const c = src[i];
+ if (c === '{') { depth++; started = true; }
+ else if (c === '}') {
+ depth--;
+ if (started && depth === 0) { end = i + 1; break; }
+ }
+ }
+ if (end === -1) break;
+ out.push(src.slice(idx, end));
+ from = end;
+ }
+ return out;
+}
+
+const mobileBlocks = extractAllMediaBlocks(cssSrc, '@media (max-width: 640px)');
+
+console.log('\n=== #1770: .col-details clamps to one line under mobile breakpoint ===');
+
+assert(mobileBlocks.length > 0, '`@media (max-width: 640px)` block(s) found in style.css');
+
+if (mobileBlocks.length > 0) {
+ // Find the .col-details rule body inside any mobile block. Accepts any
+ // selector that includes `.col-details` (e.g. `.col-details { ... }` or
+ // `.data-table td.col-details { ... }` or — post-#1805 — an inner
+ // `.col-details > .col-details-clip` wrapper). The clamp invariant is
+ // what matters; the selector scope was tightened in #1805 to avoid
+ // blowing past `max-width` and breaking row-action gestures (#1062).
+ const ruleRe = /([^{}]*\.col-details\b[^{}]*)\{([^{}]*)\}/g;
+ let clampBody = null;
+ for (const block of mobileBlocks) {
+ let m;
+ ruleRe.lastIndex = 0;
+ while ((m = ruleRe.exec(block)) !== null) {
+ if (/white-space\s*:\s*nowrap/.test(m[2])) { clampBody = m[2]; break; }
+ }
+ if (clampBody) break;
+ }
+
+ assert(
+ !!clampBody,
+ '.col-details mobile rule declares `white-space: nowrap` (single-line clamp)'
+ );
+
+ if (clampBody) {
+ assert(
+ /overflow\s*:\s*hidden/.test(clampBody),
+ '.col-details mobile rule declares `overflow: hidden`'
+ );
+ assert(
+ /text-overflow\s*:\s*ellipsis/.test(clampBody),
+ '.col-details mobile rule declares `text-overflow: ellipsis`'
+ );
+ }
+}
+
+console.log('\n=== Summary ===');
+console.log(' Passed: ' + passed);
+console.log(' Failed: ' + failed);
+console.log('\n#1770 ' + (failed === 0 ? 'PASS' : 'FAIL'));
+process.exit(failed === 0 ? 0 : 1);
|