fix(nav): floor More menu at >=2 items (#1139 Bug B) (#1148)

Partial fix for #1139 — closes Bug B (desktop More menu degenerate). Bug
A (mobile hamburger) blocked on user device info; left for separate PR.

## What this changes

`public/app.js` `applyNavPriority()` (the >1100px measurement branch):
add a "minimum More menu size" floor. After the greedy `fits()` loop
terminates, if exactly one link ended up in `is-overflow`, promote one
more from the overflow queue so the dropdown contains ≥2 items.

```diff
       let i = 0;
       while (!fits() && i < overflowQueue.length) {
         overflowQueue[i].classList.add('is-overflow');
         i++;
       }
+      // #1139 Bug B: floor the More menu at >=2 items.
+      var overflowedCount = allLinks.filter(a => a.classList.contains('is-overflow')).length;
+      if (overflowedCount === 1 && i < overflowQueue.length) {
+        overflowQueue[i].classList.add('is-overflow');
+        i++;
+      }
       rebuildMoreMenu();
```

The ≤1100px Priority+ design contract (5 high-priority + More) is
unchanged; the floor only applies on the measurement branch.

## Why

Above 1100px the measurement loop greedily fills inline links until
something overflows. If exactly one non-priority link is wider than the
remaining slack, the loop pushes only it into overflow and stops —
producing a one-item "More ▾" dropdown. With the fixture stats this
reproduces deterministically at 1600px (overflow=`["🎵 Lab"]`); the
prod report on 1101–1278px is the same root cause with realistic
`#navStats` width consuming most of the remaining slack.

## TDD

- Red: `test-nav-more-floor-1139-e2e.js` sweeps 1101, 1150, 1200,
  1240, 1278, 1280, 1340, 1500, 1600, 1700px and asserts
  `#navMoreMenu.children.length` is 0 or ≥2 — never 1. On master it
  fails at 1600px (`items=1, overflow=[#/audio-lab]`).
- Green: with the floor in place all 10 viewports pass.
- Existing `test-nav-priority-1102-e2e.js` and
  `test-nav-fluid-1055-e2e.js` still pass (5/5 and 20/20).
- Wired into CI alongside the other nav E2E tests.

## Out of scope (Bug A)

The mobile hamburger inert-button report needs a console snapshot from
the affected device (pasted in the issue body) to pin the root cause.
Left open for a follow-up PR. This PR uses "Partial fix" intentionally
and does NOT include `Fixes #1139` so the issue stays open.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
This commit is contained in:
Kpa-clawbot
2026-05-07 06:52:03 -07:00
committed by GitHub
parent f27132e44e
commit 81f95aaabe
3 changed files with 153 additions and 0 deletions
+1
View File
@@ -228,6 +228,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-table-fluid-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
+22
View File
@@ -1065,6 +1065,28 @@ window.addEventListener('DOMContentLoaded', () => {
overflowQueue[i].classList.add('is-overflow');
i++;
}
// #1139 Bug B: floor the More menu at >=2 items. The greedy
// fits() loop above is happy to stop after pushing exactly ONE
// link into overflow (commonly "🎵 Lab" at ~1600px viewports),
// producing a degenerate single-item dropdown. If exactly one
// link overflowed, promote one more from the queue so the user
// sees a useful menu instead of a one-item fragment. Skip when
// nothing overflowed (everything fits inline → More is hidden,
// which is the correct UX) and skip when the queue is exhausted.
var overflowedCount = allLinks.filter(a => a.classList.contains('is-overflow')).length;
if (overflowedCount === 1) {
if (i < overflowQueue.length) {
overflowQueue[i].classList.add('is-overflow');
i++;
} else {
// Defensive: queue exhausted with exactly 1 overflowed link
// means we cannot satisfy the >=2 floor (only one promotable
// link existed). Surface it loudly instead of silently
// shipping the degenerate single-item dropdown the floor
// was added to prevent.
console.warn('[nav] More menu floor: overflowQueue exhausted with 1 item; cannot enforce >=2 floor');
}
}
rebuildMoreMenu();
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/* Issue #1139 Bug B — desktop "More ▾" overflow menu degenerate at 11011278px.
*
* Above 1100px the Priority+ measurement loop in public/app.js
* (applyNavPriority) iteratively pushes one link at a time into the
* overflow set until the inline strip "fits". At intermediate widths
* (~1101-1278px) the loop terminates with exactly ONE link in
* overflow — producing a degenerate "More ▾" dropdown that contains
* just one item (often the active route).
*
* Acceptance for Bug B (band-specific):
* Bug band (1101, 1150, 1200, 1240, 1278px): items >= 2 STRICTLY.
* items===0 here would mean the More menu vanished entirely —
* a different regression class this test must catch.
* Wide band (1280, 1340, 1500, 1600, 1700px): items === 0
* (everything fits inline) OR items >= 2 (overflow with floor).
* In both bands items === 1 is the original Bug B and fails.
*
* This test FAILS on master @ origin/master (>=1 viewport returns 1)
* and PASSES once the floor check is added to applyNavPriority().
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Two bands with DIFFERENT acceptance criteria:
// - bug band (1101-1278px): the user-reported regression range. The
// More menu MUST be present with >=2 items. items===0 here would
// mean the menu vanished entirely (a different regression class
// this test must catch). items===1 is the original Bug B.
// - wide band (>=1280px): everything genuinely fits at some widths
// (items===0 is acceptable) AND items>=2 is acceptable (overflow
// with the floor enforced). items===1 is still the bug.
// Narrower mobile/tablet widths are covered by separate E2E tests.
const BUG_BAND = [1101, 1150, 1200, 1240, 1278];
const WIDE_BAND = [1280, 1340, 1500, 1600, 1700];
const VIEWPORTS = [...BUG_BAND, ...WIDE_BAND];
const HEIGHT = 800;
async function main() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (process.env.CHROMIUM_REQUIRE === '1') {
console.error(`test-nav-more-floor-1139-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-nav-more-floor-1139-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0;
let passes = 0;
try {
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
for (const w of VIEWPORTS) {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-links');
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
// Don't mutate stats text — leave whatever the page rendered. The
// bug reproduces deterministically against fixture stats at ~1600px
// (only "🎵 Lab" overflows → degenerate 1-item More menu) and the
// sweep covers the prod-reported 1101-1278px band as well.
await page.evaluate(() => { window.dispatchEvent(new Event('resize')); });
// Two rAFs to let the rAF-debounced resize handler run + settle.
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForFunction(() => {
const el = document.querySelector('.top-nav .nav-right');
if (!el) return false;
const r1 = el.getBoundingClientRect();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
const r2 = el.getBoundingClientRect();
resolve(r1.right === r2.right && r1.left === r2.left);
}));
});
}, null, { timeout: 5000 });
const data = await page.evaluate(() => {
const menu = document.getElementById('navMoreMenu');
const wrap = document.querySelector('.nav-more-wrap');
const moreVisible = wrap ? getComputedStyle(wrap).display !== 'none' : false;
const items = menu ? menu.children.length : -1;
const overflowInline = Array.from(document.querySelectorAll('.nav-links .nav-link.is-overflow'))
.map(a => a.getAttribute('href'));
return { items, moreVisible, overflowInline };
});
// Band-specific acceptance:
// - bug band (1101-1278px): items >= 2 STRICTLY. items===0
// would mean More vanished (a different regression).
// - wide band (>=1280px): items === 0 OR items >= 2.
// In both bands items === 1 is the original Bug B and fails.
const inBugBand = BUG_BAND.indexOf(w) !== -1;
const ok = inBugBand
? data.items >= 2
: (data.items === 0 || data.items >= 2);
const bandLabel = inBugBand ? 'bug-band' : 'wide-band';
if (ok) {
passes++;
console.log(`${w}px [${bandLabel}]: more menu items=${data.items} moreVisible=${data.moreVisible}`);
} else {
failures++;
const why = inBugBand && data.items === 0
? 'More menu vanished in bug band (regression: should have overflow)'
: `degenerate More menu (items=${data.items})`;
console.log(`${w}px [${bandLabel}]: ${why} overflow=[${data.overflowInline.join(', ')}]`);
}
}
} finally {
// Always close Chromium even if a page step (e.g. waitForFunction
// timeout) throws — otherwise CI leaks browser processes.
try { await browser.close(); } catch (_) { /* already gone */ }
}
console.log(`\ntest-nav-more-floor-1139-e2e.js: ${failures === 0 ? 'OK' : 'FAIL'}${passes}/${VIEWPORTS.length} passed`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch(err => { console.error(err); process.exit(1); });