Skip to content

fix(website): the docs drawer's scroll lock shifts the fixed header #1147

Description

@vivek7405

Problem

The website's docs / ui sidebar drawer locks page scroll with a CSS-only rule, and that lock shifts the fixed site header. Same bug class as #1144, different code path, so the fix that landed for <ui-dialog> does not reach it.

website/lib/docs-shell.ts:252, inside the @media (max-width: 859.98px) block opened at L210:

body[data-docs-nav-open] { overflow: hidden; }

Hiding the page scrollbar widens the viewport by its width. The site header is position: fixed (website/app/layout.ts:348, class="site-top fixed inset-x-0 top-0 z-20"), so it lays out against the initial containing block and widens with the viewport.

Measured in Chromium with a classic 15px scrollbar, at a 800x700 viewport, on /docs/styling:

header width right-hand control --wj-scrollbar-compensation
drawer closed 785 right edge at 761 unset
drawer open 800 right edge at 776 unset

So the theme toggle and menu button jump right by the full 15px. The left-aligned brand does not move, which is why this reads as a subtler jump than #1144 (where a centred nav moved by half the width).

Only reproduces where the scrollbar takes layout width: classic scrollbars on Windows and Linux, and macOS set to "Show scroll bars: Always". It needs a viewport under 860px, which on those platforms means a narrowed desktop window, since a phone has overlay scrollbars. That narrow band is why it has gone unnoticed, not a reason it is not real.

Design / approach

The #1144 fix cannot be reused directly. That one is a JS lock, so it measures the residual and publishes --wj-scrollbar-compensation for a fixed element to opt into. This lock is pure CSS with no JS involved, so there is nothing to measure and the property is never set. The .site-top opt-in added in #1144 is therefore inert here.

Reserve the gutter declaratively instead, keyed off the same attribute. The gutter has to be on the root element, not on body: scrollbar-gutter on body does NOT propagate to the viewport (verified while working #1144, where the body-scoped variant left the header shifting by the full amount while the html-scoped one held it exactly).

html:has(body[data-docs-nav-open]) { scrollbar-gutter: stable; }

Two things to confirm before committing to it:

  • WebKit ignores scrollbar-gutter entirely (measured in fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144: applying stable to a short page narrows the ICB on Chromium and does nothing on WebKit). So this fixes Chromium and Firefox and leaves WebKit as is. Decide whether that is acceptable or whether the drawer toggle should set the property from the JS that already toggles the attribute (website/app/layout.ts:203), which would let the existing .site-top opt-in cover WebKit too.
  • Reserving the gutter only while the drawer is open means the width changes at the moment the rule applies, which is exactly what it is meant to prevent. Confirm empirically that the gutter is reserved in the same layout pass that removes the scrollbar and there is no one-frame flash.

An alternative worth weighing: move the lock into the JS that already owns the attribute (closeDocsNav / the toggle handler in website/app/layout.ts), and reuse the #1144 lock shape verbatim. That costs the CSS-only simplicity but gets measurement, the WebKit fallback, and one lock implementation instead of two.

Implementation notes (for the implementing agent)

Where to edit:

  • website/lib/docs-shell.ts:252, the body[data-docs-nav-open] { overflow: hidden; } rule inside the @media (max-width: 859.98px) block that opens at L210.
  • website/app/layout.ts if taking the JS route: the toggle at L203 (document.body.toggleAttribute('data-docs-nav-open')) and closeDocsNav() at L173. Note the root layout deliberately owns these handlers so the shell module stays inert at load and its importers can be elided. Do not move them into docs-shell.ts.
  • website/app/layout.ts already carries the .site-top { padding-right: var(--wj-scrollbar-compensation, 0px); } opt-in from fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144. If the JS route is taken, that line needs no change and starts working for the drawer too.

Landmines / gotchas:

  • Headless browsers hide scrollbars, so this does not reproduce by default. Playwright passes --hide-scrollbars; drop it with ignoreDefaultArgs: ['--hide-scrollbars']. The root WTR config already does this for Chromium (see fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144). A test that passes without a measurable scrollbar proves nothing.
  • Measure a RIGHT-aligned or centred element. The brand is left-aligned and does not move, so measuring it reports a false pass.
  • document.documentElement.clientWidth is not a reliable probe here: under a reserved gutter Chromium reports it growing while the fixed header does not move. Measure element rects.
  • The drawer state survives soft navigation by design (the attribute is on <body>, outside every swap range). Any state added alongside it must be cleared on the same paths, or it leaks across navigations.

Invariants to respect:

  • Repo root AGENTS.md invariant 11 for any prose added.
  • The shell module must stay inert at load (see the comment block at website/app/layout.ts:151), so no module-scope client work in docs-shell.ts.

Tests + docs surfaces:

  • website/web-test-runner.config.js has its own browser test config; check whether a drawer test belongs there or in the root suite.
  • .agents/skills/webjs/references/styling.md already documents the fixed-header-plus-scroll-lock contract (added in fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144). If the drawer takes a different shape than the kit's lock, note it there so the two do not silently diverge.

Acceptance criteria

  • Opening and closing the docs / ui drawer under 860px leaves every part of the fixed header exactly where it was, on a browser with classic scrollbars
  • In-flow page content does not shift either
  • The drawer still locks scroll, and still clears on soft navigation and popstate
  • A browser test forces a measurable scrollbar and measures a right-aligned or centred header element, and genuinely fails when the fix is reverted
  • Whatever WebKit does is stated explicitly rather than left implied

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions