Skip to content

fix(client): scroll inside inline page modals again, not the document - #5148

Merged
evanpelle merged 2 commits into
mainfrom
t3code/fix-modal-scrolling
Aug 27, 2026
Merged

evanpelle merged 2 commits into
mainfrom
t3code/fix-modal-scrolling

Conversation

@evanpelle

Copy link
Copy Markdown
Collaborator

Regression fix for #5009 (7ae09256b). No approved issue — reporting and fixing in one go.

Description:

Since #5009, opening an inline page modal (Store, Settings, Leaderboard, Help, ...) scrolls the whole document instead of scrolling inside the modal.

Cause. Those modals are the .page-content elements in index.html, sized w-full h-full. They used to get a bounded height from the locked chain #5009 removed: body h-full overflow-hidden → content div h-full overflow-hidden → main-layout's overflow-y-auto. Unbounding that chain is correct for the homepage (document-level scrolling, needed for ad viewability), but it also let the page modals grow to their full content height, so o-modal's [data-modal-scroll] container had nothing left to scroll. Measured at main with Help open: document scrollHeight 7353px, modal container clientHeight == scrollHeight == 7034.

Fix. Re-lock the layout only while an inline page is open, keyed off a page-open body class — the same pattern as the existing in-game class:

  • src/client/Navigation.ts — showPage() toggles body.page-open. It's the single place page visibility changes; BaseModal.open()/close() for inline modals route through it.
  • src/client/styles.css — body.page-open { height: 100%; overflow: hidden; flex-direction: column; flex-wrap: nowrap }. The column matters: Make homepage scrollable and shift UI below the header ad #5009 made body a wrapping flex row so the Playwire header-ad slot (#pw-oop-flex_container) gets its own full-width line. A column lets the content area take the height remaining under that slot rather than overflowing by its height.
  • index.html — content area gets in-[.page-open]:min-h-0 in-[.page-open]:overflow-hidden; the ad slot gets flex-basis: auto; flex-shrink: 0 under body.page-open, since its usual flex-basis: 100% would mean 100% height in a column.
  • src/client/components/MainLayout.ts — min-h-0 on main and the inner column so they can shrink to the bounded height. This is what the removed overflow-hidden / overflow-y-auto implicitly gave them; inert on the homepage, whose chain is auto-height.

The homepage keeps scrolling at the document level exactly as #5009 intends — the class is only present while a page modal is open.

Verification

Headless Chromium against the dev server, at 1400×900 and 390×844:

  • Repro at main: Help open → document scrollHeight 7353 / 10470, window.scrollTo(0, 5000) moves the page, modal container doesn't scroll.
  • With this PR: Help open → scrollHeight == innerHeight (900 / 844), window.scrollTo doesn't move, modal container scrolls (7034px of content in a 581px box; 10284 in 658 on mobile), footer pinned at the viewport bottom.
  • Back to Play: class removed, document scrolls again.
  • Header ad interaction: simulated a 250px #pw-oop-flex unit in its slot with a page open → slot occupies 0–250, nav sits at 250, the modal's scroll box shrinks to 331px and still scrolls, footer still at the viewport bottom, no document scroll.
  • 10 navigation-related client test files (72 tests) pass; Prettier + ESLint clean.

Not covered: the real Playwire creative (RAMP won't initialize headless). The simulation above reproduces the geometry #5009 describes, but a headed pass with ads live is worth a glance.

Please complete the following:

  • I have added screenshots for all UI updates — no visual change; the fix is scroll behavior, which the measurements above capture
  • I process any text displayed to the user through translateText() and I've added it to the en.json file — N/A, no user-visible text
  • I have added relevant tests to the test directory — N/A, pure CSS/layout behavior; verified e2e as above

Please put your Discord username so you can be contacted if a bug or regression is found:

evanpelle

🤖 Generated with Claude Code

#5009 unbounded the layout height chain so the homepage scrolls at the
document level (needed for ad viewability). That also unbounded the
inline page modals rendered in the content area (store, settings,
leaderboard, help, ...): they used to get a bounded height from
`body h-full overflow-hidden` -> content div -> `main-layout`, so
o-modal's `[data-modal-scroll]` container did the scrolling. Without it
the modal grew to its full content height and the whole page scrolled.

Re-lock the layout only while an inline page is open, keyed off a
`page-open` body class toggled in showPage() (same pattern as
`in-game`). body becomes a column there because #5009 made it a
wrapping row to give the Playwire header-ad slot its own line — a
column lets the content area take the height remaining under that slot
instead of overflowing by its height. The `min-h-0` additions restore
what the removed overflow rules implicitly gave those flex children;
they are inert on the homepage, whose chain is auto-height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bc97091-2a77-40e9-b37d-d35585b87d1a

📥 Commits

Reviewing files that changed from the base of the PR and between 9f2d4c2 and fe0617c.

📒 Files selected for processing (1)
  • src/client/Navigation.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


Walkthrough

Inline page navigation now applies page-open to the body. CSS rules lock document scrolling, stack body content, allow layout containers to shrink, clip inline content, and size the Playwire ad container to its content.

Changes

Inline page layout

Layer / File(s) Summary
Page state and body layout
src/client/Navigation.ts, src/client/styles.css
showPage toggles page-open after modal cleanup for non-play pages. The body rule locks document scrolling and uses a non-wrapping column layout.
Content and ad sizing
src/client/components/MainLayout.ts, index.html
Main layout containers can shrink. Inline content clips overflow. The Playwire flex ad container sizes to its content when page-open is active.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to fe061

This change restores scrolling inside inline page modals while preserving document-level scrolling on the homepage. No actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: ryanbarlow97

Poem

A page opens, the body aligns,
The scroll stays in its proper lines.
Ads fit close, containers bend,
Overflow meets a tidy end.
The play page clears the state again.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix: restoring scrolling inside inline page modals instead of the document.
Description check ✅ Passed The description directly explains the regression, root cause, implementation, and verification for the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Verdict: The core mechanism is sound, but there's a real re-entrancy bug that undoes the fix on the most common navigation path. Findings: 1 High, 0 Medium, 0 Low.

High

src/client/Navigation.ts:19 — page-open gets cleared again when navigating directly from one inline page modal to another, so the exact regression this PR fixes (document scrolling instead of the modal) still occurs on that path.

The new toggle runs early in showPage():

window.currentPageId = pageId;                                             // Navigation.ts:17
document.body.classList.toggle("page-open", pageId !== "page-play");       // Navigation.ts:19 (new)
...
const visibleModal = document.querySelector(".page-content:not(.hidden)"); // Navigation.ts:24ish
if (visibleModal) {
  if (... visibleModal.isOpen() ...) {
    visibleModal.close();                                                  // closes the modal that was open
  }
  ...
}

For inline modals, close() unconditionally re-enters showPage("page-play"):

// src/client/components/BaseModal.ts:238-242
if (this.inline) {
  this.style.pointerEvents = "none";
  if (window.showPage) {
    window.showPage?.("page-play");
  }
}

Trace for "page A (inline modal) is open, user navigates to page B (a different inline modal)" — e.g. clicking a nav-bar item while Store/Settings/Leaderboard/etc. is open, or ModalRouter.openRegistered() (src/client/ModalRouter.ts:87) routing to a modal while another is open:

  1. Outer showPage("page-B") → page-open toggled on.
  2. showPage finds visible modal A, calls A.close().
  3. BaseModal.close() for inline A → window.showPage("page-play") (nested call).
  4. Nested call re-runs the toggle with pageId === "page-play" → page-open toggled off. It hides A and returns.
  5. Outer call resumes: reveals B, calls B.open(). In BaseModal.open(), the inline branch's needsShow guard (BaseModal.ts:212-213, this.classList.contains("hidden") || this.style.display === "none") is false, because the outer call already removed hidden from B before calling open() — so open() does not call showPage again, and page-open is never restored.

End state: page B is visible, but body.page-open is absent, so body loses height: 100%; overflow: hidden; flex-direction: column and the whole document scrolls again instead of the modal — the bug this PR is meant to fix.

This isn't a rare edge case — it fires whenever a user switches between page modals without returning to the play page first (very common via the nav bar), and whenever ModalRouter routes to a new registered modal while one is already open.

Suggested fix: move the page-open toggle to run after the "close the currently visible modal" block (e.g. right before or after the reveal/open() logic near the end of showPage()), so the nested showPage("page-play") call can't clobber it. Note window.currentPageId (Navigation.ts:17) has the identical pre-existing reentrancy problem and would benefit from the same reordering, though that's outside this PR's stated scope.

showPage() set the page-open body class before closing the previously
visible modal. An inline modal's close() re-enters showPage("page-play"),
which cleared the class again, and the outer call never restored it —
so navigating Store -> Settings (nav bar, ModalRouter) still scrolled the
document instead of the modal. Toggle the class after the close block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Verdict: No issues found — approve. Findings: 0 critical, 0 high, 0 medium, 0 low.

Reviewed src/client/Navigation.ts, src/client/styles.css, index.html, and src/client/components/MainLayout.ts for CLAUDE.md compliance and for bugs/security issues, including specifically tracing the re-entrancy concern raised by CodeRabbit (nested showPage("page-play") from a modal's close() potentially undoing the new page-open class toggle). That concern does not hold up: the toggle in Navigation.ts runs after the modal-closing block that triggers any nested showPage call, so the outer call's toggle always has the final say. No CLAUDE.md violations (no user-visible strings were added, so the i18n rule doesn't apply; no src/core files were touched).

No issues found. Checked for bugs and CLAUDE.md compliance.

@evanpelle
evanpelle merged commit dfc3c25 into main Aug 27, 2026
14 of 15 checks passed
@evanpelle
evanpelle deleted the t3code/fix-modal-scrolling branch August 27, 2026 23:38
@github-project-automation github-project-automation Bot moved this from Triage to Complete in OpenFront Release Management Aug 27, 2026
evanpelle added a commit that referenced this pull request Aug 27, 2026
…#5148)

Regression fix for #5009 (`7ae09256b`). No approved issue — reporting
and fixing in one go.

## Description:

Since #5009, opening an inline page modal (Store, Settings, Leaderboard,
Help, ...) scrolls the **whole document** instead of scrolling inside
the modal.

**Cause.** Those modals are the `.page-content` elements in
`index.html`, sized `w-full h-full`. They used to get a bounded height
from the locked chain #5009 removed: `body h-full overflow-hidden` →
content div `h-full overflow-hidden` → `main-layout`'s
`overflow-y-auto`. Unbounding that chain is correct for the homepage
(document-level scrolling, needed for ad viewability), but it also let
the page modals grow to their full content height, so `o-modal`'s
`[data-modal-scroll]` container had nothing left to scroll. Measured at
`main` with Help open: document `scrollHeight` 7353px, modal container
`clientHeight == scrollHeight == 7034`.

**Fix.** Re-lock the layout *only* while an inline page is open, keyed
off a `page-open` body class — the same pattern as the existing
`in-game` class:

- `src/client/Navigation.ts` — `showPage()` toggles `body.page-open`.
It's the single place page visibility changes;
`BaseModal.open()/close()` for inline modals route through it.
- `src/client/styles.css` — `body.page-open { height: 100%; overflow:
hidden; flex-direction: column; flex-wrap: nowrap }`. The column
matters: #5009 made body a wrapping flex row so the Playwire header-ad
slot (`#pw-oop-flex_container`) gets its own full-width line. A column
lets the content area take the height *remaining* under that slot rather
than overflowing by its height.
- `index.html` — content area gets `in-[.page-open]:min-h-0
in-[.page-open]:overflow-hidden`; the ad slot gets `flex-basis: auto;
flex-shrink: 0` under `body.page-open`, since its usual `flex-basis:
100%` would mean 100% *height* in a column.
- `src/client/components/MainLayout.ts` — `min-h-0` on `main` and the
inner column so they can shrink to the bounded height. This is what the
removed `overflow-hidden` / `overflow-y-auto` implicitly gave them;
inert on the homepage, whose chain is auto-height.

The homepage keeps scrolling at the document level exactly as #5009
intends — the class is only present while a page modal is open.

## Verification

Headless Chromium against the dev server, at 1400×900 and 390×844:

- **Repro at `main`:** Help open → document `scrollHeight` 7353 / 10470,
`window.scrollTo(0, 5000)` moves the page, modal container doesn't
scroll.
- **With this PR:** Help open → `scrollHeight == innerHeight` (900 /
844), `window.scrollTo` doesn't move, modal container scrolls (7034px of
content in a 581px box; 10284 in 658 on mobile), footer pinned at the
viewport bottom.
- **Back to Play:** class removed, document scrolls again.
- **Header ad interaction:** simulated a 250px `#pw-oop-flex` unit in
its slot with a page open → slot occupies 0–250, nav sits at 250, the
modal's scroll box shrinks to 331px and still scrolls, footer still at
the viewport bottom, no document scroll.
- 10 navigation-related client test files (72 tests) pass; Prettier +
ESLint clean.

Not covered: the real Playwire creative (RAMP won't initialize
headless). The simulation above reproduces the geometry #5009 describes,
but a headed pass with ads live is worth a glance.

## Please complete the following:

- [x] I have added screenshots for all UI updates — no visual change;
the fix is scroll behavior, which the measurements above capture
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — N/A, no user-visible text
- [x] I have added relevant tests to the test directory — N/A, pure
CSS/layout behavior; verified e2e as above

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evanpelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
evanpelle added a commit that referenced this pull request Sep 4, 2026
…ad (#5243)

Regression surfaced by #5009 / #5237 (the flex header ad). No approved
issue — reporting and fixing in one go.

## Description:

With the Playwire flex header ad on the page, opening any modal (Help,
Store, Settings, …) rendered it **unstyled and unscrollable**.

**Cause.** `o-modal` is shadow DOM and gets the page's Tailwind by
copying the document's stylesheets into a constructed sheet
(`SharedStyles.ts`). In dev, `Main.ts` imports the components ahead of
`styles.css`, so the first copy runs before Vite has injected the
`<style>` tags (~18 rules), and the module re-read on `window`
**`load`**. The GumGum creative keeps an iframe loading indefinitely, so
`load` never fires — measured `document.readyState === "interactive"`
65s+ after the unit rendered. The re-read never happened, so the modal
had no `overflow-y-auto` / `flex-1` / `min-h-0`, grew to its full
content height inside the clipped page (`[data-modal-scroll]` computed
`overflow-y: visible`, `clientHeight == scrollHeight`), and could not
scroll.

**Fix.** Re-read on `DOMContentLoaded` instead. It fires right after the
deferred module scripts finish executing (verified there is no top-level
`await` in the client graph), and ad iframes cannot hold it up. Prod is
unaffected either way: the `<link rel="stylesheet">` is already in
`<head>` at module-evaluation time, so the initial copy works there and
this path is dev-only insurance — but the `load` dependency was a
footgun regardless.

Not touched: `SharedStyles` also copies whatever `<style>`/`<link>` ad
scripts have injected by the time it runs (`#pwCSSstyling`, the Playwire
config stylesheet). That was already the case on `load`; with
`DOMContentLoaded` it now runs *before* those exist, which is if
anything cleaner.

## Verification

Headed Chromium against `npm run dev` with the real creative (GAM
preview URL + Playwire requesting the `flex` unit), 1400×900:

- **Before:** Help open under the ad → shadow sheet has 18 rules, no
`.overflow-y-auto`; scroll box `overflow-y: visible`, 6463px tall; wheel
does nothing.
- **After:** shadow sheet fully populated; scroll box `overflow-y:
auto`, 259px tall with 6704px of content; wheel moves `scrollTop` 0 →
1500, document stays put. Store page scrolls too.
- **No ad** (headless, desktop 1400×900 + mobile 390×844): unchanged —
Help scrolls inside the modal, document locked, as after #5148.
- Prettier + ESLint clean.

## Please complete the following:

- [x] I have added screenshots for all UI updates — no visual change;
the fix restores the modal's existing styling
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — N/A
- [x] I have added relevant tests to the test directory — N/A, depends
on the live Playwire script / document load timing; verified e2e as
above

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evanpelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Code <noreply@anthropic.com>
evanpelle added a commit that referenced this pull request Sep 4, 2026
…ad (#5243)

Regression surfaced by #5009 / #5237 (the flex header ad). No approved
issue — reporting and fixing in one go.

## Description:

With the Playwire flex header ad on the page, opening any modal (Help,
Store, Settings, …) rendered it **unstyled and unscrollable**.

**Cause.** `o-modal` is shadow DOM and gets the page's Tailwind by
copying the document's stylesheets into a constructed sheet
(`SharedStyles.ts`). In dev, `Main.ts` imports the components ahead of
`styles.css`, so the first copy runs before Vite has injected the
`<style>` tags (~18 rules), and the module re-read on `window`
**`load`**. The GumGum creative keeps an iframe loading indefinitely, so
`load` never fires — measured `document.readyState === "interactive"`
65s+ after the unit rendered. The re-read never happened, so the modal
had no `overflow-y-auto` / `flex-1` / `min-h-0`, grew to its full
content height inside the clipped page (`[data-modal-scroll]` computed
`overflow-y: visible`, `clientHeight == scrollHeight`), and could not
scroll.

**Fix.** Re-read on `DOMContentLoaded` instead. It fires right after the
deferred module scripts finish executing (verified there is no top-level
`await` in the client graph), and ad iframes cannot hold it up. Prod is
unaffected either way: the `<link rel="stylesheet">` is already in
`<head>` at module-evaluation time, so the initial copy works there and
this path is dev-only insurance — but the `load` dependency was a
footgun regardless.

Not touched: `SharedStyles` also copies whatever `<style>`/`<link>` ad
scripts have injected by the time it runs (`#pwCSSstyling`, the Playwire
config stylesheet). That was already the case on `load`; with
`DOMContentLoaded` it now runs *before* those exist, which is if
anything cleaner.

## Verification

Headed Chromium against `npm run dev` with the real creative (GAM
preview URL + Playwire requesting the `flex` unit), 1400×900:

- **Before:** Help open under the ad → shadow sheet has 18 rules, no
`.overflow-y-auto`; scroll box `overflow-y: visible`, 6463px tall; wheel
does nothing.
- **After:** shadow sheet fully populated; scroll box `overflow-y:
auto`, 259px tall with 6704px of content; wheel moves `scrollTop` 0 →
1500, document stays put. Store page scrolls too.
- **No ad** (headless, desktop 1400×900 + mobile 390×844): unchanged —
Help scrolls inside the modal, document locked, as after #5148.
- Prettier + ESLint clean.

## Please complete the following:

- [x] I have added screenshots for all UI updates — no visual change;
the fix restores the modal's existing styling
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — N/A
- [x] I have added relevant tests to the test directory — N/A, depends
on the live Playwire script / document load timing; verified e2e as
above

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evanpelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Code <noreply@anthropic.com>
evanpelle added a commit that referenced this pull request Sep 16, 2026
Regression from the flex header ad (#5009 / #5237). No approved issue —
reporting and fixing in one go.

## Description:

With the flex header ad showing, opening any inline page modal (Store,
Settings, Leaderboard, ...) leaves the modal only a sliver of height, so
it effectively can't be scrolled.

**Cause.** `body.page-open` locks the document at scroll 0 (#5148).
Playwire's flex unit expands (350px desktop / 210px mobile) whenever its
container intersects the viewport and only docks to the 100px/70px fixed
leaderboard once the container scrolls out — which can never happen
while the page is locked. So the expanded ad sat in flow above the modal
for as long as the page was open. On an 800px-tall viewport the modal
got ~220px.

**Fix (CSS only, `index.html`).** While a page is open:
- park `#pw-oop-flex_container` just above the viewport (`position:
fixed; bottom: 100%`) so Playwire's IntersectionObserver docks the unit;
- pin the docked unit to `top: 0` (Playwire docks with `top: auto`, i.e.
the static position, which is now above the viewport);
- pad the content area by `--top-ad-height` (already measured by
HomepagePromos) and make the desktop nav static, since nothing scrolls
in that state.

When the page closes the container returns to flow and the unit
re-expands as before.

Verified in headed Chromium with the Playwire preview URL on desktop
(1400×800) and mobile (390×844): the unit docks within Playwire's
collapse delay, the modal scroller reaches its full extent, closing
restores the expanded unit and document scrolling, and opening a page
from a scrolled position keeps the docked unit in place.

Before: the expanded ad sat in flow above the Settings modal, which was
~220px tall. After: the ad is docked at the top and the modal fills the
area below the nav.

## Please complete the following:

- [ ] I have added screenshots for all UI updates (layout verified
in-browser; described above)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new text)
- [x] I have added relevant tests to the test directory (CSS-only layout
change; verified in a real browser against Playwire's live script)

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evanpelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
staging — fe0617cf Deployed Aug 27, 2026 by openfrontio[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant