Skip to content

fix: navbar — priority+ overflow, alignment, click-through guard#103

Merged
smaramwbc merged 1 commit into
mainfrom
fix-navbar-overflow
May 25, 2026
Merged

fix: navbar — priority+ overflow, alignment, click-through guard#103
smaramwbc merged 1 commit into
mainfrom
fix-navbar-overflow

Conversation

@smaramwbc
Copy link
Copy Markdown
Owner

Summary

Adding Blog + About pushed the 7-link in-nav past its horizontal budget on narrow desktop widths (≈800–1100 px), and labels wrapped into a multi-line stack. The mobile hamburger only kicks in below `md` (<768 px) so the middle zone was uncovered. This change fixes the overflow plus four follow-on issues we uncovered while iterating.

What it does

Issue Fix
7 links wrapping into multi-line stack Priority+ dropdown — trailing items collapse into a "More" menu (ResizeObserver-driven, cached widths on first mount, useLayoutEffect collapse so no flash)
Centered overflow bleeding left into the logo `justify-start` (default) — overflow extends rightward only
"More" dropdown panel rendering empty when clicked Removed `overflow-hidden` from the nav container — it was clipping the abs-positioned panel below
Nav items touching the Ask Support button Switched `px-8` → `mx-8` so margin shrinks the flex-1 allocation; padding was over-budgeting the algorithm by 64 px
"More" text baseline sitting ~3–5 px below the `` siblings Wrap-and-shift: outer span carries `vertical-align: -0.75em` (expands line-box so the parent flex pulls the bbox back up to match `` baselines); SVG inside uses `top: -0.7em` to land at text x-height middle
HeroBackground particles still firing hover-tooltip behind the open menu `stopBubble` (e.stopPropagation) on `onMouseMove` / `onMouseOver` / `onPointerMove` of both the backdrop AND the panel — the panel is a DOM sibling of the backdrop, so its events never bubble through the backdrop

Plus housekeeping: GitHub link removed from the in-nav (it's already in the hero CTAs and the footer Community column), `whitespace-nowrap` on every link label, `shrink-0` on the right-actions cluster.

Accessibility

  • More trigger is a real `` with `aria-haspopup="menu"`, `aria-expanded`, `aria-label`
  • Panel is `role="menu"` with `role="menuitem"` children
  • Escape / outside-click / route-change all close the menu
  • Overflowed inline copies are `aria-hidden` + `tabIndex=-1` so SR / keyboard users don't see them twice

Anti-future-drift

Adding new nav items doesn't break the header again — they just join the More list at narrower widths.

Verification

  • Headless Chrome screenshots at 700 / 850 / 950 / 1100 / 1280 px — clean at every width
  • User confirmed the More-open click-through guard works (HeroBackground particles no longer respond to hover/click behind the dropdown)
  • `npm run build` — all 6 prerendered routes still build cleanly
  • `npx vitest run` — 284 + 2 skipped
  • `npm run lint`, `npm run typecheck` — green
  • CI green

Rollback

Single-file change. `git revert` restores the previous nav (broken at narrow widths).

Adding Blog + About pushed the 7-link in-nav past its horizontal
budget on narrow desktop widths (~800–1100px) and labels wrapped
into a multi-line stack. The mobile hamburger only kicks in below
md (<768px) so the middle zone was uncovered. This change fixes
the overflow plus four follow-on issues uncovered while iterating.

Priority+ dropdown ("More" overflow).
  * Trailing links collapse into a "More" dropdown instead of
    wrapping. ResizeObserver re-measures on container resize.
  * Each item's natural width is captured ONCE on mount while
    every item is still visible and then reused — re-measuring
    items that are display:none reads scrollWidth === 0, which
    under-counts and causes the algorithm to oscillate.
  * useLayoutEffect (not useEffect) so the collapse lands in the
    same paint as the initial render. No flash of all-7-inline.
  * Overflowed items stay in the DOM as display:none +
    aria-hidden + tabIndex -1, so a future width change can
    re-show them without remounting and screen-reader users
    don't see them twice.

Layout.
  * `justify-start` (the flex default) keeps overflowing items
    bleeding rightward only, never left into the logo.
  * `mx-8` (NOT `px-8`) gives a visible gap between the logo and
    the nav, and between the nav and the right-actions cluster.
    Padding leaves clientWidth = container width including the
    padding, which over-budgets the overflow algorithm by 64px
    and lets items visually touch the Ask Support button.
    Margin shrinks the flex-1 allocation itself.
  * NO `overflow-hidden` on the container. The dropdown panel is
    absolutely positioned below the container, and overflow-hidden
    would clip it (the user clicks More and sees an empty popup).
    The cached-widths algorithm makes the collapse reliable
    without needing visual clipping as a fallback.

GitHub link removed from the in-nav. It's already in the hero CTAs
and the footer Community column.

"More" alignment.
  * `<button>` ships with intrinsic padding + border (~8px) even
    after Tailwind v4's preflight, plus a different baseline than
    `<a>` in flex children. `p-0 m-0 border-0 bg-transparent
    appearance-none cursor-pointer` strips the chrome, but the
    baseline still ends up ~3-5px lower than the sibling <a>'s.
  * The fix that empirically lands: wrap the SVG in a span with
    `vertical-align: -0.75em`. That expands the button's line-box
    downward, the parent flex's items-center pulls the bbox back
    up to match the <a>'s, and the text baseline aligns exactly.
  * The chevron then needs `top: -0.7em` to come back up to text
    x-height middle (otherwise it hangs off the bottom of the
    line). The pair (-0.75em wrapper / -0.7em SVG) is the working
    set found empirically.

Click-through guard.
  * HeroBackground listens on `window` for mousemove → particle
    hover tooltip. A passive z-40 backdrop didn't help because
    the events still bubble through to window.
  * Both the backdrop AND the panel (they're DOM siblings, so a
    panel event never bubbles through the backdrop) attach
    onMouseMove / onMouseOver / onPointerMove handlers that call
    `stopBubble` — a tiny module-level helper that
    `e.stopPropagation()`s. The window listener now never fires
    while the menu is open, so the particle tooltip stays off
    behind the dropdown.

Accessibility.
  * The More trigger is a real <button> with aria-haspopup="menu",
    aria-expanded, aria-label.
  * The panel is role="menu" with role="menuitem" children.
  * Escape / outside click / route change all close the menu.

Anti-future-drift. Adding new nav items doesn't break the header
again — they just join the More list at narrower widths.

Verified at 700 / 850 / 950 / 1100 / 1280px via headless Chrome
screenshots and user inspection. All four gates (build / vitest /
lint / typecheck) green.
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
statewave-web Ready Ready Preview, Comment May 25, 2026 5:11pm

Request Review

@smaramwbc smaramwbc merged commit 0a9602c into main May 25, 2026
6 checks passed
@smaramwbc smaramwbc deleted the fix-navbar-overflow branch May 25, 2026 17:12
smaramwbc added a commit that referenced this pull request May 25, 2026
React error #418 surfaced in production after PR #103 shipped: the
HomePage's `showHeroCanvas` useState initialiser branched on
window.matchMedia at first render. On SSR it returned false (typeof
window === 'undefined' guard); on the client first render (during
hydration) it returned !matches — true on desktop. The conditional
{showHeroCanvas && <HeroBackground />} then differed between the
prerendered HTML and the hydrating client → React detected a tree
mismatch at the subtree, blew up the whole hydration, and re-rendered
the entire root from scratch on the client.

Two-pass pattern fix: initial state is `false` on both SSR and the
first client render, so hydration sees the same tree shape on both
sides. After hydration the effect reads matchMedia and flips the
flag — a normal post-mount re-render mounts HeroBackground on
desktop. Brief flash of "no background" is invisible because the
canvas is decorative and the hero content paints from the
prerendered HTML regardless.

Same pattern as the ThemeProvider fix in the SSR PR — anything that
reads window / matchMedia / localStorage in a useState initialiser
during render is an SSR/client mismatch waiting to happen. The
useState default must match what SSR produces; the real value
arrives in a mount effect.

Why this only surfaced now: the navbar PR #103 happened to expose it
because the navbar's ResizeObserver + useLayoutEffect path triggered
extra hydration scrutiny in React 19's dev / error-recovery flow.
The bug was latent since PR #94 (lazy HeroBackground); React was
silently recovering and re-rendering the root before, but it
counts as an error now.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant