Skip to content

Commit 20adc5f

Browse files
bloveclaude
andcommitted
refactor(website): make the nav bar one translucent CSS surface
The bar switched between a transparent hero surface and a solid white one. That switch cost a `useNavSurface` hook, an 8px sentinel div, an IntersectionObserver, a `getBoundingClientRect` seed, a `data-surface` attribute, a `HERO_ROUTES` list and 12 tests — all of which are deleted here. It also had a defect that could not be fixed from inside the mechanism. `atTop` initialised to `false`, so the server rendered `data-surface="solid"`: the server cannot know scroll position and effects do not run during SSR, so the browser painted a white bar over the yellow hero for the duration of hydration (~250ms against `next dev`) before flipping to transparent. Seeding `true` only moves the same flash to already-scrolled loads. One CSS surface replaces it: a 72% white over `saturate(180%) blur(14px)`. It reads over the hero's yellow and over white content alike, so there is nothing to switch — and, because it has no hydration-dependent state at all, there is no flash to fix. Measured at 1440px against `next dev`, sampling rendered pixels rather than estimating the blend: - over the yellow hero the bar resolves to rgb(255, 232, 184); the nav links (`--color-text-secondary`, rgb(70, 70, 70)) sit at 7.86:1 on it, so they stay grey rather than moving to navy - over white content (scrolled `/`, `/docs`, `/pricing`) the bar resolves to a clean rgb(255, 255, 255) behind the hairline; links 9.44:1 - over the dark reliability band it resolves to rgb(193, 198, 204); links 5.49:1, still past 4.5:1 - the blur samples live content: a hard section edge behind the bar renders as a ~56px ramp through it, and the sampled bar colour changes with scroll Two fallbacks are required because a translucent bar that fails to blur is an unreadable smear: `@supports not (backdrop-filter: ...)` and `prefers-reduced-transparency: reduce` both fall back to the opaque surface. The `-webkit-` prefix is deliberately NOT hand-written — Lightning CSS emits it, and hand-writing it makes Lightning collapse the pair to the prefixed property alone, which Chromium does not implement at all. That silently disables the blur, and the @supports fallback cannot catch it. See the comment in chrome.css. `e2e/nav-surface.spec.ts` is rewritten to assert the single surface — alpha strictly between 0 and 1, a non-`none` backdrop-filter, no box-shadow, and an identical computed surface before and after a scroll. That last case is the guard for the whole simplification and was proved non-vacuous by mutation. The `/docs` condensation (`data-route`, the flat 58px, the demoted CTA) is untouched, and its test is kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e36ccdb commit 20adc5f

7 files changed

Lines changed: 154 additions & 279 deletions

File tree

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

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,111 @@
1-
import { test, expect } from '@playwright/test';
2-
import { HERO_ROUTES } from '../src/components/shared/nav-config';
1+
import { test, expect, type Page } from '@playwright/test';
32

43
/**
5-
* A hand-maintained hero-route list drifts. A unit test over the list cannot
6-
* catch a page that stopped rendering a hero, so the guard has to visit the
7-
* page and read the computed background.
4+
* The bar is one CSS surface: a translucent white over a backdrop blur, on
5+
* every route at every scroll position. There is no route list, no sentinel,
6+
* no observer and no `data-surface` attribute any more, so there is nothing
7+
* here to assert about state — only that the single surface is actually the
8+
* one that renders.
89
*
9-
* The background is asserted with `toHaveCSS` rather than a one-shot
10-
* `getComputedStyle` read: `.nav-bar` transitions `background` over 200ms, so
11-
* the attribute flips a fifth of a second before the colour finishes moving,
12-
* and a single read lands mid-fade on a partial alpha. `toHaveCSS` retries,
13-
* which is what makes this assert the resting surface instead of the timing.
10+
* Two things can silently take that away, which is why this suite reads the
11+
* computed style out of a real browser rather than trusting the source:
12+
*
13+
* 1. The blur is prefixed by Lightning CSS, not by hand. Writing
14+
* `-webkit-backdrop-filter` in chrome.css makes Lightning collapse the pair
15+
* down to the prefixed property alone, and Chromium does not implement
16+
* `-webkit-backdrop-filter` at all — the bar keeps its 72% alpha and loses
17+
* the blur, which is an unreadable smear rather than a visible failure.
18+
* 2. Anything that reintroduces a scroll- or route-dependent surface brings
19+
* back the hydration flash this replaced.
1420
*/
15-
for (const route of HERO_ROUTES) {
16-
test(`the nav is transparent at rest on ${route}`, async ({ page }) => {
17-
await page.setViewportSize({ width: 1440, height: 900 });
18-
await page.goto(route);
19-
const nav = page.locator('nav').first();
20-
await expect(nav).toHaveAttribute('data-surface', 'transparent');
21-
await expect(nav).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
21+
22+
const DOCS_ROUTE = '/docs/langgraph/getting-started/introduction';
23+
24+
interface BarSurface {
25+
readonly background: string;
26+
readonly backdropFilter: string;
27+
readonly boxShadow: string;
28+
readonly borderBottomColor: string;
29+
}
30+
31+
async function readBarSurface(page: Page): Promise<BarSurface> {
32+
return page.evaluate(() => {
33+
const bar = document.querySelector('.nav-bar');
34+
if (!bar) throw new Error('no .nav-bar on the page');
35+
const style = getComputedStyle(bar);
36+
return {
37+
background: style.backgroundColor,
38+
backdropFilter: style.backdropFilter,
39+
boxShadow: style.boxShadow,
40+
borderBottomColor: style.borderBottomColor,
41+
};
2242
});
43+
}
44+
45+
/** The alpha of an `rgb()`/`rgba()` computed colour; 1 when none is present. */
46+
function alphaOf(color: string): number {
47+
const parts = color.match(/-?[\d.]+/g);
48+
if (!parts) throw new Error(`unparseable colour: ${color}`);
49+
return parts.length >= 4 ? Number(parts[3]) : 1;
50+
}
2351

24-
test(`the nav solidifies once ${route} is scrolled`, async ({ page }) => {
52+
for (const [label, route] of [
53+
['the marketing hero', '/'],
54+
['a docs page', DOCS_ROUTE],
55+
] as const) {
56+
test(`the nav bar is translucent and blurred on ${label}`, async ({
57+
page,
58+
}) => {
2559
await page.setViewportSize({ width: 1440, height: 900 });
2660
await page.goto(route);
27-
await page.mouse.wheel(0, 600);
61+
await expect(page.locator('nav').first()).toBeVisible();
62+
63+
const surface = await readBarSurface(page);
64+
65+
// Strictly between 0 and 1: fully opaque is the old solid bar, fully
66+
// transparent is the old hero state. Neither exists any more.
67+
const alpha = alphaOf(surface.background);
68+
expect(alpha, `background was ${surface.background}`).toBeGreaterThan(0);
69+
expect(alpha, `background was ${surface.background}`).toBeLessThan(1);
2870

29-
const nav = page.locator('nav').first();
30-
await expect(nav).toHaveAttribute('data-surface', 'solid');
31-
await expect(nav).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
71+
// `none` here is the Lightning-CSS prefix trap in the header comment: the
72+
// translucency survives it, so only this read catches it.
73+
expect(surface.backdropFilter).not.toBe('none');
74+
expect(surface.backdropFilter).toContain('blur');
75+
76+
// The redesign removed the shadow deliberately; the hairline is the edge.
77+
expect(surface.boxShadow).toBe('none');
78+
expect(surface.borderBottomColor).not.toBe('rgba(0, 0, 0, 0)');
3279
});
3380
}
3481

35-
test('the nav is solid on a route with no hero', async ({ page }) => {
82+
/**
83+
* The guard for the whole simplification. `useNavSurface`, its 8px sentinel and
84+
* its IntersectionObserver existed only to change this value on scroll; if any
85+
* of that comes back — or a scroll listener, or a route-conditional class —
86+
* these two reads stop matching.
87+
*
88+
* Proved non-vacuous by mutation: adding a rule that repaints `.nav-bar` once
89+
* the page is scrolled fails this case on the background line.
90+
*/
91+
test('the nav bar surface does not change when the page is scrolled', async ({
92+
page,
93+
}) => {
3694
await page.setViewportSize({ width: 1440, height: 900 });
37-
await page.goto('/docs/langgraph/getting-started/introduction');
38-
await expect(page.locator('nav').first()).toHaveAttribute(
39-
'data-surface',
40-
'solid',
41-
);
95+
await page.goto('/');
96+
await expect(page.locator('nav').first()).toBeVisible();
97+
98+
const atTop = await readBarSurface(page);
99+
100+
await page.mouse.wheel(0, 900);
101+
await page.waitForFunction(() => window.scrollY > 400);
102+
// Long enough that a reintroduced 200ms surface transition would have
103+
// finished, so a difference here is a real difference and not a fade caught
104+
// mid-flight.
105+
await page.waitForTimeout(600);
106+
const scrolled = await readBarSurface(page);
107+
108+
expect(scrolled).toEqual(atTop);
42109
});
43110

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

76-
await page.goto('/docs/langgraph/getting-started/introduction');
141+
await page.goto(DOCS_ROUTE);
77142
const docsCta = page
78143
.locator('nav')
79144
.first()

apps/website/src/components/shared/Nav.tsx

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { getLibraryConfig, type LibraryId } from '../../lib/docs-config';
66
import { LogoMark } from '../ui/LogoMark';
77
import { NavDesktop } from './NavDesktop';
88
import { NavMobile } from './NavMobile';
9-
import { useNavSurface } from './useNavSurface';
109

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

2624
return (
27-
<>
28-
{/* Outside the fixed <nav> so it actually scrolls: `body` is its
29-
containing block, so `top: 0` is the top of the document. */}
30-
<div ref={sentinelRef} className="nav-scroll-sentinel" aria-hidden="true" />
31-
<nav
32-
ref={navRef}
33-
className="fixed top-0 left-0 right-0 z-50 nav-bar"
34-
data-site-navigation=""
35-
data-surface={surface}
36-
data-route={isDocsPage ? 'docs' : 'marketing'}
37-
>
38-
{/* Top bar */}
39-
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5">
40-
<Link href="/" className="nav-logo-link">
41-
<LogoMark size="md" />
42-
</Link>
25+
<nav
26+
ref={navRef}
27+
className="fixed top-0 left-0 right-0 z-50 nav-bar"
28+
data-site-navigation=""
29+
data-route={isDocsPage ? 'docs' : 'marketing'}
30+
>
31+
{/* Top bar */}
32+
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5">
33+
<Link href="/" className="nav-logo-link">
34+
<LogoMark size="md" />
35+
</Link>
4336

44-
{/* Desktop links */}
45-
<NavDesktop />
37+
{/* Desktop links */}
38+
<NavDesktop />
4639

47-
<NavMobile
48-
isDocsPage={isDocsPage}
49-
docsLibrary={docsLibrary}
50-
activeSection={activeSection}
51-
activeSlug={activeSlug}
52-
navRef={navRef}
53-
/>
54-
</div>
55-
</nav>
56-
</>
40+
<NavMobile
41+
isDocsPage={isDocsPage}
42+
docsLibrary={docsLibrary}
43+
activeSection={activeSection}
44+
activeSlug={activeSlug}
45+
navRef={navRef}
46+
/>
47+
</div>
48+
</nav>
5749
);
5850
}

apps/website/src/components/shared/nav-config.spec.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
22
import { dirname, join } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import { describe, expect, it } from 'vitest';
5-
import { HERO_ROUTES, NAV_TRIGGERS, navItems } from './nav-config';
5+
import { NAV_TRIGGERS, navItems } from './nav-config';
66
import { docsConfig } from '../../lib/docs-config';
77
import { getAllSolutionSlugs } from '../../lib/solutions-data';
88

@@ -88,10 +88,4 @@ describe('nav-config', () => {
8888
'Pricing',
8989
]);
9090
});
91-
92-
it('lists only routes that actually render a hero', () => {
93-
// Landing pages join this list in the change that gives each one a hero.
94-
// Listing a white page here renders navy links over nothing.
95-
expect(HERO_ROUTES).toEqual(['/']);
96-
});
9791
});

apps/website/src/components/shared/nav-config.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,3 @@ export function navItems(): readonly NavItem[] {
243243
return trigger.panel.footer ? [...items, trigger.panel.footer] : items;
244244
});
245245
}
246-
247-
/**
248-
* Routes whose page opens on a colored hero, where the bar renders transparent
249-
* at scroll 0.
250-
*
251-
* `/` is the only one today. The library landing pages open on white; listing
252-
* one before it has a hero renders navy links over a white page with no bar
253-
* behind them. Each page joins this list in the change that gives it a hero.
254-
*/
255-
export const HERO_ROUTES: readonly string[] = ['/'];

apps/website/src/components/shared/useNavSurface.spec.tsx

Lines changed: 0 additions & 113 deletions
This file was deleted.

0 commit comments

Comments
 (0)