feat(website): rebuild the navbar as four triggers with hover panels, a transparent hero surface, and a mobile drill-in stack - #1083
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
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>
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
force-pushed
the
blove/navbar-design-brainstorm-2b6a7d
branch
from
September 9, 2026 03:20
5274ca5 to
9d4cfd6
Compare
Contributor
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>
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdPlan:
docs/superpowers/plans/2026-09-08-navbar-redesign.mdWhat changes
The nav stops hiding the product. It carried
Pilot to Prod · Docs · Pricingplus a hand-rolledDemo ▾. The four library pages,/solutionsand its children,/blogand/aboutwere 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-his 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.
mobileTabis gone; depth carries that meaning. On a/docsroute the drawer opens pre-pushed to the docs level.Architecture
Nav.tsxgoes from 517 lines to 58 — a shell that derives route facts and composes.nav-config.tsNavDesktop.tsxNavMobile.tsxuseNavSurface.tsNav.tsxBoth surfaces render from
nav-config.ts, so they cannot drift, and adding a destination is a data change.Verification
nav-config.spec.ts,useNavSurface.spec.tsx,e2e/nav-panels.spec.ts,e2e/nav-surface.spec.ts,e2e/nav-drawer.spec.tsWhy the history has so many
fix(website):commitsSeven 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:
.btnCSS selector matching no elementctaIdwidening tostring— production build brokenaria-controlspointing at a nonexistent idThree were process gaps, now closed in the plan:
nx test/nx lintdo not typecheck (onlynx builddoes); 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
HERO_ROUTESships as['/']only. Listing a page before it has a hero renders navy links over white.NavMobile → NavDesktopimport — same fix, deferred rather than refactored immediately before a merge./(~250ms): SSR renderssolidbecause the first render has no layout to read. Recorded in the spec; judge it on the preview.Outstanding manual gate
The transparent bar has not been checked against a deployed preview.
reactStrictModedefaults on so effects double-invoke in dev, and this repo has had website e2e pass onnext devand fail only against deployed prod. Worth eyeballing the homepage bar on the preview before this goes out.🤖 Generated with Claude Code