Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
326d468
docs(specs): navbar redesign design
blove Sep 8, 2026
52fe492
docs(specs): correct the navbar spec's copy-scan and nav-height risks
blove Sep 8, 2026
115c01d
docs(plans): navbar redesign implementation plan
blove Sep 8, 2026
20d2a5c
feat(website): describe the navbar IA as data
blove Sep 8, 2026
788e4c2
docs(plans): teach the nav-config test about the dynamic solutions route
blove Sep 8, 2026
9163cc2
fix(website): deep-link the nav's solutions items and validate the dy…
blove Sep 8, 2026
a3a6d87
docs(plans): note that nx swallows the vitest path filter
blove Sep 8, 2026
b1dc60b
docs(plans): record the docs-structured-data timeout seen under full-…
blove Sep 8, 2026
0377cab
feat(website): derive the nav bar surface from route and scroll
blove Sep 8, 2026
19a5fd0
docs(plans): defer the nav surface's optimistic-atTop decision to Task 5
blove Sep 8, 2026
f759471
test(website): assert useNavSurface observes the sentinel it hands back
blove Sep 8, 2026
c915dd2
refactor(website): extract the desktop nav row into its own component
blove Sep 8, 2026
6775175
docs(plans): warn that some website specs are cwd-sensitive
blove Sep 8, 2026
88aadec
docs(plans): fix the nav CTA selector — Button emits no class
blove Sep 8, 2026
2af827e
feat(website): render the desktop nav as four triggers with hover panels
blove Sep 8, 2026
2ce68a6
fix(website): lay the libraries panel across its own width
blove Sep 8, 2026
a878008
docs(plans): correct the e2e invocation and warn about orphaned dev s…
blove Sep 8, 2026
7a0d2fe
docs(plans): require nx build per task — test and lint do not typecheck
blove Sep 8, 2026
17aed4b
fix(website): keep the nav's analytics id assignable to CtaId
blove Sep 8, 2026
94c126e
docs(specs): drop the shared morphing nav panel, on measurement
blove Sep 8, 2026
4dff77f
docs(specs): record the nav panel's gutter misalignment as a known gap
blove Sep 8, 2026
da3b06e
fix(website): put nav panels in tab order and make the panel grid gen…
blove Sep 8, 2026
44ba394
docs(plans): record two Playwright traps found in Task 4
blove Sep 8, 2026
b267c24
feat(website): render the nav transparent over the hero until scrolled
blove Sep 8, 2026
65602f9
docs(specs): record the nav's first-load solid flash and pin the cont…
blove Sep 8, 2026
2fe4405
docs(specs): correct the mobile section — the drawer is hidden on doc…
blove Sep 8, 2026
ef68cc1
feat(website): condense the nav to one height inside docs
blove Sep 8, 2026
b614bde
fix(website): give the demoted docs CTA a real hit area and pin it
blove Sep 8, 2026
2bd639b
test(website): pin the declared nav heights per surface
blove Sep 8, 2026
b9b6dd4
test(website): say which kind of nav-height failure you are looking at
blove Sep 8, 2026
136872a
docs(plans): warn Task 7 not to move the drawer inside <nav>
blove Sep 8, 2026
2c94e13
refactor(website): extract the mobile nav drawer into its own component
blove Sep 9, 2026
4862be8
docs(plans): record cross-worktree e2e port contention and the BASE_U…
blove Sep 9, 2026
89e84c8
feat(website): replace the mobile tab strip with a drill-in stack
blove Sep 9, 2026
8be0860
docs(plans): widen Task 9's dead-CSS sweep to the audited orphan set
blove Sep 9, 2026
7b1b041
fix(website): state the drawer's Escape rule and restore its footer lead
blove Sep 9, 2026
b147002
docs(plans): inverting a condition does not prove it equivalent
blove Sep 9, 2026
2880244
fix(website): stop Escape becoming a dead key at the drawer root
blove Sep 9, 2026
ad975f3
chore(website): delete the superseded nav exports and dead drawer CSS
blove Sep 9, 2026
5ff7b8b
docs(plans): write down the three deferrals that were only in reviewe…
blove Sep 9, 2026
9d4cfd6
test(website): open the mobile drawer in a real browser
blove Sep 9, 2026
aa661d0
refactor(website): render both nav panel surfaces from one body (#1084)
blove Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions apps/website/e2e/nav-drawer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { test, expect, type Page } from '@playwright/test';

/**
* jsdom has no CSS and no layout engine, so `src/components/shared/Nav.spec.tsx`
* can exercise every state transition of the mobile drawer (push, pop, focus,
* Escape) without ever being able to catch what only a real browser renders:
* the overlay's `top: calc(var(--nav-h) - 1px)` landing in the wrong place, an
* overlay host swallowing or mispositioning pointer events over a row (a
* failure mode this repo has shipped before), an `lg:hidden` regression
* leaking the hamburger onto desktop, or a scroll lock that never engages or
* never releases. These assertions are about geometry and real pointer
* delivery, so — like e2e/nav-panels.spec.ts for the desktop panels — they
* only mean anything in a real browser.
*/

const dialog = (page: Page) =>
page.getByRole('dialog', { name: 'Mobile navigation' });

test.describe('mobile nav drawer', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
});

test('opens and shows the four root rows', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open menu' }).click();

const drawer = dialog(page);
await expect(drawer).toBeVisible();

await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible();

const pricing = drawer.getByRole('link', { name: 'Pricing' });
await expect(pricing).toBeVisible();
await expect(pricing).toHaveAttribute('href', '/pricing');
});

test('a click on the Libraries row actually reaches it', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open menu' }).click();

const drawer = dialog(page);
const librariesRow = drawer.getByRole('button', { name: 'Libraries' });
await expect(librariesRow).toBeVisible();

// Independent of whether .click() "succeeds": ask the browser what
// element is actually topmost at the row's centre. An invisible overlay
// host sitting over the row — the exact failure mode this repo has
// shipped before — would still let Playwright's actionability checks
// pass (the row is visible and unobscured *by Playwright's own reading of
// the DOM*), but elementFromPoint reports what a real finger would hit.
const box = await librariesRow.boundingBox();
if (!box) throw new Error('Libraries row has no box');
const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const topmostIsRow = await page.evaluate(
({ x, y }) => {
const el = document.elementFromPoint(x, y);
return Boolean(el?.closest('.nav-mobile-row'));
},
centre,
);
expect(topmostIsRow).toBe(true);

await librariesRow.click();

// The level actually pushed: the four package links are showing, not
// just "the click handler ran" (which jsdom already proves).
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible();
await expect(drawer.getByRole('link', { name: /@threadplane\/ag-ui/ })).toBeVisible();
await expect(drawer.getByRole('link', { name: /@threadplane\/chat/ })).toBeVisible();
await expect(drawer.getByRole('link', { name: /@threadplane\/render/ })).toBeVisible();
await expect(drawer.getByText('Not sure which one?')).toBeVisible();
await expect(drawer.getByRole('link', { name: /Choosing an adapter/ })).toBeVisible();
});

test('pushes into Libraries and pops back to the root rows', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open menu' }).click();

const drawer = dialog(page);
await drawer.getByRole('button', { name: 'Libraries' }).click();

await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible();

await drawer.getByRole('button', { name: 'Back to menu' }).click();

await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible();
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toHaveCount(0);
});

test('sits flush under the nav', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open menu' }).click();

const drawer = dialog(page);
await expect(drawer).toBeVisible();

const navBox = await page.locator('nav.nav-bar').boundingBox();
const drawerBox = await drawer.boundingBox();
if (!navBox || !drawerBox) throw new Error('nav or drawer has no box');

// `top: calc(var(--nav-h) - 1px)` exists precisely so the drawer overlaps
// the nav's own 1px border rather than leaving a gap or an overshoot —
// the class of offset bug that has shipped on this repo before.
// `boundingBox()` returns {x, y, width, height} — not `bottom` — so the
// nav's bottom edge is derived, not read directly.
const navBottom = navBox.y + navBox.height;
expect(Math.abs(drawerBox.y - navBottom)).toBeLessThanOrEqual(1);
});

test('locks body scroll while open and releases it on close', async ({ page }) => {
await page.goto('/');

expect(
await page.evaluate(() => document.body.style.overflow),
).not.toBe('hidden');

await page.getByRole('button', { name: 'Open menu' }).click();
await expect(dialog(page)).toBeVisible();
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');

await page.getByRole('button', { name: 'Close menu' }).click();
await expect(dialog(page)).toBeHidden();
expect(
await page.evaluate(() => document.body.style.overflow),
).not.toBe('hidden');
});

test('the hamburger is not present at desktop width', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
});

test('opens pre-pushed to the docs level on /docs', async ({ page }) => {
await page.goto('/docs');
await page.getByRole('button', { name: 'Open menu' }).click();

const drawer = dialog(page);
await expect(drawer).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Search docs' })).toBeVisible();

// It opened at the docs level, not the root list of site triggers.
await expect(drawer.getByRole('button', { name: 'Libraries' })).toHaveCount(0);
await expect(drawer.getByRole('button', { name: 'Solutions' })).toHaveCount(0);
});
});
124 changes: 105 additions & 19 deletions apps/website/e2e/nav-height.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,118 @@ import { test, expect } from '@playwright/test';
* bug this guards against was fifteen.
*/
const STEPS = [
{ width: 375, note: 'phone — px-6 py-4' },
{ width: 767, note: 'phone — last px before md' },
{ width: 768, note: 'tablet — md padding, no lg link row' },
{ width: 1023, note: 'tablet — last px before lg' },
{ width: 1024, note: 'desktop — lg link row appears' },
{ width: 1440, note: 'desktop' },
{ width: 375, note: 'phone — px-6 py-4', marketingNavH: 58, docsNavH: 58 },
{ width: 767, note: 'phone — last px before md', marketingNavH: 58, docsNavH: 58 },
{ width: 768, note: 'tablet — md padding, no lg link row', marketingNavH: 66, docsNavH: 58 },
{ width: 1023, note: 'tablet — last px before lg', marketingNavH: 66, docsNavH: 58 },
{ width: 1024, note: 'desktop — lg link row appears', marketingNavH: 81, docsNavH: 58 },
{ width: 1440, note: 'desktop', marketingNavH: 81, docsNavH: 58 },
];

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

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

const measured = await nav.evaluate((el) => el.getBoundingClientRect().height);
const variable = await page.evaluate(() =>
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),
);
const nav = page.locator('nav').first();
await expect(nav).toBeVisible();

expect(variable).toBeGreaterThanOrEqual(measured);
expect(variable - measured).toBeLessThanOrEqual(1);
});
const measured = await nav.evaluate((el) => el.getBoundingClientRect().height);
const variable = await page.evaluate(() =>
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),
);

expect(
variable,
`--nav-h (${variable}) drifted from the rendered nav (${measured}) on ` +
`${surface.name} at ${step.width}px. The variable no longer describes ` +
`reality, so every offset built on it is wrong. Re-measure the nav.`,
).toBeGreaterThanOrEqual(measured);
expect(
variable - measured,
`--nav-h (${variable}) overshoots the rendered nav (${measured}) by ` +
`${(variable - measured).toFixed(2)}px on ${surface.name} at ` +
`${step.width}px. Overshoot becomes dead space above the content.`,
).toBeLessThanOrEqual(1);

// The two checks above are self-consistency only: they confirm --nav-h
// tracks whatever the nav happens to render, but they cannot see a
// regression where the *ladder itself* collapses — e.g. the marketing
// steps flattening to 58px like docs. If the declared value and the
// rendered nav moved together, every self-consistency check above would
// still pass. Pinning the declared value against the ladder we intend
// catches that; it is a separate property from "does the variable match
// what rendered."
expect(
variable,
`--nav-h is ${variable} on ${surface.name} at ${step.width}px, but this ` +
`ladder is meant to declare ${surface.expectedNavH(step)}px. This is a ` +
`DESIGN change, not drift — the checks above still passed, so the ` +
`variable and the nav moved together. Update this table only if the ` +
`new ladder is intended.`,
).toBe(surface.expectedNavH(step));
});
}
}

test('the docs nav does not grow with the breakpoint', async ({ page }) => {
const heights: number[] = [];
for (const width of [375, 768, 1440]) {
await page.setViewportSize({ width, height: 800 });
await page.goto('/docs/langgraph/getting-started/introduction');
heights.push(
await page
.locator('nav')
.first()
.evaluate((el) => el.getBoundingClientRect().height),
);
}
const [phone] = heights;
for (const height of heights) expect(Math.abs(height - phone)).toBeLessThanOrEqual(1);
});

test('the marketing nav does grow with the breakpoint', async ({ page }) => {
// Direct counterpart to "the docs nav does not grow with the breakpoint"
// above: docs stays flat on purpose, and marketing is supposed to keep its
// ladder. Stating both intents as tests means a future change that
// accidentally flattens the marketing ladder (matching it to docs) fails
// here even though every self-consistency check elsewhere in this file
// would still pass.
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/');
const phoneHeight = await page
.locator('nav')
.first()
.evaluate((el) => el.getBoundingClientRect().height);

await page.setViewportSize({ width: 1440, height: 800 });
await page.goto('/');
const desktopHeight = await page
.locator('nav')
.first()
.evaluate((el) => el.getBoundingClientRect().height);

expect(desktopHeight - phoneHeight).toBeGreaterThan(15);
});

test('the docs column starts directly under the nav at a tablet width', async ({ page }) => {
// The 15px overshoot showed up here as dead space above the breadcrumb.
await page.setViewportSize({ width: 900, height: 800 });
Expand Down
Loading
Loading