Skip to content

Commit dd0fe38

Browse files
bloveclaude
andcommitted
fix(website): close the docs shell's nav-height and column-measure defects
Polish pass over the /docs single-pane reading experience, following the footer removal in #932. Three real defects, each measured rather than inferred, plus the parity gap on the one route that wore no chrome. --nav-h was wrong by 15px from 768px to 1023px. The nav has three heights, not two: padding steps at md (768) but the tall `hidden lg:flex` link row only appears at lg (1024), leaving a 66px nav in between. The variable jumped straight to 81px at md, so every offset in that band overshot — dead space above the docs column, and the mobile drawer (top: nav-h - 1px) hanging 14px below the nav it attaches to. This is the nav-height coupling the migration comment in pages.css deferred; it lived in chrome.css, not in the docs shell, and it is site-wide. The article's `overflow-x-hidden` is removed rather than relocated. It was redundant — global.css already clips the body — and it was the very mistake that rule's own comment warns about: `overflow-x: hidden` computes `overflow-y: auto`, so it made every docs article a scroll container. All 123 docs URLs were swept at 375px with it removed; none overflow. The breadcrumb/page-header block now shares the article's md:max-w-3xl measure, which the article and the prev/next rail already used. It had stretched to the full content width, floating PageActions ~500px right of its column (1272px against 768px at 1920). /docs/choosing-an-adapter gains the TOC rail. It carries as many headings as any library page but had no rail. It stays library-neutral, so it takes no breadcrumb or page header — both are keyed to a library it deliberately has not picked. Tests: e2e/nav-height.spec.ts pins --nav-h to the rendered nav at all six breakpoint edges (jsdom cannot measure layout, so a real browser is the only place these can be compared). e2e/docs-shell.spec.ts covers the TOC rail, which had no tests at all, and the shared column edge. The existing horizontal-overflow guard in website.spec.ts was vacuous: it measured documentElement.scrollWidth, which the global body clip pins to the viewport, so its 24 assertions could never fail. It now measures whether content escapes its own column, exempting content inside its own horizontal scroller. Every new and rewritten guard was mutation-tested against the defect it describes. Verified: 447 unit tests pass (unchanged), 72 e2e pass (59 pre-existing + 13 new), lint 0 errors, production build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9fc6a17 commit dd0fe38

8 files changed

Lines changed: 258 additions & 20 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
const ARTICLE = '/docs/langgraph/getting-started/introduction';
4+
5+
/**
6+
* The docs shell is one reading pane: a sticky control plane on the left, one
7+
* prose column, and a sticky TOC rail on the right. These guard the parts of
8+
* that whose failure mode is silence — a rail that stops tracking, a column
9+
* that stops sharing its measure — and which jsdom cannot see.
10+
*/
11+
12+
test.describe('DocsTOC rail', () => {
13+
test('tracks the reading position on a hard load', async ({ page }) => {
14+
await page.setViewportSize({ width: 1440, height: 900 });
15+
await page.goto(ARTICLE);
16+
await expect(page.locator('.docs-toc-link').first()).toBeVisible();
17+
18+
// Nothing is active at the top: the first heading is below the reading line.
19+
await expect(page.locator('.docs-toc-link[data-active]')).toHaveCount(0);
20+
21+
await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' }));
22+
await expect
23+
.poll(() =>
24+
page
25+
.locator('.docs-toc-link[data-active]')
26+
.evaluateAll((els) => els.map((e) => e.getAttribute('href'))),
27+
)
28+
.toEqual(['#connect-with-angular']);
29+
30+
// ...and it follows the scroll rather than latching on the first match.
31+
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
32+
await expect.poll(() => page.locator('.docs-toc-link[data-active]').count()).toBe(0);
33+
});
34+
35+
test('every rail link resolves to a heading in the article', async ({ page }) => {
36+
await page.setViewportSize({ width: 1440, height: 900 });
37+
await page.goto(ARTICLE);
38+
39+
const unresolved = await page.evaluate(() =>
40+
[...document.querySelectorAll('.docs-toc-link')]
41+
.map((a) => (a as HTMLAnchorElement).getAttribute('href') ?? '')
42+
.filter((href) => !document.getElementById(href.slice(1))),
43+
);
44+
expect(unresolved).toEqual([]);
45+
});
46+
47+
test('the library-neutral adapter page gets the same rail', async ({ page }) => {
48+
await page.setViewportSize({ width: 1440, height: 900 });
49+
await page.goto('/docs/choosing-an-adapter');
50+
await expect(page.locator('.docs-toc')).toBeVisible();
51+
expect(await page.locator('.docs-toc-link').count()).toBeGreaterThan(3);
52+
});
53+
});
54+
55+
test.describe('docs shell layout', () => {
56+
test('the sticky rails hold through a full-page scroll', async ({ page }) => {
57+
await page.setViewportSize({ width: 1440, height: 900 });
58+
await page.goto(ARTICLE);
59+
60+
const navH = await page.evaluate(() =>
61+
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),
62+
);
63+
const tops = async () => ({
64+
plane: await page.locator('.docs-control-plane').evaluate((el) => Math.round(el.getBoundingClientRect().top)),
65+
toc: await page.locator('.docs-toc').evaluate((el) => Math.round(el.getBoundingClientRect().top)),
66+
});
67+
68+
expect(await tops()).toEqual({ plane: navH, toc: navH });
69+
await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' }));
70+
expect(await tops()).toEqual({ plane: navH, toc: navH });
71+
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
72+
expect(await tops()).toEqual({ plane: navH, toc: navH });
73+
});
74+
75+
test('breadcrumb, prose and prev/next share one right edge', async ({ page }) => {
76+
// The header block used to stretch to the full content width while the
77+
// article and the prev/next rail sat at max-w-3xl, so PageActions floated
78+
// ~500px right of the column it belongs to.
79+
await page.setViewportSize({ width: 1920, height: 1000 });
80+
await page.goto(ARTICLE);
81+
82+
const right = (selector: string) =>
83+
page.locator(selector).first().evaluate((el) => Math.round(el.getBoundingClientRect().right));
84+
85+
const header = await right('.docs-page-header');
86+
const article = await right('article');
87+
const prevNext = await right('.docs-prevnext');
88+
89+
// The header and prev/next sit inside the article's horizontal padding.
90+
expect(article - header).toBeLessThanOrEqual(48);
91+
expect(header).toBe(prevNext);
92+
});
93+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* `--nav-h` (styles/chrome.css) is the single source of truth for every offset
5+
* against the fixed nav: the docs shell's top padding, the sticky sidebar and
6+
* TOC rails, the mobile drawer's `top`, and html's scroll-padding.
7+
*
8+
* Its value is measured from the rendered nav, not derived from the classes, so
9+
* it silently drifts whenever Nav.tsx changes what it shows at a breakpoint —
10+
* which is exactly how the 768–1023px band came to overshoot by 15px. jsdom
11+
* cannot measure layout, so this is the only place the two can be compared.
12+
*
13+
* The tolerance is 1px, and deliberately not 0: the declared values round *up*
14+
* off the measured height (58/66/81 against 57/65/81 in Chrome at dpr 1) so the
15+
* offset always clears the nav rather than tucking content under it, and the
16+
* sub-pixel height itself moves with font rendering. 1px is the rounding; the
17+
* bug this guards against was fifteen.
18+
*/
19+
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' },
26+
];
27+
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');
32+
33+
const nav = page.locator('nav').first();
34+
await expect(nav).toBeVisible();
35+
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+
);
40+
41+
expect(variable).toBeGreaterThanOrEqual(measured);
42+
expect(variable - measured).toBeLessThanOrEqual(1);
43+
});
44+
}
45+
46+
test('the docs column starts directly under the nav at a tablet width', async ({ page }) => {
47+
// The 15px overshoot showed up here as dead space above the breadcrumb.
48+
await page.setViewportSize({ width: 900, height: 800 });
49+
await page.goto('/docs/langgraph/getting-started/introduction');
50+
51+
const navBottom = await page
52+
.locator('nav')
53+
.first()
54+
.evaluate((el) => el.getBoundingClientRect().bottom);
55+
const shellTop = await page
56+
.locator('.docs-shell-page')
57+
.evaluate((el) => el.getBoundingClientRect().top + parseFloat(getComputedStyle(el).paddingTop));
58+
59+
expect(Math.abs(shellTop - navBottom)).toBeLessThanOrEqual(1);
60+
});
61+
62+
test('the mobile drawer hangs flush off the nav on a tablet width', async ({ page }) => {
63+
// The drawer is positioned at `top: calc(var(--nav-h) - 1px)`, so a wrong
64+
// --nav-h shows up here as a visible gap between the nav and the panel.
65+
await page.setViewportSize({ width: 900, height: 800 });
66+
await page.goto('/docs/langgraph/getting-started/introduction');
67+
68+
await page.locator('.nav-hamburger').click();
69+
const overlay = page.locator('.nav-mobile-overlay');
70+
await expect(overlay).toBeVisible();
71+
72+
const navBottom = await page
73+
.locator('nav')
74+
.first()
75+
.evaluate((el) => el.getBoundingClientRect().bottom);
76+
const overlayTop = await overlay.evaluate((el) => el.getBoundingClientRect().top);
77+
78+
// Flush or overlapping the nav's bottom border — never a gap below it.
79+
expect(overlayTop - navBottom).toBeLessThanOrEqual(0);
80+
expect(overlayTop - navBottom).toBeGreaterThanOrEqual(-2);
81+
});

apps/website/e2e/website.spec.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,37 @@ test('representative docs pages do not create page-level horizontal overflow', a
413413

414414
for (const route of routes) {
415415
await page.goto(route);
416-
const overflow = await page.evaluate(() => (
417-
document.documentElement.scrollWidth - document.documentElement.clientWidth
418-
));
419416

420-
expect(overflow, `${route} at ${width}px`).toBeLessThanOrEqual(1);
417+
// NOT documentElement.scrollWidth. global.css clips the body
418+
// (`overflow-x: clip`) precisely so overflow can never reach the layout
419+
// viewport, which means that number is pinned to the viewport width and
420+
// every assertion on it passed vacuously — confirmed by injecting a
421+
// 2000px-wide element and watching it stay put. Ask the question the
422+
// clip is hiding instead: does anything escape its own column? Content
423+
// inside a horizontal scroller (code blocks, wide tables) is exempt —
424+
// scrolling there is the intended containment.
425+
const escaped = await page.evaluate(() => {
426+
const column = document.querySelector('article') ?? document.querySelector('main');
427+
if (!column) return ['no column'];
428+
const box = column.getBoundingClientRect();
429+
const inScroller = (el: Element) => {
430+
let p = el.parentElement;
431+
while (p && p !== column) {
432+
const ox = getComputedStyle(p).overflowX;
433+
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip') return true;
434+
p = p.parentElement;
435+
}
436+
return false;
437+
};
438+
return [...column.querySelectorAll('*')]
439+
.filter((el) => {
440+
const r = el.getBoundingClientRect();
441+
return r.width > 0 && r.right > box.right + 1 && !inScroller(el);
442+
})
443+
.map((el) => `${el.tagName}.${String(el.className).slice(0, 40)}`);
444+
});
445+
446+
expect(escaped, `${route} at ${width}px`).toEqual([]);
421447
}
422448
}
423449
});

apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,20 @@ export default async function DocsPage({ params }: DocsRouteProps) {
9898
/>
9999
<div className="flex-1 flex min-w-0 docs-shell-body">
100100
<div className="flex-1 min-w-0">
101-
<div className="px-4 sm:px-6 md:px-12 pt-6">
101+
{/* Same measure as the article and the prev/next rail below it, so the
102+
* whole column shares one right edge. Without md:max-w-3xl this
103+
* block stretched to the full content width and PageActions floated
104+
* ~500px right of the prose it belongs to (1272px vs 768px at
105+
* 1920). */}
106+
<div className="px-4 sm:px-6 md:px-12 md:max-w-3xl pt-6">
102107
<DocsBreadcrumb library={library as LibraryId} section={section} slug={slug} title={doc.title} />
103108
<DocsPageHeader
104109
library={library as LibraryId}
105110
section={section}
106111
actions={<PageActions library={library} section={section} slug={slug} headings={headings} />}
107112
/>
108113
</div>
109-
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
114+
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl">
110115
<MdxRenderer source={doc.body} />
111116
</article>
112117
{section === 'api' && (() => {

apps/website/src/app/docs/choosing-an-adapter/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { notFound } from 'next/navigation';
44
import { DocsControlPlane } from '../../../components/docs/DocsControlPlane';
55
import { DocsSearch } from '../../../components/docs/DocsSearch';
66
import { MdxRenderer } from '../../../components/docs/MdxRenderer';
7+
import { DocsTOC } from '../../../components/docs/DocsTOC';
78
import { createPageMetadata } from '../../../lib/site-metadata';
9+
import { extractHeadings } from '../../../lib/extract-headings';
810
import { stripFrontmatter } from '../../../lib/docs';
911

1012
const PAGE_TITLE = 'Choosing an adapter';
@@ -32,6 +34,7 @@ export default function ChoosingAnAdapterPage() {
3234
if (!filePath) notFound();
3335

3436
const source = stripFrontmatter(fs.readFileSync(filePath, 'utf8'));
37+
const headings = extractHeadings(source);
3538

3639
return (
3740
<div className="flex min-h-screen docs-shell-page">
@@ -48,11 +51,16 @@ export default function ChoosingAnAdapterPage() {
4851
<div className="flex-1 min-w-0">
4952
<article
5053
aria-label={PAGE_TITLE}
51-
className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden"
54+
className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl"
5255
>
5356
<MdxRenderer source={source} />
5457
</article>
5558
</div>
59+
{/* This page carries as many headings as any library page, so it gets
60+
* the same rail. It stays library-neutral, so it takes no breadcrumb
61+
* or page header — both are keyed to a library it deliberately
62+
* has not picked. */}
63+
<DocsTOC headings={headings} />
5664
</div>
5765
</div>
5866
);

apps/website/src/styles/chrome.css

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,31 @@
1010
* Fonts: unified onto the next/font vars — see the font note atop ui.css.
1111
*/
1212

13-
/* One nav height. The fixed nav measures 58px (px-6 py-4) and 81px at the md
14-
* breakpoint (md:px-8 md:py-5, 1px border included). Everything that offsets
15-
* against the nav — the docs shell's top padding, both sticky rails, the
16-
* mobile overlay, and html's scroll-padding for anchor jumps — reads this one
17-
* variable instead of hardcoding its own guess (the old hardcoded 80px left
18-
* 22px of dead space on phones; anchors landed 81px under the nav). */
13+
/* One nav height. Everything that offsets against the nav — the docs shell's
14+
* top padding, both sticky rails, the mobile overlay, and html's scroll-padding
15+
* for anchor jumps — reads this one variable instead of hardcoding its own
16+
* guess (the old hardcoded 80px left 22px of dead space on phones; anchors
17+
* landed 81px under the nav).
18+
*
19+
* The nav has THREE heights, not two, because padding and content step at
20+
* different breakpoints (Nav.tsx): the inner row is `px-6 py-4 md:px-8 md:py-5`,
21+
* so padding grows at md (768px), but the tall `hidden lg:flex` link row only
22+
* appears at lg (1024px). Between them the nav is 66px — a 25px logo in 40px of
23+
* padding plus the 1px border. This variable used to jump straight to 81px at
24+
* md, so from 768px to 1023px every offset overshot by 15px: dead space above
25+
* the docs column, and the mobile drawer (top: nav-h - 1px) hung 14px below the
26+
* nav it is supposed to be attached to. These are measured, not derived, so
27+
* only a real browser can hold them honest: e2e/nav-height.spec.ts asserts
28+
* nav.height === --nav-h at each of the three steps. */
1929
:root {
2030
--nav-h: 58px;
2131
}
2232
@media (min-width: 768px) {
33+
:root {
34+
--nav-h: 66px;
35+
}
36+
}
37+
@media (min-width: 1024px) {
2338
:root {
2439
--nav-h: 81px;
2540
}

apps/website/src/styles/docs.css

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -978,9 +978,8 @@
978978
font-weight: 500;
979979
}
980980

981-
/* DocsBreadcrumb — verbatim, including the inconsistent li/separator
982-
* typography (crumb/sep were separate style-variable shapes; a later
983-
* polish pass reconciles them, not this migration). */
981+
/* DocsBreadcrumb — the crumb/separator typography the migration left
982+
* inconsistent is reconciled below, on the list rather than the links. */
984983
.docs-crumb-nav {
985984
margin-bottom: 16px;
986985
}

apps/website/src/styles/pages.css

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,21 @@
249249
max-width: 760px;
250250
}
251251

252-
/* Docs shell — app/docs/[library]/[section]/[slug]/page.tsx
253-
* paddingTop: 80 and the overflow-x-hidden utility are known defects
254-
* (nav-height coupling) migrated VERBATIM here; a later project fixes them,
255-
* not this migration. */
252+
/* Docs shell — shared by all three /docs routes.
253+
*
254+
* Both defects the migration flagged here are now closed. The hardcoded
255+
* `paddingTop: 80` became `var(--nav-h)`, and the nav-height coupling behind it
256+
* was fixed at the source in chrome.css (the variable was missing its
257+
* 768–1023px step). `min-height: 100vh` and this padding do not stack, because
258+
* global.css sets `box-sizing: border-box` on everything.
259+
*
260+
* The article's `overflow-x-hidden` utility is gone rather than moved here. It
261+
* was redundant — global.css already clips the body — and it was the same
262+
* mistake that rule's comment warns about: `overflow-x: hidden` computes
263+
* `overflow-y: auto`, so it made every docs article a scroll container. All 123
264+
* docs URLs were measured at 375px with it removed; none overflow. Wide code
265+
* blocks and tables scroll inside their own `overflow-x: auto` containers,
266+
* which is where the containment belongs. */
256267
.docs-shell-page {
257268
background: var(--color-canvas);
258269
padding-top: var(--nav-h);

0 commit comments

Comments
 (0)