Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
133 changes: 99 additions & 34 deletions apps/website/e2e/nav-surface.spec.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,111 @@
import { test, expect } from '@playwright/test';
import { HERO_ROUTES } from '../src/components/shared/nav-config';
import { test, expect, type Page } from '@playwright/test';

/**
* A hand-maintained hero-route list drifts. A unit test over the list cannot
* catch a page that stopped rendering a hero, so the guard has to visit the
* page and read the computed background.
* The bar is one CSS surface: a translucent white over a backdrop blur, on
* every route at every scroll position. There is no route list, no sentinel,
* no observer and no `data-surface` attribute any more, so there is nothing
* here to assert about state — only that the single surface is actually the
* one that renders.
*
* The background is asserted with `toHaveCSS` rather than a one-shot
* `getComputedStyle` read: `.nav-bar` transitions `background` over 200ms, so
* the attribute flips a fifth of a second before the colour finishes moving,
* and a single read lands mid-fade on a partial alpha. `toHaveCSS` retries,
* which is what makes this assert the resting surface instead of the timing.
* Two things can silently take that away, which is why this suite reads the
* computed style out of a real browser rather than trusting the source:
*
* 1. The blur is prefixed by Lightning CSS, not by hand. Writing
* `-webkit-backdrop-filter` in chrome.css makes Lightning collapse the pair
* down to the prefixed property alone, and Chromium does not implement
* `-webkit-backdrop-filter` at all — the bar keeps its 72% alpha and loses
* the blur, which is an unreadable smear rather than a visible failure.
* 2. Anything that reintroduces a scroll- or route-dependent surface brings
* back the hydration flash this replaced.
*/
for (const route of HERO_ROUTES) {
test(`the nav is transparent at rest on ${route}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(route);
const nav = page.locator('nav').first();
await expect(nav).toHaveAttribute('data-surface', 'transparent');
await expect(nav).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');

const DOCS_ROUTE = '/docs/langgraph/getting-started/introduction';

interface BarSurface {
readonly background: string;
readonly backdropFilter: string;
readonly boxShadow: string;
readonly borderBottomColor: string;
}

async function readBarSurface(page: Page): Promise<BarSurface> {
return page.evaluate(() => {
const bar = document.querySelector('.nav-bar');
if (!bar) throw new Error('no .nav-bar on the page');
const style = getComputedStyle(bar);
return {
background: style.backgroundColor,
backdropFilter: style.backdropFilter,
boxShadow: style.boxShadow,
borderBottomColor: style.borderBottomColor,
};
});
}

/** The alpha of an `rgb()`/`rgba()` computed colour; 1 when none is present. */
function alphaOf(color: string): number {
const parts = color.match(/-?[\d.]+/g);
if (!parts) throw new Error(`unparseable colour: ${color}`);
return parts.length >= 4 ? Number(parts[3]) : 1;
}

test(`the nav solidifies once ${route} is scrolled`, async ({ page }) => {
for (const [label, route] of [
['the marketing hero', '/'],
['a docs page', DOCS_ROUTE],
] as const) {
test(`the nav bar is translucent and blurred on ${label}`, async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(route);
await page.mouse.wheel(0, 600);
await expect(page.locator('nav').first()).toBeVisible();

const surface = await readBarSurface(page);

// Strictly between 0 and 1: fully opaque is the old solid bar, fully
// transparent is the old hero state. Neither exists any more.
const alpha = alphaOf(surface.background);
expect(alpha, `background was ${surface.background}`).toBeGreaterThan(0);
expect(alpha, `background was ${surface.background}`).toBeLessThan(1);

const nav = page.locator('nav').first();
await expect(nav).toHaveAttribute('data-surface', 'solid');
await expect(nav).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
// `none` here is the Lightning-CSS prefix trap in the header comment: the
// translucency survives it, so only this read catches it.
expect(surface.backdropFilter).not.toBe('none');
expect(surface.backdropFilter).toContain('blur');

// The redesign removed the shadow deliberately; the hairline is the edge.
expect(surface.boxShadow).toBe('none');
expect(surface.borderBottomColor).not.toBe('rgba(0, 0, 0, 0)');
});
}

test('the nav is solid on a route with no hero', async ({ page }) => {
/**
* The guard for the whole simplification. `useNavSurface`, its 8px sentinel and
* its IntersectionObserver existed only to change this value on scroll; if any
* of that comes back — or a scroll listener, or a route-conditional class —
* these two reads stop matching.
*
* Proved non-vacuous by mutation: adding a rule that repaints `.nav-bar` once
* the page is scrolled fails this case on the background line.
*/
test('the nav bar surface does not change when the page is scrolled', async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/docs/langgraph/getting-started/introduction');
await expect(page.locator('nav').first()).toHaveAttribute(
'data-surface',
'solid',
);
await page.goto('/');
await expect(page.locator('nav').first()).toBeVisible();

const atTop = await readBarSurface(page);

await page.mouse.wheel(0, 900);
await page.waitForFunction(() => window.scrollY > 400);
// Long enough that a reintroduced 200ms surface transition would have
// finished, so a difference here is a real difference and not a fade caught
// mid-flight.
await page.waitForTimeout(600);
const scrolled = await readBarSurface(page);

expect(scrolled).toEqual(atTop);
});

/**
Expand All @@ -48,11 +115,9 @@ test('the nav is solid on a route with no hero', async ({ page }) => {
* also result from a filled button that happened to be 25px tall, so nothing
* else asserts the surface actually changed.
*
* Marketing is asserted as "has a fill", not "has a yellow fill": at rest
* (scroll 0) `/` is a HERO_ROUTES page with a transparent `.nav-bar`, and the
* transparent-surface rule inverts the CTA to a navy fill rather than leaving
* it in its normal yellow — asserting a specific colour here would encode
* that scroll-position inversion and break the moment either theme changes.
* Marketing is asserted as "has a fill", not "has a yellow fill", so the case
* survives a retheme; the docs side can name its colour because the demotion is
* specifically to `--color-accent` as a text link.
*
* Both reads use `toHaveCSS`, not a one-shot `getComputedStyle`: the button
* itself transitions `background-color`/`color` over 120ms on mount, so an
Expand All @@ -73,7 +138,7 @@ test('the nav CTA is a filled button on marketing but a text link on docs', asyn
'rgba(0, 0, 0, 0)',
);

await page.goto('/docs/langgraph/getting-started/introduction');
await page.goto(DOCS_ROUTE);
const docsCta = page
.locator('nav')
.first()
Expand Down
52 changes: 22 additions & 30 deletions apps/website/src/components/shared/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { getLibraryConfig, type LibraryId } from '../../lib/docs-config';
import { LogoMark } from '../ui/LogoMark';
import { NavDesktop } from './NavDesktop';
import { NavMobile } from './NavMobile';
import { useNavSurface } from './useNavSurface';

export function Nav() {
const pathname = usePathname();
Expand All @@ -21,38 +20,31 @@ export function Nav() {
const docsLibrary = (getLibraryConfig(activeLibrary)?.id ??
null) as LibraryId | null;
const navRef = useRef<HTMLElement>(null);
const { surface, sentinelRef } = useNavSurface(pathname);

return (
<>
{/* Outside the fixed <nav> so it actually scrolls: `body` is its
containing block, so `top: 0` is the top of the document. */}
<div ref={sentinelRef} className="nav-scroll-sentinel" aria-hidden="true" />
<nav
ref={navRef}
className="fixed top-0 left-0 right-0 z-50 nav-bar"
data-site-navigation=""
data-surface={surface}
data-route={isDocsPage ? 'docs' : 'marketing'}
>
{/* Top bar */}
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5">
<Link href="/" className="nav-logo-link">
<LogoMark size="md" />
</Link>
<nav
ref={navRef}
className="fixed top-0 left-0 right-0 z-50 nav-bar"
data-site-navigation=""
data-route={isDocsPage ? 'docs' : 'marketing'}
>
{/* Top bar */}
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5">
<Link href="/" className="nav-logo-link">
<LogoMark size="md" />
</Link>

{/* Desktop links */}
<NavDesktop />
{/* Desktop links */}
<NavDesktop />

<NavMobile
isDocsPage={isDocsPage}
docsLibrary={docsLibrary}
activeSection={activeSection}
activeSlug={activeSlug}
navRef={navRef}
/>
</div>
</nav>
</>
<NavMobile
isDocsPage={isDocsPage}
docsLibrary={docsLibrary}
activeSection={activeSection}
activeSlug={activeSlug}
navRef={navRef}
/>
</div>
</nav>
);
}
8 changes: 1 addition & 7 deletions apps/website/src/components/shared/nav-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { HERO_ROUTES, NAV_TRIGGERS, navItems } from './nav-config';
import { NAV_TRIGGERS, navItems } from './nav-config';
import { docsConfig } from '../../lib/docs-config';
import { getAllSolutionSlugs } from '../../lib/solutions-data';

Expand Down Expand Up @@ -88,10 +88,4 @@ describe('nav-config', () => {
'Pricing',
]);
});

it('lists only routes that actually render a hero', () => {
// Landing pages join this list in the change that gives each one a hero.
// Listing a white page here renders navy links over nothing.
expect(HERO_ROUTES).toEqual(['/']);
});
});
10 changes: 0 additions & 10 deletions apps/website/src/components/shared/nav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,3 @@ export function navItems(): readonly NavItem[] {
return trigger.panel.footer ? [...items, trigger.panel.footer] : items;
});
}

/**
* Routes whose page opens on a colored hero, where the bar renders transparent
* at scroll 0.
*
* `/` is the only one today. The library landing pages open on white; listing
* one before it has a hero renders navy links over a white page with no bar
* behind them. Each page joins this list in the change that gives it a hero.
*/
export const HERO_ROUTES: readonly string[] = ['/'];
113 changes: 0 additions & 113 deletions apps/website/src/components/shared/useNavSurface.spec.tsx

This file was deleted.

Loading
Loading