Skip to content

feat(website): rebuild the navbar as four triggers with hover panels, a transparent hero surface, and a mobile drill-in stack - #1083

Merged
blove merged 42 commits into
mainfrom
blove/navbar-design-brainstorm-2b6a7d
Sep 9, 2026
Merged

feat(website): rebuild the navbar as four triggers with hover panels, a transparent hero surface, and a mobile drill-in stack#1083
blove merged 42 commits into
mainfrom
blove/navbar-design-brainstorm-2b6a7d

Conversation

@blove

@blove blove commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Rebuilds the website navbar: the desktop bar becomes four triggers with hover panels, the bar goes transparent over the homepage hero, it condenses inside /docs, and the mobile drawer becomes a drill-in stack.

Design: docs/superpowers/specs/2026-09-08-navbar-redesign-design.md
Plan: docs/superpowers/plans/2026-09-08-navbar-redesign.md

What changes

The nav stops hiding the product. It carried Pilot to Prod · Docs · Pricing plus a hand-rolled Demo ▾. The four library pages, /solutions and its children, /blog and /about were reachable only from the footer. Now: Libraries ▾ · Docs ▾ · Solutions ▾ · Pricing, rendered from one data module.

The bar has two surfaces. Transparent at rest on hero routes (navy links, navy-filled CTA — 8.33:1 and 15.37:1, both AAA), solid everywhere else and once scrolled. Detection is an IntersectionObserver on an 8px sentinel, not a scroll listener: the in-app browser pane suspends scroll events, so a listener version looks broken when it is not.

Docs gets a condensed bar — a flat 58px at every width, CTA demoted to a text link. --nav-h is now route-dependent, which is the riskiest change here: nine layout systems read it.

Mobile is a drill-in stack that replaces the Site/Docs tab strip. mobileTab is gone; depth carries that meaning. On a /docs route the drawer opens pre-pushed to the docs level.

Architecture

Nav.tsx goes from 517 lines to 58 — a shell that derives route facts and composes.

File Lines Responsibility
nav-config.ts 255 the IA as readonly data
NavDesktop.tsx 259 the bar and its hover panels
NavMobile.tsx 479 the drawer and its drill-in stack
useNavSurface.ts 55 transparent vs. solid
Nav.tsx 58 shell

Both surfaces render from nav-config.ts, so they cannot drift, and adding a destination is a data change.

Verification

  • 1462 unit tests, 158 e2e passed / 4 skipped, build exits 0, lint 0 errors
  • New: nav-config.spec.ts, useNavSurface.spec.tsx, e2e/nav-panels.spec.ts, e2e/nav-surface.spec.ts, e2e/nav-drawer.spec.ts
  • Every new guard was mutation-proven — broken deliberately, confirmed to fail, restored

Why the history has so many fix(website): commits

Seven defects were found during implementation, and none of them failed a test. They are kept as separate commits because the fixes are where the information is:

Defect What was green while it was broken
.btn CSS selector matching no element everything
4-track grid applied to a 1-child container 1411 tests
ctaId widening to stringproduction build broken 1411 tests + 0 lint errors
aria-controls pointing at a nonexistent id everything
Panels rendered after the entire trigger row (tab order) everything
A hover test that asserted nothing it passed
Escape becoming a dead key at the drawer root 1418 tests

Three were process gaps, now closed in the plan: nx test/nx lint do not typecheck (only nx build does); Playwright's .hover() teleports and never traverses, so path-shaped tests written with it are vacuous; and jsdom has no layout engine, so geometry needs a real browser.

The dead-Escape bug is the instructive one: it was introduced by a clarity refactor of working code, and its "equivalence proof" passed because the two existing tests covered two of five reachable states.

Deliberately not in this PR

  • Landing-page heroes. HERO_ROUTES ships as ['/'] only. Listing a page before it has a hero renders navy links over white.
  • Panel column/footer duplication and the NavMobile → NavDesktop import — same fix, deferred rather than refactored immediately before a merge.
  • First-load solid flash on / (~250ms): SSR renders solid because the first render has no layout to read. Recorded in the spec; judge it on the preview.
  • Panel gutter misalignment — the shell insets against the padding box, so panel items start at x=24 against a logo at x=32.

Outstanding manual gate

The transparent bar has not been checked against a deployed preview. reactStrictMode defaults on so effects double-invoke in dev, and this repo has had website e2e pass on next dev and fail only against deployed prod. Worth eyeballing the homepage bar on the preview before this goes out.

🤖 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:40am UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@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 and others added 26 commits September 8, 2026 20:19
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>
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>
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>
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>
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>
…namic route

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
…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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ervers

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>
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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
…eric

- 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>
.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>
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>
…rast 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>
…s 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>
blove and others added 15 commits September 8, 2026 20:19
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>
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>
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>
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>
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>
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>
…RL 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>
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>
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>
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>
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>
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>
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>
…rs' 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>
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>
@blove
blove force-pushed the blove/navbar-design-brainstorm-2b6a7d branch from 5274ca5 to 9d4cfd6 Compare September 9, 2026 03:20
@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.

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>
@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 dbfe68b into main Sep 9, 2026
32 checks passed
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