mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 15:25:02 +00:00
## Summary Fixes #1102 — regression from PR #1097 polish where Priority+ collapsed too aggressively at wide widths and the "More" menu didn't reflect what was actually hidden. ## Root cause Two bugs, one root: the post-#1097 CSS rule ```css .nav-links a:not([data-priority="high"]) { display: none; } ``` unconditionally hid 6 of 11 links at every width ≥768px — including 2560px where everything fits comfortably. The "More" menu populator (`querySelectorAll('.nav-links a:not([data-priority="high"])')`) ran exactly once on load against that same selector, so it always held the same 6 links and never reflected the actual viewport fit. ## Fix Replace the static CSS hide with a JS measurement pass (`applyNavPriority` in `public/app.js`): 1. Start with **all** links visible inline. 2. Compute `needed = brand + gutters + visible-links + More + nav-right + safety` and compare to `window.innerWidth`. 3. If it doesn't fit, mark the rightmost (lowest-priority) link `.is-overflow` and re-measure. Repeat. High-priority links are queued last so they're kept visible as long as possible. 4. Rebuild the "More ▾" menu from whatever currently has `.is-overflow`. When nothing overflows, hide the More wrap entirely (`.is-hidden`). 5. Re-run on resize (rAF-debounced), on `hashchange` (active-link padding shifts), and after fonts load. Why JS, not CSS: the breakpoint where each link drops depends on label width, gutters, active-link padding, and the nav-stats badge — none of which are addressable from a media query. ## TDD trail - Red commit `8507756`: `test-nav-priority-1102-e2e.js` — fails 2/4 (2560 and 1920 only show 5/11). - Green commit `3e84736`: implementation — passes 4/4. ## E2E `test-nav-priority-1102-e2e.js` asserts: - 2560px → all 11 visible, More hidden - 1920px → ≥9 visible - 1080px → 5+ visible AND More menu contains every hidden link - 800px → 5+ visible AND More menu non-empty Local run on the e2e fixture: **4/4 pass**. Existing `test-nav-fluid-1055-e2e.js` also stays green: **20/20 pass** (no overlap, no overflow at 768/1024/1280/1440/1920 across 4 routes). --------- Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
+103
-9
@@ -917,18 +917,112 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
link.addEventListener('click', closeNav);
|
||||
});
|
||||
|
||||
// --- "More" dropdown (tablet Priority+ nav) ---
|
||||
// --- "More" dropdown — JS-driven Priority+ (Issue #1102) ---
|
||||
const navMoreBtn = document.getElementById('navMoreBtn');
|
||||
const navMoreMenu = document.getElementById('navMoreMenu');
|
||||
if (navMoreBtn && navMoreMenu) {
|
||||
// Build More menu dynamically from non-priority nav links (DRY)
|
||||
navMoreMenu.innerHTML = '';
|
||||
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
|
||||
var clone = link.cloneNode(true);
|
||||
clone.setAttribute('role', 'menuitem');
|
||||
clone.addEventListener('click', closeMoreMenu);
|
||||
navMoreMenu.appendChild(clone);
|
||||
const navMoreWrap = document.querySelector('.nav-more-wrap');
|
||||
if (navMoreBtn && navMoreMenu && navMoreWrap) {
|
||||
// Measure available room and decide which links overflow.
|
||||
// Algorithm: try to fit all links inline. If the link strip doesn't
|
||||
// fit alongside .nav-right + .nav-brand, hide non-priority links one
|
||||
// at a time (right-to-left, lowest priority first) until it does.
|
||||
// Then mirror the hidden links into the "More ▾" menu so nothing
|
||||
// disappears from the user's reach.
|
||||
const navTop = document.querySelector('.top-nav');
|
||||
const navLeft = document.querySelector('.nav-left');
|
||||
const navRightEl = document.querySelector('.nav-right');
|
||||
const linksContainer = document.querySelector('.nav-links');
|
||||
const allLinks = Array.from(linksContainer.querySelectorAll('.nav-link'));
|
||||
// High-priority links last in the overflow queue (kept visible).
|
||||
// Source order is preserved; only `order:-1` flips render order.
|
||||
const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high')
|
||||
.reverse() // right-to-left
|
||||
.concat(allLinks.filter(a => a.dataset.priority === 'high').reverse());
|
||||
|
||||
function rebuildMoreMenu() {
|
||||
navMoreMenu.innerHTML = '';
|
||||
const hidden = allLinks.filter(a => a.classList.contains('is-overflow'));
|
||||
hidden.forEach(function(link) {
|
||||
var clone = link.cloneNode(true);
|
||||
// The clone is in the overflow menu, not the inline strip.
|
||||
clone.classList.remove('is-overflow');
|
||||
clone.setAttribute('role', 'menuitem');
|
||||
clone.addEventListener('click', closeMoreMenu);
|
||||
navMoreMenu.appendChild(clone);
|
||||
});
|
||||
// If nothing overflows, hide the More button entirely so wide
|
||||
// viewports don't show a useless dropdown trigger.
|
||||
navMoreWrap.classList.toggle('is-hidden', hidden.length === 0);
|
||||
// Refresh active state on the More button (a hidden active link
|
||||
// means the More menu currently "is" the active section).
|
||||
var hasActiveMore = navMoreMenu.querySelector('.nav-link.active');
|
||||
navMoreBtn.classList.toggle('active', !!hasActiveMore);
|
||||
}
|
||||
|
||||
function applyNavPriority() {
|
||||
// Skip on mobile (<768px) — hamburger CSS owns that layout.
|
||||
if (window.innerWidth < 768) {
|
||||
allLinks.forEach(a => a.classList.remove('is-overflow'));
|
||||
navMoreWrap.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
// Reset: show everything, then hide as needed.
|
||||
allLinks.forEach(a => a.classList.remove('is-overflow'));
|
||||
navMoreWrap.classList.remove('is-hidden');
|
||||
// Iteratively hide low-priority links until the link strip fits.
|
||||
// .top-nav has overflow:hidden and .nav-left has flex-shrink:1, so
|
||||
// an overflowing strip silently clips rather than pushing
|
||||
// nav-right out — bounding-rect math on .nav-left lies. Instead
|
||||
// measure the *intrinsic* widths of the parts (independent of
|
||||
// current clipping) and compare to the viewport. SAFETY absorbs
|
||||
// the .top-nav side padding + nav-right inner gaps + sub-pixel
|
||||
// rounding (the historic #1055 bug was a 6–20px overlap).
|
||||
const navBrand = document.querySelector('.nav-brand');
|
||||
const GUTTER = 24; // matches .nav-left gap (--space-lg)
|
||||
const SAFETY = 32;
|
||||
function fits() {
|
||||
const visibleLinks = allLinks.filter(a => !a.classList.contains('is-overflow'));
|
||||
let linkW = 0;
|
||||
visibleLinks.forEach(a => { linkW += a.getBoundingClientRect().width; });
|
||||
const gapPx = parseFloat(getComputedStyle(linksContainer).columnGap ||
|
||||
getComputedStyle(linksContainer).gap || '0');
|
||||
const linksGap = Math.max(0, visibleLinks.length - 1) * gapPx;
|
||||
const brandW = navBrand ? navBrand.getBoundingClientRect().width : 0;
|
||||
// Always reserve space for the More button if anything could
|
||||
// overflow — measure it as if visible (use offsetWidth which
|
||||
// is 0 when display:none, so we add a fixed reserve fallback).
|
||||
const moreVis = !navMoreWrap.classList.contains('is-hidden');
|
||||
const moreW = moreVis ? navMoreWrap.getBoundingClientRect().width : 70;
|
||||
const rightW = navRightEl.scrollWidth; // intrinsic, ignores clipping
|
||||
const needed = brandW + GUTTER + linkW + linksGap + GUTTER + moreW + GUTTER + rightW + SAFETY;
|
||||
return needed <= window.innerWidth;
|
||||
}
|
||||
let i = 0;
|
||||
while (!fits() && i < overflowQueue.length) {
|
||||
overflowQueue[i].classList.add('is-overflow');
|
||||
i++;
|
||||
}
|
||||
rebuildMoreMenu();
|
||||
}
|
||||
|
||||
// Run once on load, again after fonts settle (label widths shift),
|
||||
// and on resize (debounced via rAF).
|
||||
applyNavPriority();
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(applyNavPriority);
|
||||
}
|
||||
let rafId = 0;
|
||||
window.addEventListener('resize', function() {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(applyNavPriority);
|
||||
});
|
||||
// Re-apply on route change too: the active link gets bigger padding
|
||||
// (background pill), so which links fit can shift between pages.
|
||||
window.addEventListener('hashchange', function() {
|
||||
// Defer so the route handler's class toggles run first.
|
||||
requestAnimationFrame(applyNavPriority);
|
||||
});
|
||||
|
||||
navMoreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !navMoreMenu.classList.contains('open');
|
||||
|
||||
+16
-6
@@ -1336,19 +1336,29 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
.ch-main { width: 100%; }
|
||||
}
|
||||
|
||||
/* === Nav Priority+ — applies at ALL desktop widths (Issue #1055) ===
|
||||
Above the mobile hamburger breakpoint, only data-priority="high" links
|
||||
render inline; the rest collapse into the "More ▾" overflow menu.
|
||||
This guarantees nav-right cannot be pushed off-screen or overlapped by
|
||||
the link strip at any common viewport (768/1024/1280/1440/1920+). */
|
||||
/* === Nav Priority+ — JS-driven measurement (Issue #1102) ===
|
||||
Above the mobile hamburger breakpoint we render ALL nav links inline
|
||||
by default. A small JS pass in app.js (applyNavPriority) measures the
|
||||
actual fit and adds .is-overflow to whichever links don't fit, then
|
||||
moves them into the "More ▾" overflow menu. Pure CSS can't do this
|
||||
correctly because we want every link visible at 2560px+ but only the
|
||||
high-priority ones at 800px — and the breakpoint where each link
|
||||
drops depends on its label width, the gutters, the active-link
|
||||
padding, and the nav-stats badge. JS measurement is the only correct
|
||||
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) {
|
||||
.nav-links { display: flex !important; flex-direction: row; gap: var(--space-xs); }
|
||||
.nav-links a:not([data-priority="high"]) { display: none; }
|
||||
.nav-more-wrap { display: flex; align-items: center; }
|
||||
.hamburger { display: none; }
|
||||
.nav-link { padding: 14px clamp(8px, 0.6vw + 4px, 14px); font-size: var(--fs-sm); }
|
||||
.nav-links a[data-priority="high"] { order: -1; }
|
||||
.nav-link.active { background: var(--nav-active-bg); border-radius: 6px; margin: 4px 0; padding: 10px clamp(8px, 0.6vw + 4px, 14px); }
|
||||
/* JS-managed: links assigned .is-overflow get hidden inline; the
|
||||
"More ▾" wrap is hidden when nothing overflows. */
|
||||
.nav-links .nav-link.is-overflow { display: none; }
|
||||
.nav-more-wrap.is-hidden { display: none !important; }
|
||||
}
|
||||
|
||||
/* === Nav narrow-desktop tightening (Issue #1055 — 1024px overlap fix) ===
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1102 — Nav Priority+ at very wide widths and "More" menu correctness.
|
||||
*
|
||||
* Regression from PR #1097 polish: at all widths >=768px the CSS rule
|
||||
* .nav-links a:not([data-priority="high"]) { display: none; }
|
||||
* unconditionally hides 6 of 11 links, even at 2560px where there is
|
||||
* plenty of room for everything. The "More" menu is built once on load
|
||||
* from the same selector, so it correctly shows the hidden links — but
|
||||
* the bug here is the SET being hidden is wrong (way too aggressive).
|
||||
*
|
||||
* Acceptance:
|
||||
* - At 2560px: ALL 11 links visible inline AND "More ▾" hidden.
|
||||
* - At 1920px: at least 9 links visible (room for most).
|
||||
* - At 1080px: 5 high-priority links visible AND More menu contains
|
||||
* every link not currently visible inline.
|
||||
* - At 768px (just above hamburger threshold): 5 high-priority links
|
||||
* visible AND More menu non-empty.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
// [width, expected behavior]
|
||||
const CASES = [
|
||||
// viewport, minVisible, moreVisible, label
|
||||
{ w: 2560, minVisible: 11, moreVisible: false, label: '2560px — all visible' },
|
||||
{ w: 1920, minVisible: 9, moreVisible: null, label: '1920px — most visible' },
|
||||
{ w: 1080, minVisible: 5, moreVisible: true, label: '1080px — collapsed' },
|
||||
{ w: 800, minVisible: 5, moreVisible: true, label: '800px — collapsed' },
|
||||
];
|
||||
|
||||
const HEIGHT = 900;
|
||||
|
||||
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-priority-1102-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-nav-priority-1102-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
for (const c of CASES) {
|
||||
await page.setViewportSize({ width: c.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);
|
||||
// Settle layout (two consecutive frames identical for nav-right).
|
||||
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 links = Array.from(document.querySelectorAll('.nav-links .nav-link'));
|
||||
const visible = links.filter(a => getComputedStyle(a).display !== 'none');
|
||||
const visibleHrefs = visible.map(a => a.getAttribute('href'));
|
||||
const allHrefs = links.map(a => a.getAttribute('href'));
|
||||
const hiddenInline = allHrefs.filter(h => !visibleHrefs.includes(h));
|
||||
const moreWrap = document.querySelector('.nav-more-wrap');
|
||||
const moreVisible = moreWrap ? getComputedStyle(moreWrap).display !== 'none' : false;
|
||||
const moreMenuLinks = Array.from(document.querySelectorAll('#navMoreMenu .nav-link'))
|
||||
.map(a => a.getAttribute('href'));
|
||||
return { totalLinks: links.length, visibleCount: visible.length,
|
||||
visibleHrefs, hiddenInline, moreVisible, moreMenuLinks };
|
||||
});
|
||||
|
||||
const reasons = [];
|
||||
if (data.visibleCount < c.minVisible) {
|
||||
reasons.push(`only ${data.visibleCount}/${data.totalLinks} links visible (need >=${c.minVisible})`);
|
||||
}
|
||||
if (c.moreVisible === true && !data.moreVisible) {
|
||||
reasons.push(`"More" button should be visible but is hidden`);
|
||||
}
|
||||
if (c.moreVisible === false && data.moreVisible) {
|
||||
reasons.push(`"More" button should be HIDDEN at ${c.w}px (all links fit) but is visible`);
|
||||
}
|
||||
// More menu MUST contain every link not currently visible inline.
|
||||
if (data.moreVisible) {
|
||||
const missing = data.hiddenInline.filter(h => !data.moreMenuLinks.includes(h));
|
||||
if (missing.length) {
|
||||
reasons.push(`More menu missing hidden links: ${missing.join(', ')} ` +
|
||||
`(menu has ${data.moreMenuLinks.length}, expected ${data.hiddenInline.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
const tag = c.label;
|
||||
if (reasons.length === 0) {
|
||||
passes++;
|
||||
console.log(` ✅ ${tag}: visible=${data.visibleCount}/${data.totalLinks} more=${data.moreVisible} menu=${data.moreMenuLinks.length}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.log(` ❌ ${tag}: ${reasons.join(' | ')} ` +
|
||||
`(visible=${data.visibleCount}/${data.totalLinks} more=${data.moreVisible} menu=${data.moreMenuLinks.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-nav-priority-1102-e2e.js: ${failures === 0 ? 'OK' : 'FAIL'} — ${passes}/${CASES.length} passed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('test-nav-priority-1102-e2e.js: fatal', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user