Skip to content

Commit dbfe68b

Browse files
bloveclaude
andauthored
feat(website): rebuild the navbar as four triggers with hover panels, 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>
1 parent 0efddee commit dbfe68b

16 files changed

Lines changed: 4839 additions & 757 deletions
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
/**
4+
* jsdom has no CSS and no layout engine, so `src/components/shared/Nav.spec.tsx`
5+
* can exercise every state transition of the mobile drawer (push, pop, focus,
6+
* Escape) without ever being able to catch what only a real browser renders:
7+
* the overlay's `top: calc(var(--nav-h) - 1px)` landing in the wrong place, an
8+
* overlay host swallowing or mispositioning pointer events over a row (a
9+
* failure mode this repo has shipped before), an `lg:hidden` regression
10+
* leaking the hamburger onto desktop, or a scroll lock that never engages or
11+
* never releases. These assertions are about geometry and real pointer
12+
* delivery, so — like e2e/nav-panels.spec.ts for the desktop panels — they
13+
* only mean anything in a real browser.
14+
*/
15+
16+
const dialog = (page: Page) =>
17+
page.getByRole('dialog', { name: 'Mobile navigation' });
18+
19+
test.describe('mobile nav drawer', () => {
20+
test.beforeEach(async ({ page }) => {
21+
await page.setViewportSize({ width: 390, height: 844 });
22+
});
23+
24+
test('opens and shows the four root rows', async ({ page }) => {
25+
await page.goto('/');
26+
await page.getByRole('button', { name: 'Open menu' }).click();
27+
28+
const drawer = dialog(page);
29+
await expect(drawer).toBeVisible();
30+
31+
await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible();
32+
await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible();
33+
await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible();
34+
35+
const pricing = drawer.getByRole('link', { name: 'Pricing' });
36+
await expect(pricing).toBeVisible();
37+
await expect(pricing).toHaveAttribute('href', '/pricing');
38+
});
39+
40+
test('a click on the Libraries row actually reaches it', async ({ page }) => {
41+
await page.goto('/');
42+
await page.getByRole('button', { name: 'Open menu' }).click();
43+
44+
const drawer = dialog(page);
45+
const librariesRow = drawer.getByRole('button', { name: 'Libraries' });
46+
await expect(librariesRow).toBeVisible();
47+
48+
// Independent of whether .click() "succeeds": ask the browser what
49+
// element is actually topmost at the row's centre. An invisible overlay
50+
// host sitting over the row — the exact failure mode this repo has
51+
// shipped before — would still let Playwright's actionability checks
52+
// pass (the row is visible and unobscured *by Playwright's own reading of
53+
// the DOM*), but elementFromPoint reports what a real finger would hit.
54+
const box = await librariesRow.boundingBox();
55+
if (!box) throw new Error('Libraries row has no box');
56+
const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
57+
const topmostIsRow = await page.evaluate(
58+
({ x, y }) => {
59+
const el = document.elementFromPoint(x, y);
60+
return Boolean(el?.closest('.nav-mobile-row'));
61+
},
62+
centre,
63+
);
64+
expect(topmostIsRow).toBe(true);
65+
66+
await librariesRow.click();
67+
68+
// The level actually pushed: the four package links are showing, not
69+
// just "the click handler ran" (which jsdom already proves).
70+
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
71+
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible();
72+
await expect(drawer.getByRole('link', { name: /@threadplane\/ag-ui/ })).toBeVisible();
73+
await expect(drawer.getByRole('link', { name: /@threadplane\/chat/ })).toBeVisible();
74+
await expect(drawer.getByRole('link', { name: /@threadplane\/render/ })).toBeVisible();
75+
await expect(drawer.getByText('Not sure which one?')).toBeVisible();
76+
await expect(drawer.getByRole('link', { name: /Choosing an adapter/ })).toBeVisible();
77+
});
78+
79+
test('pushes into Libraries and pops back to the root rows', async ({ page }) => {
80+
await page.goto('/');
81+
await page.getByRole('button', { name: 'Open menu' }).click();
82+
83+
const drawer = dialog(page);
84+
await drawer.getByRole('button', { name: 'Libraries' }).click();
85+
86+
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
87+
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible();
88+
89+
await drawer.getByRole('button', { name: 'Back to menu' }).click();
90+
91+
await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible();
92+
await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible();
93+
await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible();
94+
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toHaveCount(0);
95+
});
96+
97+
test('sits flush under the nav', async ({ page }) => {
98+
await page.goto('/');
99+
await page.getByRole('button', { name: 'Open menu' }).click();
100+
101+
const drawer = dialog(page);
102+
await expect(drawer).toBeVisible();
103+
104+
const navBox = await page.locator('nav.nav-bar').boundingBox();
105+
const drawerBox = await drawer.boundingBox();
106+
if (!navBox || !drawerBox) throw new Error('nav or drawer has no box');
107+
108+
// `top: calc(var(--nav-h) - 1px)` exists precisely so the drawer overlaps
109+
// the nav's own 1px border rather than leaving a gap or an overshoot —
110+
// the class of offset bug that has shipped on this repo before.
111+
// `boundingBox()` returns {x, y, width, height} — not `bottom` — so the
112+
// nav's bottom edge is derived, not read directly.
113+
const navBottom = navBox.y + navBox.height;
114+
expect(Math.abs(drawerBox.y - navBottom)).toBeLessThanOrEqual(1);
115+
});
116+
117+
test('locks body scroll while open and releases it on close', async ({ page }) => {
118+
await page.goto('/');
119+
120+
expect(
121+
await page.evaluate(() => document.body.style.overflow),
122+
).not.toBe('hidden');
123+
124+
await page.getByRole('button', { name: 'Open menu' }).click();
125+
await expect(dialog(page)).toBeVisible();
126+
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
127+
128+
await page.getByRole('button', { name: 'Close menu' }).click();
129+
await expect(dialog(page)).toBeHidden();
130+
expect(
131+
await page.evaluate(() => document.body.style.overflow),
132+
).not.toBe('hidden');
133+
});
134+
135+
test('the hamburger is not present at desktop width', async ({ page }) => {
136+
await page.setViewportSize({ width: 1440, height: 900 });
137+
await page.goto('/');
138+
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
139+
});
140+
141+
test('opens pre-pushed to the docs level on /docs', async ({ page }) => {
142+
await page.goto('/docs');
143+
await page.getByRole('button', { name: 'Open menu' }).click();
144+
145+
const drawer = dialog(page);
146+
await expect(drawer).toBeVisible();
147+
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
148+
await expect(drawer.getByRole('button', { name: 'Search docs' })).toBeVisible();
149+
150+
// It opened at the docs level, not the root list of site triggers.
151+
await expect(drawer.getByRole('button', { name: 'Libraries' })).toHaveCount(0);
152+
await expect(drawer.getByRole('button', { name: 'Solutions' })).toHaveCount(0);
153+
});
154+
});

apps/website/e2e/nav-height.spec.ts

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,118 @@ import { test, expect } from '@playwright/test';
1717
* bug this guards against was fifteen.
1818
*/
1919
const STEPS = [
20-
{ width: 375, note: 'phone — px-6 py-4' },
21-
{ width: 767, note: 'phone — last px before md' },
22-
{ width: 768, note: 'tablet — md padding, no lg link row' },
23-
{ width: 1023, note: 'tablet — last px before lg' },
24-
{ width: 1024, note: 'desktop — lg link row appears' },
25-
{ width: 1440, note: 'desktop' },
20+
{ width: 375, note: 'phone — px-6 py-4', marketingNavH: 58, docsNavH: 58 },
21+
{ width: 767, note: 'phone — last px before md', marketingNavH: 58, docsNavH: 58 },
22+
{ width: 768, note: 'tablet — md padding, no lg link row', marketingNavH: 66, docsNavH: 58 },
23+
{ width: 1023, note: 'tablet — last px before lg', marketingNavH: 66, docsNavH: 58 },
24+
{ width: 1024, note: 'desktop — lg link row appears', marketingNavH: 81, docsNavH: 58 },
25+
{ width: 1440, note: 'desktop', marketingNavH: 81, docsNavH: 58 },
2626
];
2727

28-
for (const step of STEPS) {
29-
test(`--nav-h matches the rendered nav at ${step.width}px (${step.note})`, async ({ page }) => {
30-
await page.setViewportSize({ width: step.width, height: 800 });
31-
await page.goto('/docs/langgraph/getting-started/introduction');
28+
/**
29+
* `--nav-h` is route-dependent as of the navbar redesign: marketing routes keep
30+
* the measured 58/66/81 ladder, and /docs is a flat 58 at every width. Both
31+
* have to be measured, because the declared value is rounded up off the
32+
* rendered height and only a browser knows what that height is.
33+
*/
34+
const SURFACES = [
35+
{ name: 'marketing', url: '/', expectedNavH: (step: (typeof STEPS)[number]) => step.marketingNavH },
36+
{
37+
name: 'docs',
38+
url: '/docs/langgraph/getting-started/introduction',
39+
expectedNavH: (step: (typeof STEPS)[number]) => step.docsNavH,
40+
},
41+
];
3242

33-
const nav = page.locator('nav').first();
34-
await expect(nav).toBeVisible();
43+
for (const surface of SURFACES) {
44+
for (const step of STEPS) {
45+
test(`--nav-h matches the rendered nav on ${surface.name} at ${step.width}px (${step.note})`, async ({
46+
page,
47+
}) => {
48+
await page.setViewportSize({ width: step.width, height: 800 });
49+
await page.goto(surface.url);
3550

36-
const measured = await nav.evaluate((el) => el.getBoundingClientRect().height);
37-
const variable = await page.evaluate(() =>
38-
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),
39-
);
51+
const nav = page.locator('nav').first();
52+
await expect(nav).toBeVisible();
4053

41-
expect(variable).toBeGreaterThanOrEqual(measured);
42-
expect(variable - measured).toBeLessThanOrEqual(1);
43-
});
54+
const measured = await nav.evaluate((el) => el.getBoundingClientRect().height);
55+
const variable = await page.evaluate(() =>
56+
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),
57+
);
58+
59+
expect(
60+
variable,
61+
`--nav-h (${variable}) drifted from the rendered nav (${measured}) on ` +
62+
`${surface.name} at ${step.width}px. The variable no longer describes ` +
63+
`reality, so every offset built on it is wrong. Re-measure the nav.`,
64+
).toBeGreaterThanOrEqual(measured);
65+
expect(
66+
variable - measured,
67+
`--nav-h (${variable}) overshoots the rendered nav (${measured}) by ` +
68+
`${(variable - measured).toFixed(2)}px on ${surface.name} at ` +
69+
`${step.width}px. Overshoot becomes dead space above the content.`,
70+
).toBeLessThanOrEqual(1);
71+
72+
// The two checks above are self-consistency only: they confirm --nav-h
73+
// tracks whatever the nav happens to render, but they cannot see a
74+
// regression where the *ladder itself* collapses — e.g. the marketing
75+
// steps flattening to 58px like docs. If the declared value and the
76+
// rendered nav moved together, every self-consistency check above would
77+
// still pass. Pinning the declared value against the ladder we intend
78+
// catches that; it is a separate property from "does the variable match
79+
// what rendered."
80+
expect(
81+
variable,
82+
`--nav-h is ${variable} on ${surface.name} at ${step.width}px, but this ` +
83+
`ladder is meant to declare ${surface.expectedNavH(step)}px. This is a ` +
84+
`DESIGN change, not drift — the checks above still passed, so the ` +
85+
`variable and the nav moved together. Update this table only if the ` +
86+
`new ladder is intended.`,
87+
).toBe(surface.expectedNavH(step));
88+
});
89+
}
4490
}
4591

92+
test('the docs nav does not grow with the breakpoint', async ({ page }) => {
93+
const heights: number[] = [];
94+
for (const width of [375, 768, 1440]) {
95+
await page.setViewportSize({ width, height: 800 });
96+
await page.goto('/docs/langgraph/getting-started/introduction');
97+
heights.push(
98+
await page
99+
.locator('nav')
100+
.first()
101+
.evaluate((el) => el.getBoundingClientRect().height),
102+
);
103+
}
104+
const [phone] = heights;
105+
for (const height of heights) expect(Math.abs(height - phone)).toBeLessThanOrEqual(1);
106+
});
107+
108+
test('the marketing nav does grow with the breakpoint', async ({ page }) => {
109+
// Direct counterpart to "the docs nav does not grow with the breakpoint"
110+
// above: docs stays flat on purpose, and marketing is supposed to keep its
111+
// ladder. Stating both intents as tests means a future change that
112+
// accidentally flattens the marketing ladder (matching it to docs) fails
113+
// here even though every self-consistency check elsewhere in this file
114+
// would still pass.
115+
await page.setViewportSize({ width: 375, height: 800 });
116+
await page.goto('/');
117+
const phoneHeight = await page
118+
.locator('nav')
119+
.first()
120+
.evaluate((el) => el.getBoundingClientRect().height);
121+
122+
await page.setViewportSize({ width: 1440, height: 800 });
123+
await page.goto('/');
124+
const desktopHeight = await page
125+
.locator('nav')
126+
.first()
127+
.evaluate((el) => el.getBoundingClientRect().height);
128+
129+
expect(desktopHeight - phoneHeight).toBeGreaterThan(15);
130+
});
131+
46132
test('the docs column starts directly under the nav at a tablet width', async ({ page }) => {
47133
// The 15px overshoot showed up here as dead space above the breadcrumb.
48134
await page.setViewportSize({ width: 900, height: 800 });

0 commit comments

Comments
 (0)