# Two-column layout: replace calc()-based heights with a viewport-locked flex shell ## Background musehub#84 fixed the original bug (sticky sidebars clipped and couldn't scroll) by moving from `position: sticky` to a bounded-height pattern: each column got `max-height: calc(100dvh - var(--sticky-offset, var(--header-height)) - ...)` plus `overflow-y: auto`. That pattern has now broken twice, both for the same underlying reason — `--sticky-offset` is a **static CSS guess**: ```scss body:has(.repo-tabs) { --sticky-offset: calc(var(--header-height) + var(--repo-tabs-height)); } ``` It only accounts for the navbar + repo-tabs bar. Any page with additional chrome between the header and the two-column grid (a toolbar, filter bar, hero section, stat strip) has a wrong `--sticky-offset`, so every column's computed `max-height` is off by however tall that extra chrome is: 1. **Footer-overlap regression** (fixed this cycle) — the calc() put on the *grid wrapper itself* (an earlier iteration of this pattern) claimed a full viewport's height measured from wherever the grid happened to sit, pushing the footer down to overlap still-visible content on pages with a hero/eyebrow above the grid. 2. **Unreachable pagination regression** (this ticket) — on the issues list page, `.isl-main`'s `max-height` doesn't account for the issues-list toolbar above `.isl-layout`, so the column's box extends past the visible viewport with no way to scroll to the pagination controls at the bottom. Patching the formula a third time (adding yet another term to `--sticky-offset`, or a per-page override) just sets up the next page that adds chrome above its grid to hit the same bug again. The pattern itself — hand-deriving "how much vertical space is left" per page in CSS — is the defect, not any single formula. ## Goal Replace the calc()-based height budget with a **viewport-locked flex shell**, the same pattern Gmail/Slack/VS Code/Notion use for independently-scrollable sibling panes: the shell claims `height: 100dvh` once, every fixed-height sibling above the scrollable region is `flex-shrink: 0`, and the scrollable region itself is `flex: 1; min-height: 0`. The browser computes "whatever's left" automatically — no calc(), no magic numbers, no per-page offset to get wrong, and this bug class becomes structurally impossible rather than something to keep re-patching. Confirmed with gabriel: Gmail's own layout (header fixed, no footer while inside the app view, two panes each scroll independently within a viewport-locked shell) is the reference UX to match. ## Design - `base.html` gets an opt-in body class, `body-app-shell`, set only by templates that use a two-column layout. Pages without one (home, plain forms, docs/marketing pages) are untouched — normal document scroll, footer visible. - `body.body-app-shell`: `height: 100dvh; overflow: hidden; display: flex; flex-direction: column;`. Navbar and repo-tabs: `flex-shrink: 0`. `#content`: `flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column;`. - The footer is hidden on `body-app-shell` pages — matches Gmail; a marketing-site footer doesn't belong on a dense two-column app screen. Pages that keep normal scroll keep the footer exactly as today. - Every wrapper between `#content` and the actual two-column grid (per-page toolbars, etc.) becomes `flex: 1; min-height: 0; display: flex; flex-direction: column;` so the "fill remaining space" chain isn't broken partway down. - Each column (`*-main`, `*-sidebar`) keeps `min-height: 0; overflow-y: auto;` — no `max-height`, no `calc()`. - `--sticky-offset` / `--repo-tabs-height` are deleted once nothing references them. ## Affected pages (11) - [ ] Repo home (`_repo-home.scss` / `repo_home.html`) - [ ] Issues list (`_issues.scss` isl-* / `issue_list.html`) — the page that's actually broken right now; verify this one first. - [ ] Issue detail (`_issues.scss` isd-* / `issue_detail.html`) - [ ] Proposal detail (`_proposal-detail.scss` / `proposal_detail.html`) - [ ] Explore/browse (`_explore.scss` / `explore.html`) - [ ] Docs (`_docs.scss` / `docs.html`) - [ ] Muse dev docs (`_muse-docs.scss` / `muse_docs.html` or equivalent) - [ ] Blame (`_blame.scss` / `blame.html`) - [ ] Blob/outline panel (`_blob.scss` — different pattern, verify it still works once the shell change lands, may not need edits) - [ ] Profile graph sidebar (`_profile.scss` `.graph-sidebar` / `profile.html`) - [ ] `_layout.scss`'s shared `.layout-two-col`/`.layout-sidebar` utility classes (whichever pages actually use them, if any still do) Each page verified individually (screenshot or live check) before moving to the next, then merged together once all are converted and the sticky-offset machinery is fully removed. ## Coordination note aaronrene picked up musehub#129 (rich social preview cards / OG images) at the same time this work started. That ticket touches `musehub/api/routes/musehub/_ui_helpers.py`, `ui_repo.py` (backend route, OG tag wiring), and a new `/opengraph-image` endpoint — no overlap expected with this ticket's scope (base.html, SCSS, page templates' layout markup). Flagging here so neither of us has to guess. ## Findings while converting the issues list page (first page done) Two real pitfalls found via headless-browser verification (Playwright, not just reading CSS) — both are the actual explanations for scroll failures hit during this work, not the zoom theory originally suspected: 1. **`align-items: start` on the grid.** Carried over from the old max-height-per-column pattern, where each column set its own height independently and `start` kept the shorter column visually anchored to the top. In the flex-fill model the grid row's height comes from the grid container's own (flex-derived) height — `align-items: start` makes grid *items* size to their own content instead of stretching to fill that row, so a column's `overflow-y: auto` never gets a bounded box to clip against. Must be `align-items: stretch` (or omitted — that's the default) on any grid converted to this pattern. 2. **A `.card` (or similar) child with its own `overflow: hidden`, inside a `flex-direction: column` scroll container.** Flex items default to `flex-shrink: 1`. When content exceeds the scroll container's bounded height, the browser shrinks the child to fit *instead of* letting it overflow — and if that child has its own `overflow: hidden` (often there just to clip rounded corners, as in `issue_list.html`'s `
`), the excess content is silently eaten with no scrollbar anywhere. The parent's own `overflow-y: auto` never triggers because nothing actually overflows *its* box. Fix: `flex-shrink: 0` on that child. Neither of these is specific to zoom or `--sticky-offset` — they're generic flex/grid gotchas that would bite any page using this pattern. Checking for both on every remaining page before considering it done. Verified via headless Chromium (Playwright) driving the actual rendered page and reading `scrollHeight`/`clientHeight`/`scrollTop`, not just reasoning about the CSS — recommended for verifying the remaining pages too, since two prior "should work" theories (the `--sticky-offset` gap, then the zoom/dvh interaction) both turned out to be wrong or incomplete without it. - [x] Issues list (`_issues.scss` isl-* / `issue_list.html`) — verified: independent scroll on both columns at 700px viewport, mobile breakpoint (700px width) stacks to single-column normal scroll with footer visible, desktop footer hidden and not overlapping.