Skip to content

refactor(website): render both nav panel surfaces from one body - #1084

Merged
blove merged 1 commit into
blove/navbar-design-brainstorm-2b6a7dfrom
blove/nav-panel-shared-body
Sep 9, 2026
Merged

refactor(website): render both nav panel surfaces from one body#1084
blove merged 1 commit into
blove/navbar-design-brainstorm-2b6a7dfrom
blove/nav-panel-shared-body

Conversation

@blove

@blove blove commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1083 — base is blove/navbar-design-brainstorm-2b6a7d, so this diff is the single commit on top of that branch.

The duplication

NavDesktop's Panel and NavMobile's inline panel JSX each wrote the same shape by hand: map panel.columns → a column wrapper → column.items.map(NavPanelItem), then conditionally render panel.footer with the same nav-panel-footer / nav-panel-footer-lead classes plus one more NavPanelItem. Individual items already funnelled through the shared NavPanelItem — which is what has kept the analytics ids from drifting — but the column and footer shape around them was written twice and free to diverge.

The shape

NavPanelBody renders columns + footer once, parameterised by the two things that genuinely differ:

  • wrapper class names, because the layouts are not the same — desktop keeps nav-panel-cols / nav-panel-col, mobile keeps nav-mobile-panel / nav-mobile-group
  • an optional onNavigate, which the mobile drawer uses to close itself and desktop omits

The caller still owns the outermost element, because that is the other real difference: desktop needs the panel id and data-columns, mobile needs neither. Desktop passes columnsClassName; mobile omits it, so the mobile stack keeps its columns as direct children of .nav-mobile-panel and the scoped .nav-mobile-panel .nav-panel-footer override that stacks the footer lead above the link at narrow widths still matches.

NavPanelItem and trackNavItem move into the new module with it. NavMobile had been importing NavPanelItem from NavDesktop, pointing the dependency the wrong way between two sibling surfaces; both now import from a shared leaf and neither imports the other.

Rendered output is unchanged

Pure refactor. Rather than trusting the unit suite — jsdom has no layout engine, and the panel geometry bugs on this branch were all invisible to it — the outerHTML of all three panels was dumped on both surfaces before and after (6 files, 612 lines). diff -r reports zero differences, down to the useId-generated id="_r_0_-libraries".

No cta_id changes: trackNavItem moved verbatim and both call sites pass the same surface, so desktop still emits nav_<ctaId> and mobile mobile_nav_<ctaId>.

Verification

Lane Result
nx test website -- --run 1462 passed, 0 failed (409 suites; Nav.spec.tsx collected, 28 tests)
nx build website green, 320 pages prerendered
nx e2e website -- --testFiles=e2e/nav-panels.spec.ts 5 passed
nav-drawer + nav-surface + nav-height e2e 27 passed
nx lint website 0 errors

nav-panels.spec.ts is desktop-only and this touches the mobile surface too, so the mobile drawer suites were run as well rather than left unverified in a real browser.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 9, 2026 3:35am UTC

Request Review

The desktop hover panel and the mobile drill-in stack each wrote the same
shape by hand: map panel.columns to a column wrapper, map column.items
through NavPanelItem, then render panel.footer with nav-panel-footer /
nav-panel-footer-lead plus one more NavPanelItem. Individual items already
funnelled through the shared NavPanelItem, which is what has kept the
analytics ids from drifting, but the column and footer shape around them
was duplicated and free to diverge.

NavPanelBody now renders columns + footer once, parameterised by the two
things that genuinely differ: the wrapper class names (the layouts are not
the same, so desktop keeps nav-panel-cols / nav-panel-col and mobile keeps
nav-mobile-panel / nav-mobile-group) and the optional onNavigate the mobile
drawer uses to close itself. The caller still owns the outermost element,
because that is the other real difference: desktop needs the panel id and
data-columns, mobile needs neither. Desktop passes columnsClassName, mobile
omits it, so the mobile stack keeps its columns as direct children and the
scoped .nav-mobile-panel .nav-panel-footer override still matches.

NavPanelItem and trackNavItem move into the new module with it. NavMobile
had been importing NavPanelItem from NavDesktop, which pointed the
dependency the wrong way between two sibling surfaces; both now import from
a shared leaf and neither imports the other.

Pure refactor — no rendered output changes. Verified by dumping the
outerHTML of all three panels on both surfaces before and after: identical
byte for byte, down to the useId-generated panel id. nx test website 1462
passed, nx build website green, and the nav e2e suites (nav-panels,
nav-drawer, nav-surface, nav-height) 32 passed in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@blove
blove force-pushed the blove/nav-panel-shared-body branch from 05850d7 to 52eb171 Compare September 9, 2026 03:31
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit aa661d0 into blove/navbar-design-brainstorm-2b6a7d Sep 9, 2026
3 checks passed
@blove
blove deleted the blove/nav-panel-shared-body branch September 9, 2026 03:36
blove added a commit that referenced this pull request Sep 9, 2026
… a transparent hero surface, and a mobile drill-in stack (#1083)

* docs(specs): navbar redesign design

Single-row nav with hover-opened panels (Libraries / Docs / Solutions /
Pricing), transparent-on-hero bar surface, a condensed docs height, and a
mobile drill-in stack that replaces the Site/Docs tab strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(specs): correct the navbar spec's copy-scan and nav-height risks

public-copy.spec.ts has a second `renderedCopyFiles` AST scan covering every
non-spec .ts/.tsx/.mjs under src/, so nav-config copy is guarded already; the
spec had claimed only content/** was scanned.

nav-height.spec.ts navigates every width step to /docs, so a docs-specific
height moves those steps rather than merely adding cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): navbar redesign implementation plan

Nine TDD tasks: IA as data, surface hook, two verbatim extractions, desktop
panels, transparent bar, condensed docs height, mobile drill-in stack, and the
deletion pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(website): describe the navbar IA as data

Lands the four-trigger nav structure (Libraries, Docs, Solutions,
Pricing) as a standalone data module with route-resolution and
copy-quality tests. Nothing consumes it yet — Nav.tsx is unchanged;
later tasks render both the desktop panels and the mobile drill-in
from this same module so the two surfaces cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): teach the nav-config test about the dynamic solutions route

Task 1's test only resolved /docs/* dynamically, so the three /solutions/:slug
deep links looked unresolvable and invited repointing them at the hub — which
would have collapsed three distinct panel destinations into one.

Adds solutionsHrefResolves against getAllSolutionSlugs, plus a mutation check
so neither dynamic-route resolver can pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): deep-link the nav's solutions items and validate the dynamic route

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): note that nx swallows the vitest path filter

Every per-task command runs the full website suite; the trailing path is
ignored. Records the `cd apps/website && npx vitest run <path>` form for
iterating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): record the docs-structured-data timeout seen under full-suite load

Passes in isolation and is untouched by this branch, so Task 9's full run will
likely hit it. Noted rather than pre-excused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(website): derive the nav bar surface from route and scroll

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): defer the nav surface's optimistic-atTop decision to Task 5

jsdom cannot measure layout, so the flash trade-off can only be judged in a
real browser. Records both failing cases, the getBoundingClientRect seed that
resolves both, and that StrictMode's dev double-invoke means a green dev-server
run is not sufficient evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(website): assert useNavSurface observes the sentinel it hands back

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(website): extract the desktop nav row into its own component

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): warn that some website specs are cwd-sensitive

cockpit-retirement.spec.ts walks directories relative to process.cwd(), so it
fails 5/9 under `npx vitest` from apps/website and passes under nx. Verified
directly. A filtered local run is fine for the one spec being iterated on;
suite-level claims must come from `npx nx test website`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): fix the nav CTA selector — Button emits no class

Tasks 5 and 6 targeted `.btn[data-variant='primary']`, which matches nothing:
Button stamps data-ui/data-variant/data-size and passes through only a caller-
supplied className, which the nav's CTA does not supply. A CSS selector that
matches nothing fails silently, so both the transparent-state CTA inversion and
the docs CTA demotion would have looked implemented and done nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(website): render the desktop nav as four triggers with hover panels

Replaces the flat link row and the hand-rolled Demo dropdown with the four
triggers from NAV_TRIGGERS: three panel disclosures (Libraries, Docs,
Solutions) and Pricing as a plain link. NavPanelItem and trackNavItem are
exported so Task 8's mobile levels render the same items through the same
component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): lay the libraries panel across its own width

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): correct the e2e invocation and warn about orphaned dev servers

A bare positional spec path fails on this Nx/Playwright executor with
`unknown option '--_=…'`; it needs --testFiles=. Also records that an orphaned
next-server can hold the web-server port, and that a stale one will happily
serve an old bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): require nx build per task — test and lint do not typecheck

Task 4 shipped a broken production build behind 1411 passing tests and a clean
lint: vitest strips types and this eslint config is not type-aware, so a
template literal widening to `string` against the CtaId union went unseen until
review ran the build. Annotates the plan's own trackNavItem, and sets
aria-controls only while the panel is actually rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): keep the nav's analytics id assignable to CtaId

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(specs): drop the shared morphing nav panel, on measurement

All three panels are the same width by construction, and Docs and Solutions are
the same height, so four of six transitions have nothing to animate. The morph
buys one 67px tween and costs an inert/allow-discrete state machine on top of
the a11y wiring, plus rewriting every .nav-panel locator. A 140ms entrance
animation addresses the real complaint instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(specs): record the nav panel's gutter misalignment as a known gap

The shell insets against the inner row's padding box, so the bar's px-8 gutter
lands inside the panel: first item at x=24 against a logo at x=32. Polish, not
a defect — recorded rather than silently dropped with the content-width claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): put nav panels in tab order and make the panel grid generic

- Render each panel shell in a Fragment right after its own trigger so tab
  order matches visual order; delete the trailing filter().map() block that
  put every panel's content after Docs, Solutions, Pricing, GitHub, and
  Talk to Us.
- Make .nav-panel-cols generic (grid-auto-flow: column) so a fourth column
  in nav-config.ts degrades gracefully instead of stacking into a 949px
  wall; the single-column Libraries layout is now equally generic instead
  of hardcoding repeat(4, 1fr).
- Delete 45 lines of dead .nav-demo-* CSS left over from the removed
  DemoDropdown component.
- Give .nav-panel an entrance animation so it doesn't snap in while the
  caret glides, with a prefers-reduced-motion override.
- Comment .nav-desktop's position: static so it isn't mistaken for a
  no-op and deleted later.
- Add e2e coverage for the tab-order fix and for the hover-open grace
  period/dead-zone survival, the latter using a stepped mouse.move
  because a plain .hover() jumps straight to the target and never
  exercises the dead zone.
- Stabilize the pre-existing "lays out side by side" geometry test
  against the new entrance animation by waiting for it to finish before
  sampling bounding boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): record two Playwright traps found in Task 4

.hover() teleports to the target centre, so any test whose subject is the path
between two elements is vacuous when written with it — Task 4's hover-grace
test passed with and without the behaviour it guarded, caught only because the
mutation proof failed to fail. And an entrance animation makes sequential
boundingBox sampling flaky; await getAnimations().finished, never a fixed sleep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(website): render the nav transparent over the hero until scrolled

The bar now carries two surfaces: transparent at rest on a HERO_ROUTES page,
solid everywhere else and once scrolled. The drop shadow goes in both.

Wiring useNavSurface into Nav.tsx also settled the design question its comment
deferred. Measured per-frame in Chrome, the unconditional optimistic
`setAtTop(true)` applied `transparent` for a frame whenever a hero route
mounted already scrolled — a #hash deep link, or back-navigation with scroll
restoration. Seeding from the sentinel's getBoundingClientRect() removes those
frames and leaves the common fresh-load-at-scroll-0 case unchanged. The unit
spec has to state the scroll position each case is about now that the hook
reads layout, because jsdom reports every rect as zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(specs): record the nav's first-load solid flash and pin the contrast figures

SSR ships `solid` because the first render has no layout to read, so the flip
waits on hydration — ~250ms against next dev. Fixing it by server-rendering
transparent trades the common case for the already-scrolled one and risks a
hydration mismatch; judge it on the deployed preview, which is already a
required gate. Contrast now computed rather than asserted: 8.33:1 and 15.37:1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(specs): correct the mobile section — the drawer is hidden on docs detail pages

Only /docs/[library]/[section]/[slug] mounts WebsiteWorkspace, and docs.css hides
the hamburger and overlay below 1024px on workspace pages; lg:hidden covers the
rest. So the site drawer is unreachable at every width on detail docs — existing
e2e already asserts it. The drill-in decision stands, but the pre-push reaches
only the two library-neutral docs routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(website): condense the nav to one height inside docs

Inside /docs the bar is chrome around the reader's content, not the page's
headline, so it condenses to a flat 58px at every breakpoint and the
"Talk to Us" CTA demotes from a fill button to a text link.

`--nav-h` becomes route-dependent. The marketing ladder (58/66/81) stays;
`:root:has(.nav-bar[data-route='docs'])` overrides it, which wins over the
media queries regardless of source order because `:has()` takes the
specificity of its most specific argument. Nav.tsx stamps the route on the
bar from the `isDocsPage` it already computes, server-rendered, so the value
is right on first paint.

Flatness comes from the CTA demotion, not the padding alone: Button's
size=md carries a hard `height: 40px`, which is what pushes the marketing
bar to 81px once the lg link row appears. With `height: auto` the 25px logo
is the tallest thing in the row at every width, so a flat 16px of padding
measures 58px from 375px up.

e2e/nav-height.spec.ts now measures both ladders — its width steps moved to
`/` so they stop measuring the docs bar against marketing values — and adds
a case pinning that the docs nav does not grow with the breakpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): give the demoted docs CTA a real hit area and pin it

Follow-ups from Task 6 of the navbar redesign (commit 7f649cde1):

- The docs CTA demotion (fill button -> text link) left `height: auto`
  with `padding-inline: 0` and `line-height: 1`, collapsing the hit area
  to ~16px tall. Padded the box and pulled it back with an equal
  negative margin, the same trick `.nav-hamburger` already uses, so the
  clickable area grows without moving the visual layout or the flex
  row's height that --nav-h depends on.
- Nothing asserted that the docs CTA is actually a text link rather than
  a fill, so a future change to Button's primary-variant styling could
  silently restyle it. Added a test to nav-surface.spec.ts pinning both
  halves of the contrast: marketing keeps a filled CTA, docs demotes it
  to a transparent background with the navy accent color.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(website): pin the declared nav heights per surface

Every nav-height.spec.ts step only checked self-consistency (--nav-h
tracks the rendered nav), which is blind to the marketing ladder and
the rendered nav drifting together to the wrong value -- e.g. the
ladder silently flattening to match docs. Add absolute assertions that
pin the declared --nav-h per surface/width (marketing: 58/66/81, docs:
58 flat) alongside the existing tolerance check, plus the inverse of
"the docs nav does not grow with the breakpoint": marketing does grow.

Also amend the stale CTA-demotion comment in chrome.css -- a later
commit added the padding-block/margin-block hit-area trick, which is
what actually pins the CTA's margin box at 16px now; height: auto is
belt-and-braces on top of it, not the sole reason for the flat 58px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(website): say which kind of nav-height failure you are looking at

The tolerance checks and the ladder pin fail with identical generic output, so
a red CI run does not tell you whether --nav-h drifted from what rendered or
whether the design changed. Each assertion now carries a message saying which.

Behaviour-neutral: messages only. Typechecked with the workspace compiler and
linted, but NOT run — the e2e suite starts a cockpit runtime server on the
hardcoded port 4300, which another worktree is currently holding. Exercised by
the next full e2e run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): warn Task 7 not to move the drawer inside <nav>

Task 6 added `.nav-bar[data-route='docs'] > div` padding. The overlay is a
sibling of <nav> today, so it is unaffected; moving it inside would silently
give it docs-only padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(website): extract the mobile nav drawer into its own component

Task 7 of the navbar redesign plan: a pure move, no behavior changes.
NavMobile.tsx now owns the hamburger trigger, the focus trap, scroll
lock, inert management, desktop-breakpoint auto-close, focus restore
via requestAnimationFrame, and the Cmd+K search handoff — verbatim
from Nav.tsx, with `open`/`mobileTab` state and refs localized to the
new component. Nav.tsx keeps the <nav> shell, path parsing, navRef,
useNavSurface, and NavDesktop, and passes down isDocsPage,
docsLibrary, activeSection, activeSlug, and navRef.

The one deliberate addition: since NavMobile is invoked once from
inside nav's flex row (where the hamburger button must stay, for
flex layout), the overlay dialog is rendered via createPortal to
document.body so it remains a sibling of <nav> in the DOM rather than
becoming a second direct <div> child of <nav> — preserving both the
original stacking-context fix and immunity from the Task 6
`.nav-bar[data-route='docs'] > div` padding rule. No props beyond the
given NavMobileProps interface were needed for this.

Verified: nx test website -- --run -> 140 files / 1413 tests passed,
identical to baseline. nx build website exits 0. nx lint website ->
0 errors, 65 pre-existing warnings (no new ones). nx e2e website
--testFiles=e2e/nav-height.spec.ts -> 16/16 passed (port 4300 was
free).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): record cross-worktree e2e port contention and the BASE_URL escape

A neighbouring worktree started a server mid-run during Task 7, producing 15
failures that were all ERR_CONNECTION_REFUSED with zero assertion failures.
Records how to tell contention from a real failure, that another worktree's
server must never be killed, and that BASE_URL bypasses the config's own
webServers entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(website): replace the mobile tab strip with a drill-in stack

The drawer's Site/Docs tab strip is gone. Depth carries what it said: the
root level lists the four nav triggers as rows, tapping one pushes to that
trigger's panel with a back row above it, and on a docs route the drawer
opens pre-pushed to the docs tree.

Escape retraces the way in — it pops a pushed level, and dismisses the
drawer only from the level it opened at, so Escape on a docs route still
closes the drawer in one press rather than stranding it at the root list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): widen Task 9's dead-CSS sweep to the audited orphan set

Thirteen nav-* classes in chrome.css are referenced from no component, no
other stylesheet and no dynamic className. Three were orphaned by Task 8's
drill-in rewrite; the rest predate this branch. Records the list, requires
re-verification before deletion, and asks that the pre-existing ones be called
out separately so the diff is not read as redesign fallout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): state the drawer's Escape rule and restore its footer lead

Escape's condition compared level against a hardcoded docs special case;
express it instead as a comparison against initialLevel(isDocsPage), the
level the drawer actually opened at, so the rule reads as what it is and
survives any future route that pre-pushes to a different level.

The mobile Libraries panel footer dropped the "Not sure which one?" lead
that desktop renders before the Choosing-an-adapter link, a copy
regression against desktop. Render it via the same nav-panel-footer /
nav-panel-footer-lead classes, with a mobile-scoped CSS override to stack
the lead above the link instead of wrapping onto its row at narrow width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): inverting a condition does not prove it equivalent

A "clarity" refactor of the drawer's Escape handler dropped a guard and made
Escape a dead key at the root of a docs-route drawer. The equivalence proof
(invert the condition, watch both Escape tests fail) passed, because those two
tests covered two of five reachable states. Records the state list, and that
nx is the only unit lane that actually runs Nav.spec.tsx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): stop Escape becoming a dead key at the drawer root

Restore the level.kind === 'panel' guard on the Escape handler's pop
branch. Without it, a root reached via Back on a docs route (never
pushed from, so not sameLevel as the docs opening level) hit the pop
branch and called setLevel(rootLevel) on a level that was already
root — a no-op that left closeMobileMenu() unreached. Escape became
permanently dead until the reader used the Close button.

Adds two regression tests covering the previously-unreached states:
Escape from a root reached via Back on a docs route, and Escape
popping a level pushed from that root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(website): delete the superseded nav exports and dead drawer CSS

The rebuilt navbar left three kinds of dead weight behind.

`NavDesktop.tsx` still exported `links` (the old flat link array) and
`trackNavLink` (the old per-label analytics helper). Both were kept alive
only for the mobile drawer, which now reads `NAV_TRIGGERS` and tracks
through `trackNavItem`. Nothing outside the file referenced either.

The dead CSS in `chrome.css` falls into two groups, and the split matters
when reading this diff:

Orphaned by this branch's drill-in rewrite (Task 8) —
`.nav-mtabs`, `.nav-mtab`, `.nav-mtab[data-active]`, `.nav-mobile-site-link`.

Already dead before this branch started, leftovers from an older drawer —
`.nav-msubtabs-wrap`, `.nav-msubtabs`, `.nav-msubtab`,
`.nav-msubtab[data-active]`, `.nav-mobile-content-list`,
`.nav-mobile-item`, `.nav-mobile-item[data-active]`,
`.nav-mobile-item--strong`, `.nav-mobile-demo-link`,
`.nav-mobile-section-toggle`, `.nav-mobile-chevron`,
`.nav-mobile-chevron[data-open]`, `.nav-mobile-search`,
`.nav-mobile-search:focus-visible`. Removing them is in scope — same
drawer's leftovers — but they are not fallout from the redesign.

Every class was re-verified unreferenced across `src/`, `e2e/`, the rest
of `src/styles/`, `src/app/global.css` and the style contracts before
deletion; none is built dynamically, and the drawer's live classes
(`.nav-mobile-list`, `-overlay`, `-row`, `-back`, `-panel`, `-group`,
`-github-link`, `-cta`, `-dialog-close`) are untouched.

Finally, drill-in focus is now symmetric. Pushing a level focuses its
"Back to menu" row through an explicit ref; popping fell through to
`focusable()[0]`, which is not a designed destination. Both pop paths —
the Back button and Escape — now route through `popToRoot`, which records
the originating trigger id so the per-level focus effect can land on that
row once root has re-rendered. The restore reads a ref map rather than
the focus-trap query on purpose: jsdom's multi-clause `querySelectorAll`
groups by clause instead of returning document order, so a test written
against `focusable()[0]` would assert the Pricing anchor where a browser
gives the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plans): write down the three deferrals that were only in reviewers' heads

The panel-shape duplication, the NavMobile -> NavDesktop import direction, and
MobileLevel.id being an untyped string were all deliberate calls, but none was
recorded. An undocumented deferral reads as an oversight later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(website): open the mobile drawer in a real browser

Nav.spec.tsx covers the drill-in stack in jsdom, but jsdom has no CSS or
layout engine, so nothing catches the overlay's top offset drifting,
an overlay host swallowing row clicks, an lg:hidden regression exposing
the hamburger at desktop, or a scroll lock that never engages/releases.
Gives the mobile drawer the same real-browser treatment nav-panels.spec.ts
gives the desktop panels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(website): render both nav panel surfaces from one body (#1084)

The desktop hover panel and the mobile drill-in stack each wrote the same
shape by hand: map panel.columns to a column wrapper, map column.items
through NavPanelItem, then render panel.footer with nav-panel-footer /
nav-panel-footer-lead plus one more NavPanelItem. Individual items already
funnelled through the shared NavPanelItem, which is what has kept the
analytics ids from drifting, but the column and footer shape around them
was duplicated and free to diverge.

NavPanelBody now renders columns + footer once, parameterised by the two
things that genuinely differ: the wrapper class names (the layouts are not
the same, so desktop keeps nav-panel-cols / nav-panel-col and mobile keeps
nav-mobile-panel / nav-mobile-group) and the optional onNavigate the mobile
drawer uses to close itself. The caller still owns the outermost element,
because that is the other real difference: desktop needs the panel id and
data-columns, mobile needs neither. Desktop passes columnsClassName, mobile
omits it, so the mobile stack keeps its columns as direct children and the
scoped .nav-mobile-panel .nav-panel-footer override still matches.

NavPanelItem and trackNavItem move into the new module with it. NavMobile
had been importing NavPanelItem from NavDesktop, which pointed the
dependency the wrong way between two sibling surfaces; both now import from
a shared leaf and neither imports the other.

Pure refactor — no rendered output changes. Verified by dumping the
outerHTML of all three panels on both surfaces before and after: identical
byte for byte, down to the useId-generated panel id. nx test website 1462
passed, nx build website green, and the nav e2e suites (nav-panels,
nav-drawer, nav-surface, nav-height) 32 passed in a real browser.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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