From c29a3ad9fe916cb4aba430dd18b05b5713dca33c Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 02:20:25 +0530 Subject: [PATCH 01/16] fix(ui): stop the dialog scroll lock shifting a fixed header Hiding the page scrollbar widens the viewport by its width. The lock compensated by padding the body, which holds in-flow content still, but a position:fixed header lays out against the initial containing block and never sees that padding, so it slid right by half the scrollbar width. That hits the pinned-header pattern WebJs itself recommends (#610), and webjs.dev/ui is a live example. The lock now reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves, in flow or fixed, with no cooperation from the page. Chromium and Firefox honour that. WebKit ignores scrollbar-gutter today, so the residual is measured rather than assumed and falls back to the old body padding plus a published --wj-scrollbar-compensation custom property a fixed element can opt into. When the gutter works the residual is zero, so the two paths cannot double-compensate. The test forces a classic scrollbar, because headless browsers default to overlay scrollbars where the whole scenario is inert and the assertion would pass vacuously. Playwright's --hide-scrollbars is dropped for Chromium for the same reason. --- .../registry/components/alert-dialog.ts | 47 +++- .../ui/packages/registry/components/dialog.ts | 47 +++- .../components/browser/ui-overlay.test.js | 221 ++++++++++++++++++ web-test-runner.config.js | 14 +- 4 files changed, 318 insertions(+), 11 deletions(-) diff --git a/packages/ui/packages/registry/components/alert-dialog.ts b/packages/ui/packages/registry/components/alert-dialog.ts index 4be97ade5..390449127 100644 --- a/packages/ui/packages/registry/components/alert-dialog.ts +++ b/packages/ui/packages/registry/components/alert-dialog.ts @@ -107,17 +107,52 @@ function installStyles(): void { // Body scroll lock. Refcounted so nested dialogs unlock in order. Kept in // lockstep with dialog.ts (not imported) so `webjs ui add alert-dialog` // doesn't pull in the full dialog component. +// +// Hiding the page scrollbar widens the viewport by the scrollbar's width, so +// anything laid out against the viewport moves. Padding the body holds in-flow +// content still, but a `position: fixed` header lays out against the initial +// containing block, never against the body's padding box, so padding alone +// leaves it sliding right by half the scrollbar width. Two mechanisms fix it: +// +// 1. Reserve the scrollbar gutter for the duration of the lock, so the +// viewport width never changes and NOTHING moves, in flow or fixed. This +// needs no cooperation from the page. Chromium and Firefox honour it. +// 2. Where the engine ignores `scrollbar-gutter` (WebKit today), fall back to +// padding the body and publish the leftover width as +// `--wj-scrollbar-compensation` on , so a fixed element can opt in +// with `padding-right: var(--wj-scrollbar-compensation, 0px)`. +// +// Everything below is MEASURED rather than assumed, because engines disagree +// about both scrollbar geometry and gutter support. When mechanism 1 works the +// measured residual is zero, so the body is never padded and the custom +// property is never set: the two mechanisms cannot double-compensate. +const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation'; + let scrollLockCount = 0; let savedOverflow = ''; let savedPaddingRight = ''; +let savedScrollbarGutter = ''; function lockScroll(): void { if (scrollLockCount === 0) { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - savedOverflow = document.body.style.overflow; - savedPaddingRight = document.body.style.paddingRight; - document.body.style.overflow = 'hidden'; - if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`; + const root = document.documentElement; + const body = document.body; + savedOverflow = body.style.overflow; + savedPaddingRight = body.style.paddingRight; + savedScrollbarGutter = root.style.scrollbarGutter; + + const widthBefore = body.getBoundingClientRect().width; + // Only a CLASSIC scrollbar takes layout width and therefore needs a gutter. + // Overlay scrollbars (macOS default, touch) take none, and reserving a + // gutter for them would shrink the page instead of holding it still. + if (window.innerWidth > root.clientWidth) root.style.scrollbarGutter = 'stable'; + body.style.overflow = 'hidden'; + + const grew = body.getBoundingClientRect().width - widthBefore; + if (grew > 0) { + body.style.paddingRight = `${grew}px`; + root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`); + } } scrollLockCount++; } @@ -127,6 +162,8 @@ function unlockScroll(): void { if (scrollLockCount === 0) { document.body.style.overflow = savedOverflow; document.body.style.paddingRight = savedPaddingRight; + document.documentElement.style.scrollbarGutter = savedScrollbarGutter; + document.documentElement.style.removeProperty(SCROLLBAR_COMPENSATION); } } diff --git a/packages/ui/packages/registry/components/dialog.ts b/packages/ui/packages/registry/components/dialog.ts index 1c8bd698d..8785b66c2 100644 --- a/packages/ui/packages/registry/components/dialog.ts +++ b/packages/ui/packages/registry/components/dialog.ts @@ -163,19 +163,54 @@ function installStyles(): void { // -------------------------------------------------------------------------- // Body scroll lock. Refcounted so nested dialogs unlock in order. Native // does not lock body scroll, only inert-ifies the background. +// +// Hiding the page scrollbar widens the viewport by the scrollbar's width, so +// anything laid out against the viewport moves. Padding the body holds in-flow +// content still, but a `position: fixed` header lays out against the initial +// containing block, never against the body's padding box, so padding alone +// leaves it sliding right by half the scrollbar width. Two mechanisms fix it: +// +// 1. Reserve the scrollbar gutter for the duration of the lock, so the +// viewport width never changes and NOTHING moves, in flow or fixed. This +// needs no cooperation from the page. Chromium and Firefox honour it. +// 2. Where the engine ignores `scrollbar-gutter` (WebKit today), fall back to +// padding the body and publish the leftover width as +// `--wj-scrollbar-compensation` on , so a fixed element can opt in +// with `padding-right: var(--wj-scrollbar-compensation, 0px)`. +// +// Everything below is MEASURED rather than assumed, because engines disagree +// about both scrollbar geometry and gutter support. When mechanism 1 works the +// measured residual is zero, so the body is never padded and the custom +// property is never set: the two mechanisms cannot double-compensate. // -------------------------------------------------------------------------- +const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation'; + let scrollLockCount = 0; let savedOverflow = ''; let savedPaddingRight = ''; +let savedScrollbarGutter = ''; function lockScroll(): void { if (scrollLockCount === 0) { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - savedOverflow = document.body.style.overflow; - savedPaddingRight = document.body.style.paddingRight; - document.body.style.overflow = 'hidden'; - if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`; + const root = document.documentElement; + const body = document.body; + savedOverflow = body.style.overflow; + savedPaddingRight = body.style.paddingRight; + savedScrollbarGutter = root.style.scrollbarGutter; + + const widthBefore = body.getBoundingClientRect().width; + // Only a CLASSIC scrollbar takes layout width and therefore needs a gutter. + // Overlay scrollbars (macOS default, touch) take none, and reserving a + // gutter for them would shrink the page instead of holding it still. + if (window.innerWidth > root.clientWidth) root.style.scrollbarGutter = 'stable'; + body.style.overflow = 'hidden'; + + const grew = body.getBoundingClientRect().width - widthBefore; + if (grew > 0) { + body.style.paddingRight = `${grew}px`; + root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`); + } } scrollLockCount++; } @@ -185,6 +220,8 @@ function unlockScroll(): void { if (scrollLockCount === 0) { document.body.style.overflow = savedOverflow; document.body.style.paddingRight = savedPaddingRight; + document.documentElement.style.scrollbarGutter = savedScrollbarGutter; + document.documentElement.style.removeProperty(SCROLLBAR_COMPENSATION); } } diff --git a/packages/ui/test/components/browser/ui-overlay.test.js b/packages/ui/test/components/browser/ui-overlay.test.js index 296f71cb2..6d70db8b4 100644 --- a/packages/ui/test/components/browser/ui-overlay.test.js +++ b/packages/ui/test/components/browser/ui-overlay.test.js @@ -122,6 +122,188 @@ suite('ui-dialog', () => { }); }); +// -------------------------------------------------------------------------- +// Scroll lock layout (#1144) +// +// Locking body scroll hides the page scrollbar, which widens the viewport by +// the scrollbar's width. Padding the body holds in-flow content still, but a +// `position: fixed` header lays out against the initial containing block, so +// it used to slide right by half the scrollbar width. +// +// These assertions are only meaningful where the scrollbar takes LAYOUT WIDTH. +// Headless browsers default to overlay scrollbars (zero width), which makes the +// whole scenario inert, so `buildFixedHeaderPage()` styles the root scrollbar to +// force a classic one and each test skips EXPLICITLY when the engine still +// reports zero. A silent pass under overlay scrollbars would prove nothing. +// -------------------------------------------------------------------------- + +const scrollbarWidth = () => window.innerWidth - document.documentElement.clientWidth; + +/** + * Build the reproduction: a page that overflows, a fixed full-width header with + * a centred inner box (the thing that visibly jumped), and a centred in-flow + * box (which must not move either). The header opts into the published + * compensation, which is a no-op on engines that hold the viewport width via + * the reserved gutter. + * + * Centres are what matter here. The body's own border box legitimately widens + * when the compensation path runs (padding holds the CONTENT still, not the + * border box), so measuring the body would report a false shift. + */ +async function buildFixedHeaderPage() { + const style = document.createElement('style'); + style.textContent = + 'html::-webkit-scrollbar { width: 15px; background: #eee; }' + + 'html::-webkit-scrollbar-thumb { background: #888; }'; + document.head.appendChild(style); + + const spacer = document.createElement('div'); + spacer.style.height = '300vh'; + document.body.appendChild(spacer); + + const flow = document.createElement('div'); + flow.style.cssText = 'max-width:400px;margin:0 auto;height:20px;'; + document.body.appendChild(flow); + + const header = document.createElement('div'); + header.style.cssText = + 'position:fixed;top:0;left:0;right:0;height:40px;' + + 'padding-right:var(--wj-scrollbar-compensation, 0px);'; + const inner = document.createElement('div'); + inner.style.cssText = 'max-width:400px;margin:0 auto;height:40px;'; + header.appendChild(inner); + document.body.appendChild(header); + + // WebKit only materialises a custom root scrollbar after a layout pass, so + // the precondition check has to wait or it under-reports the width. + await tick(); + + return { + header, + inner, + flow, + teardown() { + style.remove(); + spacer.remove(); + flow.remove(); + header.remove(); + }, + }; +} + +const centreOf = (el) => Math.round(el.getBoundingClientRect().left * 100) / 100; + +suite('ui-dialog scroll lock layout', () => { + suiteSetup(async () => { + await import(`${COMPONENTS_DIR}/dialog.ts`); + }); + + test('opening a dialog leaves a position:fixed header exactly where it was', async function () { + const page = await buildFixedHeaderPage(); + try { + if (scrollbarWidth() === 0) this.skip(); + + const root = await mount(html` + + T + + `); + const dialog = root.querySelector('ui-dialog'); + + const fixedBefore = centreOf(page.inner); + const flowBefore = centreOf(page.flow); + + dialog.show(); + await tick(); + + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on open'); + assert.equal(centreOf(page.flow), flowBefore, 'in-flow content does not move on open'); + + dialog.hide(); + await tick(); + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on close'); + root.remove(); + } finally { + page.teardown(); + } + }); + + test('the lock is released cleanly, leaving no compensation behind', async function () { + const page = await buildFixedHeaderPage(); + try { + if (scrollbarWidth() === 0) this.skip(); + + const root = await mount(html` + + T + + `); + const dialog = root.querySelector('ui-dialog'); + + dialog.show(); + await tick(); + dialog.hide(); + await tick(); + + assert.equal(document.body.style.overflow, '', 'body overflow restored'); + assert.equal(document.body.style.paddingRight, '', 'body padding restored'); + assert.equal(document.documentElement.style.scrollbarGutter, '', 'scrollbar gutter restored'); + assert.equal( + document.documentElement.style.getPropertyValue('--wj-scrollbar-compensation'), + '', + 'compensation custom property cleared', + ); + root.remove(); + } finally { + page.teardown(); + } + }); + + test('nested dialogs release the compensation only on the outermost close', async function () { + const page = await buildFixedHeaderPage(); + try { + if (scrollbarWidth() === 0) this.skip(); + + const root = await mount(html` + + Outer + + + Inner + + `); + const outer = root.querySelector('#outer'); + const nested = root.querySelector('#inner'); + + const fixedBefore = centreOf(page.inner); + + outer.show(); + await tick(); + nested.show(); + await tick(); + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header held with both dialogs open'); + + nested.hide(); + await tick(); + assert.equal(document.body.style.overflow, 'hidden', 'still locked while the outer dialog is open'); + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header still held after the nested close'); + + outer.hide(); + await tick(); + assert.equal(document.body.style.overflow, '', 'released on the outermost close'); + assert.equal( + document.documentElement.style.getPropertyValue('--wj-scrollbar-compensation'), + '', + 'compensation released on the outermost close', + ); + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header back where it started'); + root.remove(); + } finally { + page.teardown(); + } + }); +}); + suite('ui-tooltip', () => { suiteSetup(async () => { await import(`${COMPONENTS_DIR}/tooltip.ts`); @@ -346,6 +528,45 @@ suite('ui-alert-dialog', () => { await import(`${COMPONENTS_DIR}/alert-dialog.ts`); }); + // alert-dialog carries its own copy of the scroll lock (deliberately not + // imported from dialog.ts, so `webjs ui add alert-dialog` stays self + // contained), so the #1144 guarantee is asserted against it separately. This + // is the exact case reported on webjs.dev/ui: the Delete-account demo moved + // the site's fixed navbar. + test('opening an alert-dialog leaves a position:fixed header where it was', async function () { + const page = await buildFixedHeaderPage(); + try { + if (scrollbarWidth() === 0) this.skip(); + + const root = await mount(html` + + T + + `); + const dialog = root.querySelector('ui-alert-dialog'); + + const fixedBefore = centreOf(page.inner); + const flowBefore = centreOf(page.flow); + + dialog.show(); + await tick(); + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on open'); + assert.equal(centreOf(page.flow), flowBefore, 'in-flow content does not move on open'); + + dialog.hide(); + await tick(); + assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on close'); + assert.equal( + document.documentElement.style.getPropertyValue('--wj-scrollbar-compensation'), + '', + 'compensation released on close', + ); + root.remove(); + } finally { + page.teardown(); + } + }); + test('trigger click opens via show(); content has role="alertdialog"', async () => { const root = await mount(html` diff --git a/web-test-runner.config.js b/web-test-runner.config.js index a8b443181..47a2b3fe7 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -69,10 +69,22 @@ export default { // same tests run on each. WTR runs the browsers concurrently, and an engine // can be narrowed for a fast local loop with WEBJS_BROWSERS, e.g. // `WEBJS_BROWSERS=chromium npx wtr`. + // Playwright launches headless browsers with `--hide-scrollbars`, which makes + // every scrollbar zero-width. That hides a whole class of real layout bug: + // #1144 (a dialog's scroll lock shifting a fixed header) only reproduces when + // the scrollbar takes LAYOUT WIDTH, so under the default flag the regression + // test would have passed vacuously. Chromium is the engine that shows a + // classic scrollbar once the flag is dropped, so it carries that coverage. browsers: (process.env.WEBJS_BROWSERS ? process.env.WEBJS_BROWSERS.split(',').map((s) => s.trim()).filter(Boolean) : ['chromium', 'firefox', 'webkit'] - ).map((product) => playwrightLauncher({ product })), + ).map((product) => + playwrightLauncher( + product === 'chromium' + ? { product, launchOptions: { ignoreDefaultArgs: ['--hide-scrollbars'] } } + : { product }, + ), + ), testFramework: { config: { ui: 'tdd', From 6618e6822bfabc238a72fde361d55ff35c50eaf5 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 02:22:08 +0530 Subject: [PATCH 02/16] test(ui): isolate the scroll-lock tests from each other on failure A failed assertion left the lock engaged, so the next test saw a page with no scrollbar and skipped itself instead of running, and the dropdown-menu tests failed as collateral. Closing every tracked dialog in teardown keeps a red run reporting only what actually broke. --- .../components/browser/ui-overlay.test.js | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/ui/test/components/browser/ui-overlay.test.js b/packages/ui/test/components/browser/ui-overlay.test.js index 6d70db8b4..19ec0f37e 100644 --- a/packages/ui/test/components/browser/ui-overlay.test.js +++ b/packages/ui/test/components/browser/ui-overlay.test.js @@ -178,11 +178,25 @@ async function buildFixedHeaderPage() { // the precondition check has to wait or it under-reports the width. await tick(); + const mounted = []; + return { header, inner, flow, + /** Track a mounted root so teardown can unmount it even after a failure. */ + track(root) { + mounted.push(root); + return root; + }, teardown() { + // Close every dialog FIRST. An assertion that throws mid-test would + // otherwise leave the lock engaged, and the next test would see a page + // with no scrollbar and skip itself instead of running. + for (const root of mounted) { + for (const el of root.querySelectorAll('ui-dialog, ui-alert-dialog')) el.hide?.(); + root.remove(); + } style.remove(); spacer.remove(); flow.remove(); @@ -203,11 +217,11 @@ suite('ui-dialog scroll lock layout', () => { try { if (scrollbarWidth() === 0) this.skip(); - const root = await mount(html` + const root = page.track(await mount(html` T - `); + `)); const dialog = root.querySelector('ui-dialog'); const fixedBefore = centreOf(page.inner); @@ -222,7 +236,6 @@ suite('ui-dialog scroll lock layout', () => { dialog.hide(); await tick(); assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on close'); - root.remove(); } finally { page.teardown(); } @@ -233,11 +246,11 @@ suite('ui-dialog scroll lock layout', () => { try { if (scrollbarWidth() === 0) this.skip(); - const root = await mount(html` + const root = page.track(await mount(html` T - `); + `)); const dialog = root.querySelector('ui-dialog'); dialog.show(); @@ -253,7 +266,6 @@ suite('ui-dialog scroll lock layout', () => { '', 'compensation custom property cleared', ); - root.remove(); } finally { page.teardown(); } @@ -264,14 +276,14 @@ suite('ui-dialog scroll lock layout', () => { try { if (scrollbarWidth() === 0) this.skip(); - const root = await mount(html` + const root = page.track(await mount(html` Outer Inner - `); + `)); const outer = root.querySelector('#outer'); const nested = root.querySelector('#inner'); @@ -297,7 +309,6 @@ suite('ui-dialog scroll lock layout', () => { 'compensation released on the outermost close', ); assert.equal(centreOf(page.inner), fixedBefore, 'fixed header back where it started'); - root.remove(); } finally { page.teardown(); } @@ -538,11 +549,11 @@ suite('ui-alert-dialog', () => { try { if (scrollbarWidth() === 0) this.skip(); - const root = await mount(html` + const root = page.track(await mount(html` T - `); + `)); const dialog = root.querySelector('ui-alert-dialog'); const fixedBefore = centreOf(page.inner); @@ -561,7 +572,6 @@ suite('ui-alert-dialog', () => { '', 'compensation released on close', ); - root.remove(); } finally { page.teardown(); } From e758a737530d4f6af4c5f06e017022c235bd2ec8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 28 Jul 2026 02:25:00 +0530 Subject: [PATCH 03/16] docs(ui): document the dialog scroll lock's fixed-header contract The kit recommends a position: fixed header and its dialog used to move one, so the guidance and the component disagreed. Records the reserved gutter, the --wj-scrollbar-compensation opt-in, and the deliberate divergence from shadcn's body-padding-only approach. Opts webjs.dev's own fixed navbar in, which is the reported case. --- .agents/skills/webjs/references/styling.md | 12 ++++++++++++ packages/ui/AGENTS.md | 9 +++++++++ .../ui/packages/registry/components/alert-dialog.ts | 7 +++++++ packages/ui/packages/registry/components/dialog.ts | 7 +++++++ website/app/docs/styling/page.ts | 2 ++ website/app/layout.ts | 9 +++++++++ 6 files changed, 46 insertions(+) diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 86c77a1d4..c9d46eb7a 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -206,3 +206,15 @@ new ResizeObserver(apply).observe(hdr); ``` For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `
` as the internal scroller), which needs no offset but changes the scroll model. + +### A fixed header and a modal that locks scroll + +Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width. + +`@webjsdev/ui`'s `` / `` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. Where the engine ignores `scrollbar-gutter` (WebKit today), the lock publishes the leftover width on `` as `--wj-scrollbar-compensation`, and a fixed header opts in with one line: + +```css +header { position: fixed; inset-inline: 0; top: 0; padding-right: var(--wj-scrollbar-compensation, 0px); } +``` + +The property is only set while a lock is active AND the gutter did not hold, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. Rolling your own scroll lock? Reserve the gutter the same way rather than padding the body alone, or a fixed header will jump. diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 82460cddb..db0944c20 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -305,6 +305,15 @@ when the caller passes an explicit custom `--registry `. - `onValueChange={fn}` → `addEventListener('ui-value-change', fn)` - `asChild` → drop the wrapper, apply the class helper directly + **Deliberate divergence, dialog scroll lock (#1144).** shadcn (via Radix) + compensates the hidden scrollbar by padding the body only, which leaves a + `position: fixed` header sliding right by half the scrollbar width. Since + WebJs recommends exactly that pinned-header pattern (#610), our lock instead + reserves the scrollbar gutter, and publishes `--wj-scrollbar-compensation` + on `` as a fallback where the engine ignores `scrollbar-gutter`. This + is presentation and lifecycle, not API: every tag, variant, and data + attribute still matches shadcn. + 6. **Native form controls participate in `
` submission natively.** `` is a real input , no `ElementInternals`, no `setFormValue` proxying. Submission, diff --git a/packages/ui/packages/registry/components/alert-dialog.ts b/packages/ui/packages/registry/components/alert-dialog.ts index 390449127..088554756 100644 --- a/packages/ui/packages/registry/components/alert-dialog.ts +++ b/packages/ui/packages/registry/components/alert-dialog.ts @@ -46,6 +46,13 @@ * * Design tokens used: --background, --border, --muted-foreground. * + * Scroll lock (#1144): opening the dialog locks body scroll, which hides the + * page scrollbar and would widen the viewport. The lock reserves the scrollbar + * gutter so the width never changes and a `position: fixed` header stays put. + * Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the + * leftover width on as `--wj-scrollbar-compensation`, so a fixed header + * opts in with `padding-right: var(--wj-scrollbar-compensation, 0px)`. + * * @example * ```html * diff --git a/packages/ui/packages/registry/components/dialog.ts b/packages/ui/packages/registry/components/dialog.ts index 8785b66c2..d7b227ea9 100644 --- a/packages/ui/packages/registry/components/dialog.ts +++ b/packages/ui/packages/registry/components/dialog.ts @@ -39,6 +39,13 @@ * * Design tokens used: --background, --border, --muted-foreground. * + * Scroll lock (#1144): opening the dialog locks body scroll, which hides the + * page scrollbar and would widen the viewport. The lock reserves the scrollbar + * gutter so the width never changes and a `position: fixed` header stays put. + * Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the + * leftover width on as `--wj-scrollbar-compensation`, so a fixed header + * opts in with `padding-right: var(--wj-scrollbar-compensation, 0px)`. + * * @example * ```html * diff --git a/website/app/docs/styling/page.ts b/website/app/docs/styling/page.ts index e6eefb6db..f2c1599a2 100644 --- a/website/app/docs/styling/page.ts +++ b/website/app/docs/styling/page.ts @@ -287,6 +287,8 @@ export default function RootLayout({ children }: { children: unknown }) {

Pin a header with position: fixed, never position: sticky. A sticky header flickers its background for one frame on iOS WebKit (every iOS browser) during a client-router navigation: the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug, and the GPU-promotion hacks (translateZ, will-change) do not fix it. Use position: fixed and reserve the header height on the content with a --header-height CSS variable (kept exact by a ResizeObserver). It is iOS-only and invisible on desktop, Android, and in DevTools emulation, so it shows only on a real device.

+

A fixed header plus a modal that locks scroll. Locking page scroll hides the scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. Padding the body compensates in-flow content but does nothing for a fixed header, which lays out against the initial containing block rather than the body's padding box, so it slides right by half the scrollbar width. <ui-dialog> and <ui-alert-dialog> handle this: the lock reserves the scrollbar gutter so the viewport width never changes. Where the engine ignores scrollbar-gutter (WebKit today) it publishes the leftover width on <html> as --wj-scrollbar-compensation, which a fixed header opts into with padding-right: var(--wj-scrollbar-compensation, 0px). The property is set only while a lock is active and the gutter did not hold, so the 0px fallback covers every other moment.

+

Component scope

// components/my-card.ts
 import { WebComponent, html, css } from '@webjsdev/core';
diff --git a/website/app/layout.ts b/website/app/layout.ts
index 88e5838da..44aa53966 100644
--- a/website/app/layout.ts
+++ b/website/app/layout.ts
@@ -308,6 +308,15 @@ export default function RootLayout({ children }: { children: unknown }) {
         display: inline-block; width: 1.15em; height: 1.15em;
         vertical-align: -0.18em; color: var(--heart);
       }
+      /* #1144: a modal that locks page scroll hides the scrollbar, which widens
+         the viewport. The kit's dialog reserves the scrollbar gutter so nothing
+         moves, but WebKit ignores scrollbar-gutter, and there it publishes the
+         leftover width instead. The header is position: fixed, so it lays out
+         against the viewport and cannot be reached by the body padding that
+         holds in-flow content still. Opting in is what keeps it from sliding
+         right by half the scrollbar width when a dialog opens. The 0px fallback
+         is what applies at every other moment. */
+      .site-top { padding-right: var(--wj-scrollbar-compensation, 0px); }
       .glow-layer { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
       .glow-layer::before {
         content: ''; position: absolute; inset: 0;

From 052b95477281094ce7cdaa99d0fb7cb8e6599d13 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Tue, 28 Jul 2026 02:45:10 +0530
Subject: [PATCH 04/16] fix(ui): stop the scroll lock clobbering a page's own
 layout choices

Review of the first pass turned up four real problems, all measured in Chromium
and WebKit before and after.

An app that already reserves both gutters had its choice overwritten with the
single-edge value, which dropped one gutter and shifted content by the full
scrollbar width, a regression the previous code could not cause. The gutter is
now only reserved when the page has not chosen one.

The compensation now lands on  rather than , and is added to any
padding already there. Padding the body replaced the page's own value, and it
missed the shift entirely on a max-width body, which does not widen when the
viewport does, so a fixed header opting in got no compensation at all.

The residual is measured from the root's border box. A position:fixed probe
reads its OLD box on WebKit until the next rendering update, so it reported a
zero delta and the fallback never fired.

The published property is now restored on unlock rather than removed. The two
components keep separate refcounts on purpose, so a confirm opened from inside
a dialog unlocked first and stripped the dialog's compensation while it was
still open.
---
 .agents/skills/webjs/references/styling.md    |   4 +-
 .../registry/components/alert-dialog.ts       |  73 +++++++---
 .../ui/packages/registry/components/dialog.ts |  73 +++++++---
 .../components/browser/ui-overlay.test.js     | 137 +++++++++++++++++-
 4 files changed, 243 insertions(+), 44 deletions(-)

diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md
index c9d46eb7a..ea99535d2 100644
--- a/.agents/skills/webjs/references/styling.md
+++ b/.agents/skills/webjs/references/styling.md
@@ -217,4 +217,6 @@ Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides th
 header { position: fixed; inset-inline: 0; top: 0; padding-right: var(--wj-scrollbar-compensation, 0px); }
 ```
 
-The property is only set while a lock is active AND the gutter did not hold, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. Rolling your own scroll lock? Reserve the gutter the same way rather than padding the body alone, or a fixed header will jump.
+The property is only set while a lock is active AND the gutter did not hold, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate.
+
+Rolling your own scroll lock? Two things the kit learned the hard way. Reserve the gutter rather than relying on padding alone, or a fixed header will jump. And when you do pad, pad ``, not ``: a `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is. Measure the root's own border box to decide the amount, since it tracks the viewport and is re-laid-out synchronously.
diff --git a/packages/ui/packages/registry/components/alert-dialog.ts b/packages/ui/packages/registry/components/alert-dialog.ts
index 088554756..63701af40 100644
--- a/packages/ui/packages/registry/components/alert-dialog.ts
+++ b/packages/ui/packages/registry/components/alert-dialog.ts
@@ -116,48 +116,73 @@ function installStyles(): void {
 // doesn't pull in the full dialog component.
 //
 // Hiding the page scrollbar widens the viewport by the scrollbar's width, so
-// anything laid out against the viewport moves. Padding the body holds in-flow
+// anything laid out against the viewport moves. Padding the page holds in-flow
 // content still, but a `position: fixed` header lays out against the initial
-// containing block, never against the body's padding box, so padding alone
-// leaves it sliding right by half the scrollbar width. Two mechanisms fix it:
+// containing block, never against any padding box, so padding alone leaves it
+// sliding right by half the scrollbar width. Two mechanisms fix it:
 //
 //   1. Reserve the scrollbar gutter for the duration of the lock, so the
 //      viewport width never changes and NOTHING moves, in flow or fixed. This
 //      needs no cooperation from the page. Chromium and Firefox honour it.
 //   2. Where the engine ignores `scrollbar-gutter` (WebKit today), fall back to
-//      padding the body and publish the leftover width as
-//      `--wj-scrollbar-compensation` on , so a fixed element can opt in
-//      with `padding-right: var(--wj-scrollbar-compensation, 0px)`.
+//      padding  and publish the leftover width as
+//      `--wj-scrollbar-compensation` on it, so a fixed element can opt in with
+//      `padding-right: var(--wj-scrollbar-compensation, 0px)`.
 //
 // Everything below is MEASURED rather than assumed, because engines disagree
 // about both scrollbar geometry and gutter support. When mechanism 1 works the
-// measured residual is zero, so the body is never padded and the custom
-// property is never set: the two mechanisms cannot double-compensate.
+// measured residual is zero, so no padding is applied and the custom property is
+// never set: the two mechanisms cannot double-compensate.
 const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
 
 let scrollLockCount = 0;
 let savedOverflow = '';
-let savedPaddingRight = '';
+let savedRootPaddingRight = '';
 let savedScrollbarGutter = '';
+let savedCompensation = '';
 
 function lockScroll(): void {
   if (scrollLockCount === 0) {
     const root = document.documentElement;
     const body = document.body;
+    const rootStyle = getComputedStyle(root);
+
     savedOverflow = body.style.overflow;
-    savedPaddingRight = body.style.paddingRight;
+    savedRootPaddingRight = root.style.paddingRight;
     savedScrollbarGutter = root.style.scrollbarGutter;
-
-    const widthBefore = body.getBoundingClientRect().width;
-    // Only a CLASSIC scrollbar takes layout width and therefore needs a gutter.
-    // Overlay scrollbars (macOS default, touch) take none, and reserving a
-    // gutter for them would shrink the page instead of holding it still.
-    if (window.innerWidth > root.clientWidth) root.style.scrollbarGutter = 'stable';
+    savedCompensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
+
+    //  is an in-flow block filling the initial containing block, so its
+    // border box IS the viewport width, and it is re-laid-out synchronously.
+    // A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its
+    // old box until the next rendering update, so it reads a zero delta and the
+    // compensation below never fires.
+    const widthBefore = root.getBoundingClientRect().width;
+    const padBefore = parseFloat(rootStyle.paddingRight) || 0;
+    // An engine with no `scrollbar-gutter` at all reads back undefined, which
+    // must be treated as "the page has not chosen" so the gutter is still
+    // attempted (setting an unsupported property is a harmless no-op, and the
+    // measured residual below is what actually decides the fallback).
+    const chosenGutter = rootStyle.scrollbarGutter || 'auto';
+
+    // Reserve the gutter, but only when the page has not already made its own
+    // choice. An app running `stable both-edges` keeps both gutters through the
+    // lock, and overwriting that with the single-edge value would drop one and
+    // introduce the very shift this exists to prevent.
+    if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) {
+      root.style.scrollbarGutter = 'stable';
+    }
     body.style.overflow = 'hidden';
 
-    const grew = body.getBoundingClientRect().width - widthBefore;
+    const grew = root.getBoundingClientRect().width - widthBefore;
     if (grew > 0) {
-      body.style.paddingRight = `${grew}px`;
+      // Padding the ROOT holds in-flow content still whatever the body's own
+      // width is (a `max-width` body does not widen with the viewport, so
+      // padding the body would miss it), and it leaves the page's own body
+      // padding untouched.
+      root.style.paddingRight = `${padBefore + grew}px`;
+      // A fixed box lays out against the viewport, so no padding here can reach
+      // it. Publish what it moved by and let it opt in.
       root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
     }
   }
@@ -167,10 +192,16 @@ function lockScroll(): void {
 function unlockScroll(): void {
   scrollLockCount = Math.max(0, scrollLockCount - 1);
   if (scrollLockCount === 0) {
+    const root = document.documentElement;
     document.body.style.overflow = savedOverflow;
-    document.body.style.paddingRight = savedPaddingRight;
-    document.documentElement.style.scrollbarGutter = savedScrollbarGutter;
-    document.documentElement.style.removeProperty(SCROLLBAR_COMPENSATION);
+    root.style.paddingRight = savedRootPaddingRight;
+    root.style.scrollbarGutter = savedScrollbarGutter;
+    // RESTORED, not removed. dialog.ts and alert-dialog.ts keep separate
+    // refcounts on purpose, so an alert-dialog opened from inside an open dialog
+    // unlocks first; removing the property outright would strip the dialog's
+    // compensation while it is still open and jump a fixed header mid-flow.
+    if (savedCompensation) root.style.setProperty(SCROLLBAR_COMPENSATION, savedCompensation);
+    else root.style.removeProperty(SCROLLBAR_COMPENSATION);
   }
 }
 
diff --git a/packages/ui/packages/registry/components/dialog.ts b/packages/ui/packages/registry/components/dialog.ts
index d7b227ea9..df89a79ed 100644
--- a/packages/ui/packages/registry/components/dialog.ts
+++ b/packages/ui/packages/registry/components/dialog.ts
@@ -172,50 +172,75 @@ function installStyles(): void {
 //  does not lock body scroll, only inert-ifies the background.
 //
 // Hiding the page scrollbar widens the viewport by the scrollbar's width, so
-// anything laid out against the viewport moves. Padding the body holds in-flow
+// anything laid out against the viewport moves. Padding the page holds in-flow
 // content still, but a `position: fixed` header lays out against the initial
-// containing block, never against the body's padding box, so padding alone
-// leaves it sliding right by half the scrollbar width. Two mechanisms fix it:
+// containing block, never against any padding box, so padding alone leaves it
+// sliding right by half the scrollbar width. Two mechanisms fix it:
 //
 //   1. Reserve the scrollbar gutter for the duration of the lock, so the
 //      viewport width never changes and NOTHING moves, in flow or fixed. This
 //      needs no cooperation from the page. Chromium and Firefox honour it.
 //   2. Where the engine ignores `scrollbar-gutter` (WebKit today), fall back to
-//      padding the body and publish the leftover width as
-//      `--wj-scrollbar-compensation` on , so a fixed element can opt in
-//      with `padding-right: var(--wj-scrollbar-compensation, 0px)`.
+//      padding  and publish the leftover width as
+//      `--wj-scrollbar-compensation` on it, so a fixed element can opt in with
+//      `padding-right: var(--wj-scrollbar-compensation, 0px)`.
 //
 // Everything below is MEASURED rather than assumed, because engines disagree
 // about both scrollbar geometry and gutter support. When mechanism 1 works the
-// measured residual is zero, so the body is never padded and the custom
-// property is never set: the two mechanisms cannot double-compensate.
+// measured residual is zero, so no padding is applied and the custom property is
+// never set: the two mechanisms cannot double-compensate.
 // --------------------------------------------------------------------------
 
 const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
 
 let scrollLockCount = 0;
 let savedOverflow = '';
-let savedPaddingRight = '';
+let savedRootPaddingRight = '';
 let savedScrollbarGutter = '';
+let savedCompensation = '';
 
 function lockScroll(): void {
   if (scrollLockCount === 0) {
     const root = document.documentElement;
     const body = document.body;
+    const rootStyle = getComputedStyle(root);
+
     savedOverflow = body.style.overflow;
-    savedPaddingRight = body.style.paddingRight;
+    savedRootPaddingRight = root.style.paddingRight;
     savedScrollbarGutter = root.style.scrollbarGutter;
-
-    const widthBefore = body.getBoundingClientRect().width;
-    // Only a CLASSIC scrollbar takes layout width and therefore needs a gutter.
-    // Overlay scrollbars (macOS default, touch) take none, and reserving a
-    // gutter for them would shrink the page instead of holding it still.
-    if (window.innerWidth > root.clientWidth) root.style.scrollbarGutter = 'stable';
+    savedCompensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
+
+    //  is an in-flow block filling the initial containing block, so its
+    // border box IS the viewport width, and it is re-laid-out synchronously.
+    // A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its
+    // old box until the next rendering update, so it reads a zero delta and the
+    // compensation below never fires.
+    const widthBefore = root.getBoundingClientRect().width;
+    const padBefore = parseFloat(rootStyle.paddingRight) || 0;
+    // An engine with no `scrollbar-gutter` at all reads back undefined, which
+    // must be treated as "the page has not chosen" so the gutter is still
+    // attempted (setting an unsupported property is a harmless no-op, and the
+    // measured residual below is what actually decides the fallback).
+    const chosenGutter = rootStyle.scrollbarGutter || 'auto';
+
+    // Reserve the gutter, but only when the page has not already made its own
+    // choice. An app running `stable both-edges` keeps both gutters through the
+    // lock, and overwriting that with the single-edge value would drop one and
+    // introduce the very shift this exists to prevent.
+    if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) {
+      root.style.scrollbarGutter = 'stable';
+    }
     body.style.overflow = 'hidden';
 
-    const grew = body.getBoundingClientRect().width - widthBefore;
+    const grew = root.getBoundingClientRect().width - widthBefore;
     if (grew > 0) {
-      body.style.paddingRight = `${grew}px`;
+      // Padding the ROOT holds in-flow content still whatever the body's own
+      // width is (a `max-width` body does not widen with the viewport, so
+      // padding the body would miss it), and it leaves the page's own body
+      // padding untouched.
+      root.style.paddingRight = `${padBefore + grew}px`;
+      // A fixed box lays out against the viewport, so no padding here can reach
+      // it. Publish what it moved by and let it opt in.
       root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
     }
   }
@@ -225,10 +250,16 @@ function lockScroll(): void {
 function unlockScroll(): void {
   scrollLockCount = Math.max(0, scrollLockCount - 1);
   if (scrollLockCount === 0) {
+    const root = document.documentElement;
     document.body.style.overflow = savedOverflow;
-    document.body.style.paddingRight = savedPaddingRight;
-    document.documentElement.style.scrollbarGutter = savedScrollbarGutter;
-    document.documentElement.style.removeProperty(SCROLLBAR_COMPENSATION);
+    root.style.paddingRight = savedRootPaddingRight;
+    root.style.scrollbarGutter = savedScrollbarGutter;
+    // RESTORED, not removed. dialog.ts and alert-dialog.ts keep separate
+    // refcounts on purpose, so an alert-dialog opened from inside an open dialog
+    // unlocks first; removing the property outright would strip the dialog's
+    // compensation while it is still open and jump a fixed header mid-flow.
+    if (savedCompensation) root.style.setProperty(SCROLLBAR_COMPENSATION, savedCompensation);
+    else root.style.removeProperty(SCROLLBAR_COMPENSATION);
   }
 }
 
diff --git a/packages/ui/test/components/browser/ui-overlay.test.js b/packages/ui/test/components/browser/ui-overlay.test.js
index 19ec0f37e..1c9d41151 100644
--- a/packages/ui/test/components/browser/ui-overlay.test.js
+++ b/packages/ui/test/components/browser/ui-overlay.test.js
@@ -259,7 +259,7 @@ suite('ui-dialog scroll lock layout', () => {
       await tick();
 
       assert.equal(document.body.style.overflow, '', 'body overflow restored');
-      assert.equal(document.body.style.paddingRight, '', 'body padding restored');
+      assert.equal(document.documentElement.style.paddingRight, '', 'root padding restored');
       assert.equal(document.documentElement.style.scrollbarGutter, '', 'scrollbar gutter restored');
       assert.equal(
         document.documentElement.style.getPropertyValue('--wj-scrollbar-compensation'),
@@ -271,6 +271,141 @@ suite('ui-dialog scroll lock layout', () => {
     }
   });
 
+  // dialog.ts and alert-dialog.ts keep SEPARATE module-scope refcounts on
+  // purpose (so `webjs ui add alert-dialog` stays self contained), so a confirm
+  // opened from inside a dialog is the one interleaving that duplication
+  // creates. The inner lock unlocks first, and it must not strip the outer
+  // dialog's compensation on the way out.
+  test('an alert-dialog inside an open dialog does not release the dialog compensation', async function () {
+    const page = await buildFixedHeaderPage();
+    try {
+      if (scrollbarWidth() === 0) this.skip();
+      await import(`${COMPONENTS_DIR}/alert-dialog.ts`);
+
+      const root = page.track(
+        await mount(html`
+          
+            Outer
+          
+          
+            Confirm
+          
+        `),
+      );
+      const dialog = root.querySelector('ui-dialog');
+      const confirm = root.querySelector('ui-alert-dialog');
+
+      const fixedBefore = centreOf(page.inner);
+      const flowBefore = centreOf(page.flow);
+
+      dialog.show();
+      await tick();
+      const compensationWithDialog = document.documentElement.style.getPropertyValue(
+        '--wj-scrollbar-compensation',
+      );
+
+      confirm.show();
+      await tick();
+      assert.equal(centreOf(page.inner), fixedBefore, 'fixed header held with the confirm open');
+
+      confirm.hide();
+      await tick();
+      assert.equal(
+        document.documentElement.style.getPropertyValue('--wj-scrollbar-compensation'),
+        compensationWithDialog,
+        'closing the confirm leaves the dialog compensation as it was',
+      );
+      assert.equal(document.body.style.overflow, 'hidden', 'still locked, the dialog is open');
+      assert.equal(centreOf(page.inner), fixedBefore, 'fixed header held after the confirm closes');
+      assert.equal(centreOf(page.flow), flowBefore, 'in-flow content held after the confirm closes');
+
+      dialog.hide();
+      await tick();
+      assert.equal(document.body.style.overflow, '', 'released on the outermost close');
+      assert.equal(centreOf(page.inner), fixedBefore, 'fixed header back where it started');
+    } finally {
+      page.teardown();
+    }
+  });
+
+  // An app that already reserves both gutters (the standard no-layout-shift
+  // technique) keeps them through the lock, so overwriting its choice with the
+  // single-edge value would drop one and introduce a shift the page never had.
+  test("an app's own scrollbar-gutter is left alone", async function () {
+    const page = await buildFixedHeaderPage();
+    const root = document.documentElement;
+    root.style.scrollbarGutter = 'stable both-edges';
+    try {
+      await tick();
+      if (scrollbarWidth() === 0) this.skip();
+
+      const mountedRoot = page.track(
+        await mount(html`
+          
+            T
+          
+        `),
+      );
+      const dialog = mountedRoot.querySelector('ui-dialog');
+      const fixedBefore = centreOf(page.inner);
+      const flowBefore = centreOf(page.flow);
+
+      dialog.show();
+      await tick();
+      assert.equal(
+        root.style.scrollbarGutter,
+        'stable both-edges',
+        "the page's own gutter choice is not overwritten",
+      );
+      assert.equal(centreOf(page.inner), fixedBefore, 'fixed header does not move');
+      assert.equal(centreOf(page.flow), flowBefore, 'in-flow content does not move');
+
+      dialog.hide();
+      await tick();
+      assert.equal(root.style.scrollbarGutter, 'stable both-edges', 'gutter choice restored');
+    } finally {
+      page.teardown();
+      root.style.scrollbarGutter = '';
+    }
+  });
+
+  // The compensation is ADDED to whatever padding the page already had on the
+  // root, not written over it.
+  test("an app's own root padding is added to, not replaced", async function () {
+    const page = await buildFixedHeaderPage();
+    const root = document.documentElement;
+    root.style.paddingRight = '20px';
+    try {
+      await tick();
+      if (scrollbarWidth() === 0) this.skip();
+
+      const mountedRoot = page.track(
+        await mount(html`
+          
+            T
+          
+        `),
+      );
+      const dialog = mountedRoot.querySelector('ui-dialog');
+      const flowBefore = centreOf(page.flow);
+
+      dialog.show();
+      await tick();
+      assert.ok(
+        parseFloat(getComputedStyle(root).paddingRight) >= 20,
+        "the page's own padding is never reduced",
+      );
+      assert.equal(centreOf(page.flow), flowBefore, 'in-flow content does not move');
+
+      dialog.hide();
+      await tick();
+      assert.equal(root.style.paddingRight, '20px', "the page's own padding is restored exactly");
+    } finally {
+      page.teardown();
+      root.style.paddingRight = '';
+    }
+  });
+
   test('nested dialogs release the compensation only on the outermost close', async function () {
     const page = await buildFixedHeaderPage();
     try {

From 8fa72b5ece3e1f0a1ef06c2adf5b4591cc8996c9 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Tue, 28 Jul 2026 03:03:21 +0530
Subject: [PATCH 05/16] fix(ui): make the scroll lock safe to release out of
 order

The refcount was per module, and dialog.ts and alert-dialog.ts ship as separate
copies, so two counters were mutating the same . That only works if they
release LIFO, and they do not: disconnectedCallback fires in tree order and the
before-cache close runs in registration order, so a confirm opened inside a
dialog releases OUTER first. The inner unlock then re-applied the values it had
captured with nothing left to clear them, leaving the page padded and the
compensation published for the rest of the session.

One shared refcount on globalThis makes release order irrelevant, and keeps
both files importing nothing from each other.

Three test holes closed alongside it. Teardown now awaits the unlock before
restoring the app's own root styles, because hide() only queues the unlock and
the old order let it write those values straight back. Each test now asserts the
lock actually engaged, so an assertion cannot pass because nothing happened. And
a zero-width scrollbar is no longer always a skip: a leaked lock fails on every
engine, and Chromium fails outright, since its scrollbar is guaranteed by the
runner config rather than by the platform.
---
 .../registry/components/alert-dialog.ts       |  66 +++++---
 .../ui/packages/registry/components/dialog.ts |  66 +++++---
 .../components/browser/ui-overlay.test.js     | 154 ++++++++++++++----
 3 files changed, 214 insertions(+), 72 deletions(-)

diff --git a/packages/ui/packages/registry/components/alert-dialog.ts b/packages/ui/packages/registry/components/alert-dialog.ts
index 63701af40..84af7776a 100644
--- a/packages/ui/packages/registry/components/alert-dialog.ts
+++ b/packages/ui/packages/registry/components/alert-dialog.ts
@@ -133,24 +133,47 @@ function installStyles(): void {
 // about both scrollbar geometry and gutter support. When mechanism 1 works the
 // measured residual is zero, so no padding is applied and the custom property is
 // never set: the two mechanisms cannot double-compensate.
-const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
+// The lock's state is DOCUMENT level, so it is keyed on globalThis rather than
+// module scope. dialog.ts and alert-dialog.ts ship as separate copies (so
+// `webjs ui add alert-dialog` stays self contained), and two independent
+// counters mutating the same  can only be released safely in LIFO order.
+// They are not: `disconnectedCallback` fires in tree order and the #766
+// before-cache close runs in registration order, so a confirm inside a dialog
+// releases OUTER first, and the inner unlock would then re-apply the values it
+// captured with nothing left to clear them, leaving  padded for good. One
+// shared counter makes release order irrelevant.
+interface ScrollLockState {
+  count: number;
+  overflow: string;
+  rootPaddingRight: string;
+  scrollbarGutter: string;
+  compensation: string;
+}
 
-let scrollLockCount = 0;
-let savedOverflow = '';
-let savedRootPaddingRight = '';
-let savedScrollbarGutter = '';
-let savedCompensation = '';
+const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
+const SCROLL_LOCK_KEY = '__wjScrollLock';
+
+function scrollLockState(): ScrollLockState {
+  const store = globalThis as unknown as Record;
+  let state = store[SCROLL_LOCK_KEY];
+  if (!state) {
+    state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' };
+    store[SCROLL_LOCK_KEY] = state;
+  }
+  return state;
+}
 
 function lockScroll(): void {
-  if (scrollLockCount === 0) {
+  const state = scrollLockState();
+  if (state.count === 0) {
     const root = document.documentElement;
     const body = document.body;
     const rootStyle = getComputedStyle(root);
 
-    savedOverflow = body.style.overflow;
-    savedRootPaddingRight = root.style.paddingRight;
-    savedScrollbarGutter = root.style.scrollbarGutter;
-    savedCompensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
+    state.overflow = body.style.overflow;
+    state.rootPaddingRight = root.style.paddingRight;
+    state.scrollbarGutter = root.style.scrollbarGutter;
+    state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
 
     //  is an in-flow block filling the initial containing block, so its
     // border box IS the viewport width, and it is re-laid-out synchronously.
@@ -186,21 +209,20 @@ function lockScroll(): void {
       root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
     }
   }
-  scrollLockCount++;
+  state.count++;
 }
 
 function unlockScroll(): void {
-  scrollLockCount = Math.max(0, scrollLockCount - 1);
-  if (scrollLockCount === 0) {
+  const state = scrollLockState();
+  state.count = Math.max(0, state.count - 1);
+  if (state.count === 0) {
     const root = document.documentElement;
-    document.body.style.overflow = savedOverflow;
-    root.style.paddingRight = savedRootPaddingRight;
-    root.style.scrollbarGutter = savedScrollbarGutter;
-    // RESTORED, not removed. dialog.ts and alert-dialog.ts keep separate
-    // refcounts on purpose, so an alert-dialog opened from inside an open dialog
-    // unlocks first; removing the property outright would strip the dialog's
-    // compensation while it is still open and jump a fixed header mid-flow.
-    if (savedCompensation) root.style.setProperty(SCROLLBAR_COMPENSATION, savedCompensation);
+    document.body.style.overflow = state.overflow;
+    root.style.paddingRight = state.rootPaddingRight;
+    root.style.scrollbarGutter = state.scrollbarGutter;
+    // Restored rather than removed, so a value the PAGE set before any dialog
+    // opened survives the lock.
+    if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation);
     else root.style.removeProperty(SCROLLBAR_COMPENSATION);
   }
 }
diff --git a/packages/ui/packages/registry/components/dialog.ts b/packages/ui/packages/registry/components/dialog.ts
index df89a79ed..aea06ff3a 100644
--- a/packages/ui/packages/registry/components/dialog.ts
+++ b/packages/ui/packages/registry/components/dialog.ts
@@ -191,24 +191,47 @@ function installStyles(): void {
 // never set: the two mechanisms cannot double-compensate.
 // --------------------------------------------------------------------------
 
-const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
+// The lock's state is DOCUMENT level, so it is keyed on globalThis rather than
+// module scope. dialog.ts and alert-dialog.ts ship as separate copies (so
+// `webjs ui add alert-dialog` stays self contained), and two independent
+// counters mutating the same  can only be released safely in LIFO order.
+// They are not: `disconnectedCallback` fires in tree order and the #766
+// before-cache close runs in registration order, so a confirm inside a dialog
+// releases OUTER first, and the inner unlock would then re-apply the values it
+// captured with nothing left to clear them, leaving  padded for good. One
+// shared counter makes release order irrelevant.
+interface ScrollLockState {
+  count: number;
+  overflow: string;
+  rootPaddingRight: string;
+  scrollbarGutter: string;
+  compensation: string;
+}
 
-let scrollLockCount = 0;
-let savedOverflow = '';
-let savedRootPaddingRight = '';
-let savedScrollbarGutter = '';
-let savedCompensation = '';
+const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
+const SCROLL_LOCK_KEY = '__wjScrollLock';
+
+function scrollLockState(): ScrollLockState {
+  const store = globalThis as unknown as Record;
+  let state = store[SCROLL_LOCK_KEY];
+  if (!state) {
+    state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' };
+    store[SCROLL_LOCK_KEY] = state;
+  }
+  return state;
+}
 
 function lockScroll(): void {
-  if (scrollLockCount === 0) {
+  const state = scrollLockState();
+  if (state.count === 0) {
     const root = document.documentElement;
     const body = document.body;
     const rootStyle = getComputedStyle(root);
 
-    savedOverflow = body.style.overflow;
-    savedRootPaddingRight = root.style.paddingRight;
-    savedScrollbarGutter = root.style.scrollbarGutter;
-    savedCompensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
+    state.overflow = body.style.overflow;
+    state.rootPaddingRight = root.style.paddingRight;
+    state.scrollbarGutter = root.style.scrollbarGutter;
+    state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
 
     //  is an in-flow block filling the initial containing block, so its
     // border box IS the viewport width, and it is re-laid-out synchronously.
@@ -244,21 +267,20 @@ function lockScroll(): void {
       root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
     }
   }
-  scrollLockCount++;
+  state.count++;
 }
 
 function unlockScroll(): void {
-  scrollLockCount = Math.max(0, scrollLockCount - 1);
-  if (scrollLockCount === 0) {
+  const state = scrollLockState();
+  state.count = Math.max(0, state.count - 1);
+  if (state.count === 0) {
     const root = document.documentElement;
-    document.body.style.overflow = savedOverflow;
-    root.style.paddingRight = savedRootPaddingRight;
-    root.style.scrollbarGutter = savedScrollbarGutter;
-    // RESTORED, not removed. dialog.ts and alert-dialog.ts keep separate
-    // refcounts on purpose, so an alert-dialog opened from inside an open dialog
-    // unlocks first; removing the property outright would strip the dialog's
-    // compensation while it is still open and jump a fixed header mid-flow.
-    if (savedCompensation) root.style.setProperty(SCROLLBAR_COMPENSATION, savedCompensation);
+    document.body.style.overflow = state.overflow;
+    root.style.paddingRight = state.rootPaddingRight;
+    root.style.scrollbarGutter = state.scrollbarGutter;
+    // Restored rather than removed, so a value the PAGE set before any dialog
+    // opened survives the lock.
+    if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation);
     else root.style.removeProperty(SCROLLBAR_COMPENSATION);
   }
 }
diff --git a/packages/ui/test/components/browser/ui-overlay.test.js b/packages/ui/test/components/browser/ui-overlay.test.js
index 1c9d41151..dcaba86ef 100644
--- a/packages/ui/test/components/browser/ui-overlay.test.js
+++ b/packages/ui/test/components/browser/ui-overlay.test.js
@@ -133,12 +133,43 @@ suite('ui-dialog', () => {
 // These assertions are only meaningful where the scrollbar takes LAYOUT WIDTH.
 // Headless browsers default to overlay scrollbars (zero width), which makes the
 // whole scenario inert, so `buildFixedHeaderPage()` styles the root scrollbar to
-// force a classic one and each test skips EXPLICITLY when the engine still
-// reports zero. A silent pass under overlay scrollbars would prove nothing.
+// force a classic one and `requireClassicScrollbar()` decides what a zero width
+// means: a genuine overlay-only engine skips, Chromium fails (its scrollbar is
+// guaranteed by the WTR config), and a lock leaked by an earlier test fails
+// everywhere. A silent pass here would prove nothing, so none is reachable.
 // --------------------------------------------------------------------------
 
 const scrollbarWidth = () => window.innerWidth - document.documentElement.clientWidth;
 
+/**
+ * Gate a scroll-lock assertion on a scrollbar that actually takes layout width.
+ *
+ * Skipping is correct on an engine whose scrollbars are always overlay (Firefox
+ * on Linux cannot be forced off them). It is NOT correct on Chromium, where the
+ * root WTR config drops Playwright's `--hide-scrollbars` precisely so this suite
+ * has a real scrollbar: a zero width there means that flag stopped working and
+ * the whole suite would otherwise go silently green while asserting nothing.
+ */
+function requireClassicScrollbar(ctx) {
+  // A lock leaked by an earlier test hides the scrollbar, which is
+  // indistinguishable from an overlay-scrollbar engine and would silently skip.
+  // Fail instead, on every engine: a leak is the failure, not a reason to stop
+  // looking for one.
+  assert.equal(document.body.style.overflow, '', 'the page arrived with no lock left over');
+  if (scrollbarWidth() > 0) return true;
+  const ua = navigator.userAgent;
+  const isChromium = ua.includes('Chrome/') && !ua.includes('Edg/');
+  assert.equal(
+    isChromium,
+    false,
+    'Chromium reported a zero-width scrollbar: the --hide-scrollbars opt-out in ' +
+      'web-test-runner.config.js is no longer taking effect, so these assertions ' +
+      'would pass without testing anything',
+  );
+  ctx.skip();
+  return false;
+}
+
 /**
  * Build the reproduction: a page that overflows, a fixed full-width header with
  * a centred inner box (the thing that visibly jumped), and a centred in-flow
@@ -150,7 +181,7 @@ const scrollbarWidth = () => window.innerWidth - document.documentElement.client
  * when the compensation path runs (padding holds the CONTENT still, not the
  * border box), so measuring the body would report a false shift.
  */
-async function buildFixedHeaderPage() {
+async function buildFixedHeaderPage(rootStyles = {}) {
   const style = document.createElement('style');
   style.textContent =
     'html::-webkit-scrollbar { width: 15px; background: #eee; }' +
@@ -178,6 +209,16 @@ async function buildFixedHeaderPage() {
   // the precondition check has to wait or it under-reports the width.
   await tick();
 
+  // Applied here, and restored in teardown AFTER the unlock has run, because
+  // `hide()` defers unlockScroll() to a microtask: resetting these in a test's
+  // own `finally` would run first and the unlock would then write the app's
+  // values straight back onto  with nothing left to clear them.
+  const savedRoot = {};
+  for (const [prop, value] of Object.entries(rootStyles)) {
+    savedRoot[prop] = document.documentElement.style[prop];
+    document.documentElement.style[prop] = value;
+  }
+
   const mounted = [];
 
   return {
@@ -189,18 +230,23 @@ async function buildFixedHeaderPage() {
       mounted.push(root);
       return root;
     },
-    teardown() {
-      // Close every dialog FIRST. An assertion that throws mid-test would
-      // otherwise leave the lock engaged, and the next test would see a page
-      // with no scrollbar and skip itself instead of running.
+    async teardown() {
+      // Close every dialog FIRST, and WAIT for the unlock. An assertion that
+      // throws mid-test would otherwise leave the lock engaged, and the next
+      // test would see a page with no scrollbar and skip itself instead of
+      // running. hide() only queues the unlock, so the await is load-bearing.
       for (const root of mounted) {
         for (const el of root.querySelectorAll('ui-dialog, ui-alert-dialog')) el.hide?.();
-        root.remove();
       }
+      await tick();
+      for (const root of mounted) root.remove();
       style.remove();
       spacer.remove();
       flow.remove();
       header.remove();
+      for (const [prop, value] of Object.entries(savedRoot)) {
+        document.documentElement.style[prop] = value;
+      }
     },
   };
 }
@@ -215,7 +261,7 @@ suite('ui-dialog scroll lock layout', () => {
   test('opening a dialog leaves a position:fixed header exactly where it was', async function () {
     const page = await buildFixedHeaderPage();
     try {
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
 
       const root = page.track(await mount(html`
         
@@ -229,6 +275,7 @@ suite('ui-dialog scroll lock layout', () => {
 
       dialog.show();
       await tick();
+      assert.equal(document.body.style.overflow, 'hidden', 'the lock engaged');
 
       assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on open');
       assert.equal(centreOf(page.flow), flowBefore, 'in-flow content does not move on open');
@@ -237,14 +284,14 @@ suite('ui-dialog scroll lock layout', () => {
       await tick();
       assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on close');
     } finally {
-      page.teardown();
+      await page.teardown();
     }
   });
 
   test('the lock is released cleanly, leaving no compensation behind', async function () {
     const page = await buildFixedHeaderPage();
     try {
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
 
       const root = page.track(await mount(html`
         
@@ -267,7 +314,7 @@ suite('ui-dialog scroll lock layout', () => {
         'compensation custom property cleared',
       );
     } finally {
-      page.teardown();
+      await page.teardown();
     }
   });
 
@@ -279,7 +326,7 @@ suite('ui-dialog scroll lock layout', () => {
   test('an alert-dialog inside an open dialog does not release the dialog compensation', async function () {
     const page = await buildFixedHeaderPage();
     try {
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
       await import(`${COMPONENTS_DIR}/alert-dialog.ts`);
 
       const root = page.track(
@@ -324,7 +371,59 @@ suite('ui-dialog scroll lock layout', () => {
       assert.equal(document.body.style.overflow, '', 'released on the outermost close');
       assert.equal(centreOf(page.inner), fixedBefore, 'fixed header back where it started');
     } finally {
-      page.teardown();
+      await page.teardown();
+    }
+  });
+
+  // Release order is NOT guaranteed to be LIFO: disconnectedCallback fires in
+  // tree order and the before-cache close runs in registration order, so an
+  // outer dialog can release before an inner confirm that is still open. With a
+  // refcount per module the inner unlock then re-applies what it captured, with
+  // nothing left to clear it, and  stays padded for the rest of the
+  // session. One shared refcount is what makes the order irrelevant.
+  test('releasing the outer dialog first still leaves the page clean', async function () {
+    const page = await buildFixedHeaderPage();
+    try {
+      if (!requireClassicScrollbar(this)) return;
+      await import(`${COMPONENTS_DIR}/alert-dialog.ts`);
+
+      const root = page.track(
+        await mount(html`
+          
+            Outer
+          
+          
+            Confirm
+          
+        `),
+      );
+      const dialog = root.querySelector('ui-dialog');
+      const confirm = root.querySelector('ui-alert-dialog');
+      const fixedBefore = centreOf(page.inner);
+
+      dialog.show();
+      await tick();
+      confirm.show();
+      await tick();
+
+      // Outer first, the order the DOM actually gives us on a subtree removal.
+      dialog.hide();
+      await tick();
+      assert.equal(document.body.style.overflow, 'hidden', 'still locked, the confirm is open');
+      assert.equal(centreOf(page.inner), fixedBefore, 'fixed header still held');
+
+      confirm.hide();
+      await tick();
+      assert.equal(document.body.style.overflow, '', 'overflow released');
+      assert.equal(document.documentElement.style.paddingRight, '', 'no padding left on the page');
+      assert.equal(
+        document.documentElement.style.getPropertyValue('--wj-scrollbar-compensation'),
+        '',
+        'no compensation left on the page',
+      );
+      assert.equal(centreOf(page.inner), fixedBefore, 'fixed header back where it started');
+    } finally {
+      await page.teardown();
     }
   });
 
@@ -332,12 +431,11 @@ suite('ui-dialog scroll lock layout', () => {
   // technique) keeps them through the lock, so overwriting its choice with the
   // single-edge value would drop one and introduce a shift the page never had.
   test("an app's own scrollbar-gutter is left alone", async function () {
-    const page = await buildFixedHeaderPage();
+    const page = await buildFixedHeaderPage({ scrollbarGutter: 'stable both-edges' });
     const root = document.documentElement;
-    root.style.scrollbarGutter = 'stable both-edges';
     try {
       await tick();
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
 
       const mountedRoot = page.track(
         await mount(html`
@@ -352,6 +450,7 @@ suite('ui-dialog scroll lock layout', () => {
 
       dialog.show();
       await tick();
+      assert.equal(document.body.style.overflow, 'hidden', 'the lock engaged');
       assert.equal(
         root.style.scrollbarGutter,
         'stable both-edges',
@@ -364,20 +463,18 @@ suite('ui-dialog scroll lock layout', () => {
       await tick();
       assert.equal(root.style.scrollbarGutter, 'stable both-edges', 'gutter choice restored');
     } finally {
-      page.teardown();
-      root.style.scrollbarGutter = '';
+      await page.teardown();
     }
   });
 
   // The compensation is ADDED to whatever padding the page already had on the
   // root, not written over it.
   test("an app's own root padding is added to, not replaced", async function () {
-    const page = await buildFixedHeaderPage();
+    const page = await buildFixedHeaderPage({ paddingRight: '20px' });
     const root = document.documentElement;
-    root.style.paddingRight = '20px';
     try {
       await tick();
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
 
       const mountedRoot = page.track(
         await mount(html`
@@ -391,6 +488,7 @@ suite('ui-dialog scroll lock layout', () => {
 
       dialog.show();
       await tick();
+      assert.equal(document.body.style.overflow, 'hidden', 'the lock engaged');
       assert.ok(
         parseFloat(getComputedStyle(root).paddingRight) >= 20,
         "the page's own padding is never reduced",
@@ -401,15 +499,14 @@ suite('ui-dialog scroll lock layout', () => {
       await tick();
       assert.equal(root.style.paddingRight, '20px', "the page's own padding is restored exactly");
     } finally {
-      page.teardown();
-      root.style.paddingRight = '';
+      await page.teardown();
     }
   });
 
   test('nested dialogs release the compensation only on the outermost close', async function () {
     const page = await buildFixedHeaderPage();
     try {
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
 
       const root = page.track(await mount(html`
         
@@ -445,7 +542,7 @@ suite('ui-dialog scroll lock layout', () => {
       );
       assert.equal(centreOf(page.inner), fixedBefore, 'fixed header back where it started');
     } finally {
-      page.teardown();
+      await page.teardown();
     }
   });
 });
@@ -682,7 +779,7 @@ suite('ui-alert-dialog', () => {
   test('opening an alert-dialog leaves a position:fixed header where it was', async function () {
     const page = await buildFixedHeaderPage();
     try {
-      if (scrollbarWidth() === 0) this.skip();
+      if (!requireClassicScrollbar(this)) return;
 
       const root = page.track(await mount(html`
         
@@ -696,6 +793,7 @@ suite('ui-alert-dialog', () => {
 
       dialog.show();
       await tick();
+      assert.equal(document.body.style.overflow, 'hidden', 'the lock engaged');
       assert.equal(centreOf(page.inner), fixedBefore, 'fixed header content does not move on open');
       assert.equal(centreOf(page.flow), flowBefore, 'in-flow content does not move on open');
 
@@ -708,7 +806,7 @@ suite('ui-alert-dialog', () => {
         'compensation released on close',
       );
     } finally {
-      page.teardown();
+      await page.teardown();
     }
   });
 

From 7dd22bd021bfdb33f2be95884f7f045d284984f0 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Tue, 28 Jul 2026 03:07:58 +0530
Subject: [PATCH 06/16] fix(website): keep the header chrome full bleed while
 compensating it

The opt-in was on .site-top, the fixed wrapper, which paints nothing. Padding it
insets the header element that carries the background and bottom border, so with
the compensation applied the painted chrome ended 15px short of the edge:
measured 1265 to 1250 in Chromium with the property forced. Moving it to the
centring bar holds the nav centre exactly as before and leaves the chrome
edge-to-edge.

Also documents what the lock actually does now, on all four surfaces. They each
said it reserves the gutter, full stop, and none mentioned that it skips a page
that declared its own gutter, or that the fallback pads , which is
otherwise a mystery inline style on your root element.

The SSR test pins both halves, because the opt-in is one declaration in a layout
and nothing else would notice it going missing.
---
 .agents/skills/webjs/references/styling.md    |  8 +-
 packages/ui/AGENTS.md                         | 13 +--
 website/app/docs/styling/page.ts              |  2 +-
 website/app/layout.ts                         | 15 ++--
 .../test/ssr/fixed-header-scroll-lock.test.ts | 80 +++++++++++++++++++
 5 files changed, 104 insertions(+), 14 deletions(-)
 create mode 100644 website/test/ssr/fixed-header-scroll-lock.test.ts

diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md
index ea99535d2..2ef7d1b86 100644
--- a/.agents/skills/webjs/references/styling.md
+++ b/.agents/skills/webjs/references/styling.md
@@ -211,12 +211,16 @@ For a dashboard, an alternative is an app-shell scroll container (a non-scrollin
 
 Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width.
 
-`@webjsdev/ui`'s `` / `` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. Where the engine ignores `scrollbar-gutter` (WebKit today), the lock publishes the leftover width on `` as `--wj-scrollbar-compensation`, and a fixed header opts in with one line:
+`@webjsdev/ui`'s `` / `` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. It leaves the gutter alone if your page already declared its own `scrollbar-gutter`, on the assumption that a page which made that choice meant it.
+
+Two engines, two outcomes. Chromium and Firefox honour the reserved gutter, so nothing moves and the lock does nothing else. WebKit ignores `scrollbar-gutter`, so the viewport does widen there; the lock measures how much, pads `` by it to hold in-flow content still (added to any padding you already had, and restored on close), and publishes the amount as `--wj-scrollbar-compensation` so a fixed element can opt in with one line:
 
 ```css
 header { position: fixed; inset-inline: 0; top: 0; padding-right: var(--wj-scrollbar-compensation, 0px); }
 ```
 
-The property is only set while a lock is active AND the gutter did not hold, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate.
+The property is only set while a lock is active AND the viewport actually widened, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. If you find inline `padding-right` on your `` while a modal is open, that is this, and it comes off on close.
+
+Put the declaration on the element that is actually off-centre, not necessarily the fixed one. If your fixed wrapper paints nothing and a child carries the background, padding the wrapper insets that child and leaves an unpainted strip at the edge; pad the inner centring container instead, and the chrome stays full bleed. That is what `website/app/layout.ts` does.
 
 Rolling your own scroll lock? Two things the kit learned the hard way. Reserve the gutter rather than relying on padding alone, or a fixed header will jump. And when you do pad, pad ``, not ``: a `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is. Measure the root's own border box to decide the amount, since it tracks the viewport and is re-laid-out synchronously.
diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md
index db0944c20..8b309a4ff 100644
--- a/packages/ui/AGENTS.md
+++ b/packages/ui/AGENTS.md
@@ -185,7 +185,7 @@ tracked source, the standard shadcn "you own it" pattern.
 | 1b | `collapsible` | `collapsibleClass`, `collapsibleTriggerClass`, `collapsibleContentClass`. Compose with `
` + ``. | | 1b | `progress` | `progressClass()`, apply to native ``. Browser draws the bar via `::-webkit-progress-value` and `::-moz-progress-bar`. Omit `value` for the indeterminate / pulse state. | | 2 | `toggle-group` | `` + ``. Roving tabindex (one Tab stop) with Arrow / Home / End navigation, plus `aria-pressed` per item. | -| 2 | `dialog` | `` + `` / `` / ``. Built on native `.showModal()`, top-layer rendering, ::backdrop overlay, focus trap, Escape close, and focus restoration are all platform-provided. We add body-scroll lock + class helpers for `dialogHeader/Title/Description/Footer`. On open it wires `aria-labelledby` / `aria-describedby` to the `data-slot="dialog-title"` / `dialog-description` nodes (falling back to the first heading / paragraph). | +| 2 | `dialog` | `` + `` / `` / ``. Built on native `.showModal()`, top-layer rendering, ::backdrop overlay, focus trap, Escape close, and focus restoration are all platform-provided. We add a scroll lock (refcounted, and shift-free for a `position: fixed` header, see invariant 5) + class helpers for `dialogHeader/Title/Description/Footer`. On open it wires `aria-labelledby` / `aria-describedby` to the `data-slot="dialog-title"` / `dialog-description` nodes (falling back to the first heading / paragraph). | | 2 | `alert-dialog` | Like dialog, role=alertdialog. Native Escape close is cancelled via the `cancel` event; no backdrop-click dismissal. `` / ``. Wires `aria-labelledby` / `aria-describedby` to its `alert-dialog-title` / `alert-dialog-description` the same way. | | 2 | `tooltip` | ``, hover/focus + delay. Content uses `popover="manual"` for top-layer rendering. The trigger references the tip via `aria-describedby` (APG tooltip wiring). | | 2 | `hover-card` | ``, hover with linger-keep-open. Content uses `popover="manual"` for top-layer rendering. The trigger (focusable, also opens on focus) gets `aria-haspopup` / `aria-expanded` / `aria-controls`. | @@ -309,10 +309,13 @@ when the caller passes an explicit custom `--registry `. compensates the hidden scrollbar by padding the body only, which leaves a `position: fixed` header sliding right by half the scrollbar width. Since WebJs recommends exactly that pinned-header pattern (#610), our lock instead - reserves the scrollbar gutter, and publishes `--wj-scrollbar-compensation` - on `` as a fallback where the engine ignores `scrollbar-gutter`. This - is presentation and lifecycle, not API: every tag, variant, and data - attribute still matches shadcn. + reserves the scrollbar gutter (unless the page already declared its own), and + where the engine ignores `scrollbar-gutter` it pads `` by the measured + residual and publishes it as `--wj-scrollbar-compensation` for a fixed element + to opt into. The refcount is shared across both component copies via + `globalThis`, because release order is not guaranteed to be LIFO. This is + presentation and lifecycle, not API: every tag, variant, and data attribute + still matches shadcn. 6. **Native form controls participate in `` submission natively.** `` is a real input , diff --git a/website/app/docs/styling/page.ts b/website/app/docs/styling/page.ts index f2c1599a2..d48b5e52b 100644 --- a/website/app/docs/styling/page.ts +++ b/website/app/docs/styling/page.ts @@ -287,7 +287,7 @@ export default function RootLayout({ children }: { children: unknown }) {

Pin a header with position: fixed, never position: sticky. A sticky header flickers its background for one frame on iOS WebKit (every iOS browser) during a client-router navigation: the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug, and the GPU-promotion hacks (translateZ, will-change) do not fix it. Use position: fixed and reserve the header height on the content with a --header-height CSS variable (kept exact by a ResizeObserver). It is iOS-only and invisible on desktop, Android, and in DevTools emulation, so it shows only on a real device.

-

A fixed header plus a modal that locks scroll. Locking page scroll hides the scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. Padding the body compensates in-flow content but does nothing for a fixed header, which lays out against the initial containing block rather than the body's padding box, so it slides right by half the scrollbar width. <ui-dialog> and <ui-alert-dialog> handle this: the lock reserves the scrollbar gutter so the viewport width never changes. Where the engine ignores scrollbar-gutter (WebKit today) it publishes the leftover width on <html> as --wj-scrollbar-compensation, which a fixed header opts into with padding-right: var(--wj-scrollbar-compensation, 0px). The property is set only while a lock is active and the gutter did not hold, so the 0px fallback covers every other moment.

+

A fixed header plus a modal that locks scroll. Locking page scroll hides the scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. Padding the body compensates in-flow content but does nothing for a fixed header, which lays out against the initial containing block rather than the body's padding box, so it slides right by half the scrollbar width. <ui-dialog> and <ui-alert-dialog> handle this: the lock reserves the scrollbar gutter so the viewport width never changes, leaving it alone if your page already declared its own. Chromium and Firefox honour that and nothing moves. WebKit ignores scrollbar-gutter, so there the lock measures how much the viewport widened, pads <html> by it to hold in-flow content still, and publishes the amount as --wj-scrollbar-compensation, which a fixed element opts into with padding-right: var(--wj-scrollbar-compensation, 0px). The property is set only while a lock is active and the viewport actually widened, so the 0px fallback covers every other moment. Put that declaration on whichever element is off-centre: if your fixed wrapper paints nothing and a child carries the background, pad the inner centring container instead, or the painted chrome ends short of the edge.

Component scope

// components/my-card.ts
diff --git a/website/app/layout.ts b/website/app/layout.ts
index 44aa53966..e1c894834 100644
--- a/website/app/layout.ts
+++ b/website/app/layout.ts
@@ -312,11 +312,14 @@ export default function RootLayout({ children }: { children: unknown }) {
          the viewport. The kit's dialog reserves the scrollbar gutter so nothing
          moves, but WebKit ignores scrollbar-gutter, and there it publishes the
          leftover width instead. The header is position: fixed, so it lays out
-         against the viewport and cannot be reached by the body padding that
-         holds in-flow content still. Opting in is what keeps it from sliding
-         right by half the scrollbar width when a dialog opens. The 0px fallback
-         is what applies at every other moment. */
-      .site-top { padding-right: var(--wj-scrollbar-compensation, 0px); }
+         against the viewport and cannot be reached by the padding that holds
+         in-flow content still. The compensation goes on the CENTRING BAR rather
+         than on .site-top: padding the fixed wrapper would inset the header
+         element that paints the background and bottom border, leaving an
+         unpainted strip at the top right, while padding the bar keeps the chrome
+         full bleed and still holds the nav centred. See the pr-[calc(...)] below,
+         which resolves to px-6 at every moment except an open modal on an engine
+         that ignores the gutter. */
       .glow-layer { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
       .glow-layer::before {
         content: ''; position: absolute; inset: 0;
@@ -356,7 +359,7 @@ export default function RootLayout({ children }: { children: unknown }) {
 
     
-
+
webjs diff --git a/website/test/ssr/fixed-header-scroll-lock.test.ts b/website/test/ssr/fixed-header-scroll-lock.test.ts new file mode 100644 index 000000000..56c11f851 --- /dev/null +++ b/website/test/ssr/fixed-header-scroll-lock.test.ts @@ -0,0 +1,80 @@ +/** + * The site's fixed header opts into the dialog scroll lock's compensation (#1144). + * + * Opening a `` or `` hides the page scrollbar, which + * widens the viewport. The kit's lock reserves the scrollbar gutter so nothing + * moves, but WebKit ignores `scrollbar-gutter`, and there the lock publishes the + * leftover width as `--wj-scrollbar-compensation` for a fixed element to opt + * into. This site is the reported case: the Delete-account demo on /ui moved the + * navbar. + * + * The opt-in is one declaration inside the root layout's inline `
`; +const CFGS = [ + ['default', {}], + ['gtk overlay off', { 'widget.gtk.overlay-scrollbars.enabled': false }], + ['non-native theme', { 'widget.non-native-theme.enabled': true, 'widget.gtk.overlay-scrollbars.enabled': false }], + ['ui.useOverlayScrollbars 0', { 'ui.useOverlayScrollbars': 0 }], +]; +for (const [name, prefs] of CFGS) { + const b = await firefox.launch({ firefoxUserPrefs: prefs, ignoreDefaultArgs: ['--hide-scrollbars'] }); + const p = await b.newPage({ viewport: { width: 1000, height: 600 } }); + await p.setContent(PAGE); + const before = await p.evaluate(() => ({ sb: innerWidth - document.documentElement.clientWidth, hdr: document.getElementById('hdr').getBoundingClientRect().width })); + await p.evaluate(() => { document.documentElement.style.scrollbarGutter = 'stable'; }); + const after = await p.evaluate(() => ({ hdr: document.getElementById('hdr').getBoundingClientRect().width })); + console.log(`${name.padEnd(26)} scrollbarProbe=${before.sb} hdr ${before.hdr} -> ${after.hdr} gutterHonoured=${after.hdr !== before.hdr}`); + await b.close(); +} diff --git a/.sync-blog2.py b/.sync-blog2.py new file mode 100644 index 000000000..66a1fc735 --- /dev/null +++ b/.sync-blog2.py @@ -0,0 +1,71 @@ +"""Re-sync examples/blog's copied dialog with the registry source. + +The blog owns its copy (the shadcn model), and it has its own local divergences, +so this ports ONLY the pieces #1144 changed: the lock block, and the per-instance +guard in _setup / _teardown. +""" +import pathlib + +src = pathlib.Path('packages/ui/packages/registry/components/dialog.ts').read_text() +dst_path = pathlib.Path('examples/blog/components/ui/dialog.ts') +dst = dst_path.read_text() + + +def block(text, start, end_marker): + a = text.index(start) + b = text.index(end_marker, a) + return text[a:b] + + +# 1. The lock block, verbatim. +canon_lock = block(src, '// Page scroll lock, refcounted', 'function unlockScroll') +canon_lock += src[src.index('function unlockScroll'):] +canon_lock = canon_lock[: canon_lock.index('\n}\n') + 2] + +old_lock = block(dst, '// Page scroll lock, refcounted', 'function unlockScroll') +old_lock += dst[dst.index('function unlockScroll'):] +old_lock = old_lock[: old_lock.index('\n}\n') + 2] +dst = dst.replace(old_lock, canon_lock) + +# 2. The per-instance guard. +old_setup = """ _setup(): void { + const content = this._content; + if (!content) return; + lockScroll(); + content.showModal(); + }""" +new_setup = """ _setup(): void { + const content = this._content; + if (!content) return; + lockScroll(); + this._scrollLocked = true; + content.showModal(); + }""" +assert dst.count(old_setup) == 1 +dst = dst.replace(old_setup, new_setup) + +old_td = """ _teardown(): void { + unlockScroll(); + this._content?.close(); + }""" +new_td = """ _teardown(): void { + // Release only what THIS element locked. _setup() returns before locking + // when there is no content child, so an unconditional unlock here would + // consume ANOTHER open dialog's count and restore the page out from under + // it, dropping its compensation while it is still open. + if (this._scrollLocked) { + this._scrollLocked = false; + unlockScroll(); + } + this._content?.close(); + }""" +assert dst.count(old_td) == 1 +dst = dst.replace(old_td, new_td) + +if '_scrollLocked?: boolean;' not in dst: + marker = ' _disposeBeforeCache?: () => void;' + assert dst.count(marker) >= 1 + dst = dst.replace(marker, marker + '\n _scrollLocked?: boolean;', 1) + +dst_path.write_text(dst) +print('blog copy re-synced') diff --git a/examples/blog/app/layout.ts b/examples/blog/app/layout.ts index 8f62f04e5..e23446f4c 100644 --- a/examples/blog/app/layout.ts +++ b/examples/blog/app/layout.ts @@ -282,7 +282,7 @@ export default function RootLayout({ children }: LayoutProps) { -