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
93 changes: 93 additions & 0 deletions apps/website/e2e/docs-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';

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

/**
* The docs shell is one reading pane: a sticky control plane on the left, one
* prose column, and a sticky TOC rail on the right. These guard the parts of
* that whose failure mode is silence — a rail that stops tracking, a column
* that stops sharing its measure — and which jsdom cannot see.
*/

test.describe('DocsTOC rail', () => {
test('tracks the reading position on a hard load', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(ARTICLE);
await expect(page.locator('.docs-toc-link').first()).toBeVisible();

// Nothing is active at the top: the first heading is below the reading line.
await expect(page.locator('.docs-toc-link[data-active]')).toHaveCount(0);

await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' }));
await expect
.poll(() =>
page
.locator('.docs-toc-link[data-active]')
.evaluateAll((els) => els.map((e) => e.getAttribute('href'))),
)
.toEqual(['#connect-with-angular']);

// ...and it follows the scroll rather than latching on the first match.
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await expect.poll(() => page.locator('.docs-toc-link[data-active]').count()).toBe(0);
});

test('every rail link resolves to a heading in the article', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(ARTICLE);

const unresolved = await page.evaluate(() =>
[...document.querySelectorAll('.docs-toc-link')]
.map((a) => (a as HTMLAnchorElement).getAttribute('href') ?? '')
.filter((href) => !document.getElementById(href.slice(1))),
);
expect(unresolved).toEqual([]);
});

test('the library-neutral adapter page gets the same rail', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/docs/choosing-an-adapter');
await expect(page.locator('.docs-toc')).toBeVisible();
expect(await page.locator('.docs-toc-link').count()).toBeGreaterThan(3);
});
});

test.describe('docs shell layout', () => {
test('the sticky rails hold through a full-page scroll', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(ARTICLE);

const navH = await page.evaluate(() =>
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),
);
const tops = async () => ({
plane: await page.locator('.docs-control-plane').evaluate((el) => Math.round(el.getBoundingClientRect().top)),
toc: await page.locator('.docs-toc').evaluate((el) => Math.round(el.getBoundingClientRect().top)),
});

expect(await tops()).toEqual({ plane: navH, toc: navH });
await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' }));
expect(await tops()).toEqual({ plane: navH, toc: navH });
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
expect(await tops()).toEqual({ plane: navH, toc: navH });
});

test('breadcrumb, prose and prev/next share one right edge', async ({ page }) => {
// The header block used to stretch to the full content width while the
// article and the prev/next rail sat at max-w-3xl, so PageActions floated
// ~500px right of the column it belongs to.
await page.setViewportSize({ width: 1920, height: 1000 });
await page.goto(ARTICLE);

const right = (selector: string) =>
page.locator(selector).first().evaluate((el) => Math.round(el.getBoundingClientRect().right));

const header = await right('.docs-page-header');
const article = await right('article');
const prevNext = await right('.docs-prevnext');

// The header and prev/next sit inside the article's horizontal padding.
expect(article - header).toBeLessThanOrEqual(48);
expect(header).toBe(prevNext);
});
});
81 changes: 81 additions & 0 deletions apps/website/e2e/nav-height.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { test, expect } from '@playwright/test';

/**
* `--nav-h` (styles/chrome.css) is the single source of truth for every offset
* against the fixed nav: the docs shell's top padding, the sticky sidebar and
* TOC rails, the mobile drawer's `top`, and html's scroll-padding.
*
* Its value is measured from the rendered nav, not derived from the classes, so
* it silently drifts whenever Nav.tsx changes what it shows at a breakpoint —
* which is exactly how the 768–1023px band came to overshoot by 15px. jsdom
* cannot measure layout, so this is the only place the two can be compared.
*
* The tolerance is 1px, and deliberately not 0: the declared values round *up*
* off the measured height (58/66/81 against 57/65/81 in Chrome at dpr 1) so the
* offset always clears the nav rather than tucking content under it, and the
* sub-pixel height itself moves with font rendering. 1px is the rounding; the
* 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' },
];

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');

const nav = page.locator('nav').first();
await expect(nav).toBeVisible();

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

expect(variable).toBeGreaterThanOrEqual(measured);
expect(variable - measured).toBeLessThanOrEqual(1);
});
}

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 });
await page.goto('/docs/langgraph/getting-started/introduction');

const navBottom = await page
.locator('nav')
.first()
.evaluate((el) => el.getBoundingClientRect().bottom);
const shellTop = await page
.locator('.docs-shell-page')
.evaluate((el) => el.getBoundingClientRect().top + parseFloat(getComputedStyle(el).paddingTop));

expect(Math.abs(shellTop - navBottom)).toBeLessThanOrEqual(1);
});

test('the mobile drawer hangs flush off the nav on a tablet width', async ({ page }) => {
// The drawer is positioned at `top: calc(var(--nav-h) - 1px)`, so a wrong
// --nav-h shows up here as a visible gap between the nav and the panel.
await page.setViewportSize({ width: 900, height: 800 });
await page.goto('/docs/langgraph/getting-started/introduction');

await page.locator('.nav-hamburger').click();
const overlay = page.locator('.nav-mobile-overlay');
await expect(overlay).toBeVisible();

const navBottom = await page
.locator('nav')
.first()
.evaluate((el) => el.getBoundingClientRect().bottom);
const overlayTop = await overlay.evaluate((el) => el.getBoundingClientRect().top);

// Flush or overlapping the nav's bottom border — never a gap below it.
expect(overlayTop - navBottom).toBeLessThanOrEqual(0);
expect(overlayTop - navBottom).toBeGreaterThanOrEqual(-2);
});
34 changes: 30 additions & 4 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,37 @@ test('representative docs pages do not create page-level horizontal overflow', a

for (const route of routes) {
await page.goto(route);
const overflow = await page.evaluate(() => (
document.documentElement.scrollWidth - document.documentElement.clientWidth
));

expect(overflow, `${route} at ${width}px`).toBeLessThanOrEqual(1);
// NOT documentElement.scrollWidth. global.css clips the body
// (`overflow-x: clip`) precisely so overflow can never reach the layout
// viewport, which means that number is pinned to the viewport width and
// every assertion on it passed vacuously — confirmed by injecting a
// 2000px-wide element and watching it stay put. Ask the question the
// clip is hiding instead: does anything escape its own column? Content
// inside a horizontal scroller (code blocks, wide tables) is exempt —
// scrolling there is the intended containment.
const escaped = await page.evaluate(() => {
const column = document.querySelector('article') ?? document.querySelector('main');
if (!column) return ['no column'];
const box = column.getBoundingClientRect();
const inScroller = (el: Element) => {
let p = el.parentElement;
while (p && p !== column) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip') return true;
p = p.parentElement;
}
return false;
};
return [...column.querySelectorAll('*')]
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.right > box.right + 1 && !inScroller(el);
})
.map((el) => `${el.tagName}.${String(el.className).slice(0, 40)}`);
});

expect(escaped, `${route} at ${width}px`).toEqual([]);
}
}
});
Expand Down
9 changes: 7 additions & 2 deletions apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,20 @@ export default async function DocsPage({ params }: DocsRouteProps) {
/>
<div className="flex-1 flex min-w-0 docs-shell-body">
<div className="flex-1 min-w-0">
<div className="px-4 sm:px-6 md:px-12 pt-6">
{/* Same measure as the article and the prev/next rail below it, so the
* whole column shares one right edge. Without md:max-w-3xl this
* block stretched to the full content width and PageActions floated
* ~500px right of the prose it belongs to (1272px vs 768px at
* 1920). */}
<div className="px-4 sm:px-6 md:px-12 md:max-w-3xl pt-6">
<DocsBreadcrumb library={library as LibraryId} section={section} slug={slug} title={doc.title} />
<DocsPageHeader
library={library as LibraryId}
section={section}
actions={<PageActions library={library} section={section} slug={slug} headings={headings} />}
/>
</div>
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl">
<MdxRenderer source={doc.body} />
</article>
{section === 'api' && (() => {
Expand Down
10 changes: 9 additions & 1 deletion apps/website/src/app/docs/choosing-an-adapter/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { notFound } from 'next/navigation';
import { DocsControlPlane } from '../../../components/docs/DocsControlPlane';
import { DocsSearch } from '../../../components/docs/DocsSearch';
import { MdxRenderer } from '../../../components/docs/MdxRenderer';
import { DocsTOC } from '../../../components/docs/DocsTOC';
import { createPageMetadata } from '../../../lib/site-metadata';
import { extractHeadings } from '../../../lib/extract-headings';
import { stripFrontmatter } from '../../../lib/docs';

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

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

return (
<div className="flex min-h-screen docs-shell-page">
Expand All @@ -48,11 +51,16 @@ export default function ChoosingAnAdapterPage() {
<div className="flex-1 min-w-0">
<article
aria-label={PAGE_TITLE}
className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden"
className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl"
>
<MdxRenderer source={source} />
</article>
</div>
{/* This page carries as many headings as any library page, so it gets
* the same rail. It stays library-neutral, so it takes no breadcrumb
* or page header — both are keyed to a library it deliberately
* has not picked. */}
<DocsTOC headings={headings} />
</div>
</div>
);
Expand Down
27 changes: 21 additions & 6 deletions apps/website/src/styles/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,31 @@
* Fonts: unified onto the next/font vars — see the font note atop ui.css.
*/

/* One nav height. The fixed nav measures 58px (px-6 py-4) and 81px at the md
* breakpoint (md:px-8 md:py-5, 1px border included). Everything that offsets
* against the nav — the docs shell's top padding, both sticky rails, the
* mobile overlay, and html's scroll-padding for anchor jumps — reads this one
* variable instead of hardcoding its own guess (the old hardcoded 80px left
* 22px of dead space on phones; anchors landed 81px under the nav). */
/* One nav height. Everything that offsets against the nav — the docs shell's
* top padding, both sticky rails, the mobile overlay, and html's scroll-padding
* for anchor jumps — reads this one variable instead of hardcoding its own
* guess (the old hardcoded 80px left 22px of dead space on phones; anchors
* landed 81px under the nav).
*
* The nav has THREE heights, not two, because padding and content step at
* different breakpoints (Nav.tsx): the inner row is `px-6 py-4 md:px-8 md:py-5`,
* so padding grows at md (768px), but the tall `hidden lg:flex` link row only
* appears at lg (1024px). Between them the nav is 66px — a 25px logo in 40px of
* padding plus the 1px border. This variable used to jump straight to 81px at
* md, so from 768px to 1023px every offset overshot by 15px: dead space above
* the docs column, and the mobile drawer (top: nav-h - 1px) hung 14px below the
* nav it is supposed to be attached to. These are measured, not derived, so
* only a real browser can hold them honest: e2e/nav-height.spec.ts asserts
* nav.height === --nav-h at each of the three steps. */
:root {
--nav-h: 58px;
}
@media (min-width: 768px) {
:root {
--nav-h: 66px;
}
}
@media (min-width: 1024px) {
:root {
--nav-h: 81px;
}
Expand Down
5 changes: 2 additions & 3 deletions apps/website/src/styles/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -978,9 +978,8 @@
font-weight: 500;
}

/* DocsBreadcrumb — verbatim, including the inconsistent li/separator
* typography (crumb/sep were separate style-variable shapes; a later
* polish pass reconciles them, not this migration). */
/* DocsBreadcrumb — the crumb/separator typography the migration left
* inconsistent is reconciled below, on the list rather than the links. */
.docs-crumb-nav {
margin-bottom: 16px;
}
Expand Down
19 changes: 15 additions & 4 deletions apps/website/src/styles/pages.css
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,21 @@
max-width: 760px;
}

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