fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(ui): pin full-height shell screens so only the body scrolls (#331) - #335

Merged
AndresL230 merged 4 commits into
mainfrom
scrolling
Jul 15, 2026
Merged

fix(ui): pin full-height shell screens so only the body scrolls (#331)#335
AndresL230 merged 4 commits into
mainfrom
scrolling

Conversation

@Darkest-Teddy

Copy link
Copy Markdown
Collaborator

Fix: pin full-height shell screens so only the body scrolls

Closes#331

The bug, in code

ShellFrame (top-nav layout) — <main> is 100vh − navHeight:

<divstyle={{display: "flex",flexDirection: "column",height: "100vh",overflow: "hidden"}}><TopNav/>{/* 56px */}<mainstyle={{flex: 1,overflowY: "auto"}}>{children}</main>{/* 100vh − 56px */}</div>

But the screens forced the full viewport inside that shorter <main>:

// Learn.tsx, Quiz.tsx, Library.tsx, Social.tsx, notetaker, course-planner<divstyle={{display: "flex",height: "100vh", ... }}> // ← taller than <main> by 56px

100vh > (100vh − 56px)<main> overflows → the whole page scrolls, dragging the chat header/input out of view. The sidebar layout was fine because SideNav sits beside<main> (row), so <main> gets the full 100vh and 100vh matched exactly.

The fix

Fill the parent, not the viewport — 100vh100%, wrapped in one shared component:

// components/FullHeightScreen.tsx (new)exportfunctionFullHeightScreen({ children, direction ="column", className, style }: {children: React.ReactNode;direction?: "row"|"column";className?: string;style?: React.CSSProperties;}){return(<divclassName={className}style={{display: "flex",flexDirection: direction,height: "100%",minHeight: 0, ...style}}>{children}</div>);}

height: 100% resolves against <main> (which has a definite height in both layouts), so top-nav is fixed and sidebar is unchanged.

The diffs

// Learn.tsx (entry + active session), Quiz.tsx, course-planner/page.tsx
- <div style={{ display: "flex", height: "100vh", flexDirection: "column" }}>+ <FullHeightScreen>
// Library.tsx, Social.tsx (row layouts)
- <div style={{ display: "flex", height: "100vh" }}>+ <FullHeightScreen direction="row">
// notetaker/page.tsx (root + loading/empty states)
- height: "100vh",+ height: "100%",
// Study.tsx (stays scrollable, now bounded to <main>)
- <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>

Standalone pages (about, terms, privacy, careers, auth, pending, Onboarding) keep 100vh — they own the full viewport, correctly.

Files changed

 frontend/src/components/FullHeightScreen.tsx | 47 ++++++++++++++++++ (new)
frontend/src/components/screens/Learn.tsx | 9 +++--
frontend/src/components/screens/Quiz.tsx | 5 ++-
frontend/src/components/screens/Library.tsx | 5 ++-
frontend/src/components/screens/Social.tsx | 5 ++-
frontend/src/components/screens/Study.tsx | 2 +-
frontend/src/app/(shell)/course-planner/page.tsx | 5 ++-
frontend/src/app/(shell)/notetaker/page.tsx | 6 +--
8 files changed, 68 insertions(+), 16 deletions(-)

#331 checklist

  • ✅ Top nav fixed; only the body scrolls
  • height: 100vh100%
  • ✅ Shared full-height wrapper (<FullHeightScreen>) applied across affected screens
  • ✅ Sidebar layout unaffected (100% resolves to the same 100vh there)

Testing

npx tsc --noEmit # passes for changed files (pre-existing react-force-graph-3d error in KnowledgeGraph3D.tsx is unrelated)
npx eslint <changed files> # 0 errors (2 pre-existing warnings)

Manual check still recommended: /learn, /quiz, /library, /social, /notetaker in top-nav layout (header/input pinned, body scrolls) + sidebar layout unchanged.

In the horizontal top-nav layout, ShellFrame stacks the 56px TopNav above
<main> in a 100vh flex column, so <main> is only `100vh - 56px` tall.
Screens that hardcoded `height: 100vh` were therefore taller than their
container, overflowing <main> and scrolling the whole page — dragging chat
headers/inputs out of view instead of scrolling just the message body.
The sidebar layout was unaffected because SideNav sits beside <main>, giving
it the full 100vh.
Fix: fill the parent (`height: 100%`) instead of the viewport. Introduce a
shared <FullHeightScreen> wrapper and adopt it across the full-height shell
screens so the pattern is consistent and can't regress:
- new: components/FullHeightScreen.tsx (height:100%, min-height:0)
- Learn, Quiz, Library, Social, course-planner: use FullHeightScreen
- notetaker: height 100vh -> 100% (root + loading/empty states)
- Study: min-height 100vh -> 100% (stays scrollable, now bounded to <main>)
Percentage height resolves in both layouts because <main> has a definite
height in each, so the sidebar layout remains correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging40fdc91Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:48 AM

@coderabbitai

coderabbitaiBot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd5e815c-3aa5-4b01-a5b4-258304dea9cd

📥 Commits

Reviewing files that changed from the base of the PR and between 502e324 and 40fdc91.

📒 Files selected for processing (10)
  • frontend/src/app/(shell)/course-planner/page.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/components/FullHeightScreen.tsx
  • frontend/src/components/ShellFrame.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Library.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch scrolling

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.

…2px)
Settings was the screen #331 missed. Its content row hardcoded
`height: calc(100vh - 112px)` — the TopBar height subtracted from the
viewport, which only held under the sidebar layout where `<main>` is the
full viewport. Under the top-nav layout `<main>` is `100vh - 56px`, so
the row overflowed it by exactly the 56px TopNav: /settings got a second
scrollbar and pushed its bottom 56px below the fold, including the
account-deletion controls on the `data` tab.
Root the screen in FullHeightScreen and let the content row flex into
whatever `<main>` leaves below the TopBar, matching how the other five
screens were fixed. This also drops the 112px magic number, so the row
no longer silently breaks if the TopBar is ever resized.
PreviewModal is `position: fixed`, so it stays out of flow and is
unaffected by the root becoming a flex column.
On iOS Safari `100vh` resolves to the *large* viewport — the height with
the toolbar collapsed — so a `100vh` shell always extends past the visual
viewport while the toolbar is expanded, hiding its own bottom edge. Until
now the full-height screens overflowed `<main>` by 56px, and that
incidental scroll slack let you drag the clipped edge back into view.
#331 removed the slack: screens fit `<main>` exactly, so anything under
the toolbar is now unreachable — most visibly the /learn composer.
`100dvh` tracks the visual viewport, so the shell ends where the toolbar
begins. The usual dvh objection (the value changes as the toolbar
collapses, resizing the layout mid-scroll) does not apply here: the shell
root is `overflow: hidden` and scrolling happens in inner panes, so the
document never scrolls and the toolbar never collapses.
No `100vh` fallback: expressing one needs two declarations of the same
property, which a React inline style object cannot hold, so it would mean
moving the shell root to a CSS class. It would buy nothing — dvh has been
Baseline since 2022 (Safari 15.4 / Chrome 108 / Firefox 101), and
globals.css already leans unguarded on `color-mix()`, `:has()`, and
`overflow-x: clip`, all of which are equal or narrower support. Any
browser needing the fallback is already broken by the stylesheet.
`min-height: 0` only does anything on a flex item, where it overrides the
`auto` automatic-minimum-size that would otherwise refuse to shrink below
the content. FullHeightScreen's documented parent is ShellFrame's
`<main>`, a block container, so the root is a block-level box and
`min-height: auto` already computes to 0 — the declaration is a no-op at
every one of its six call sites.
Remove it rather than comment it. Inert CSS on a shared primitive reads
as load-bearing and gets copied into places where the author has not
checked whether it matters. Callers that genuinely need it can pass it
via `style`, and screens that need it on their own inner rows still set
it there (see Settings).
@AndresL230

Copy link
Copy Markdown
Collaborator

@Darkest-Teddy heads up — I pushed 3 commits to this branch as part of a review sweep, and retargeted the base. Nothing of yours was rewritten (fast-forward only). Happy to back any of it out if you disagree.

Base retargeted: Gradebookmain

This one was load-bearing. Gradebook was already merged into main via #241 on 2026-06-28 — the branch is a stale post-merge leftover sitting at 067b46e. As targeted, this PR would have merged into Gradebook and shipped nothing to users, while closing successfully and looking fine.

It is not stacked and there is no merge-order constraint: git diff origin/Gradebook...origin/scrolling and git diff origin/main...origin/scrolling are byte-identical, so retargeting is a no-op on the diff.

3fa6cf5Settings.tsx had the same bug (the main one)

Settings.tsx:232 had height: calc(100vh - 112px), where 112px is the TopBar height. Root had no height, so TopBar (112) + calc(100vh - 112) = exactly 100vh — correct in the sidebar layout, but 56px too tall inside the top-nav layout's 100vh - 56px<main>. Result: two nested scrollbars on /settings and the bottom 56px below the fold, including the delete-account controls on the data tab. Mobile always takes the top-nav path.

Fixed it the way this PR fixes the others — FullHeightScreen + flex: 1, minHeight: 0 — which also deletes the 112px magic number so the row can't silently break if TopBar is ever resized. (minHeight: 0 is genuinely needed here, unlike in FullHeightScreen — see below — because the row is now a flex item with overflow: visible, so its automatic minimum size would otherwise refuse to shrink.)

1113035100vh100dvh in ShellFrame

There was zero dvh usage in frontend/src. On iOS Safari 100vh is the large viewport (toolbar collapsed), so with the toolbar expanded the shell runs past the visual viewport and hides its own bottom edge — the /learn composer most visibly, which is the exact element #331 is about.

This became load-bearing because of this PR: previously the 56px overflow gave incidental scroll slack to drag that edge into view; now screens fill <main> exactly with zero slack.

No 100vh fallback, deliberately: a fallback needs two declarations of the same property, which a React inline style object can't express (it would mean moving the shell root into a CSS class), and it would buy nothing — globals.css already leans unguarded on color-mix() and :has() (14 hits) plus overflow-x: clip, all equal or narrower support than dvh (Baseline since 2022). Any browser needing the fallback is already broken by the stylesheet.

Worth noting dvh's usual objection (value changes mid-scroll → resize thrash) doesn't apply here: the shell root is overflow: hidden and scrolling lives in inner panes, so the document never scrolls and the toolbar never collapses.

40fdc91 — dropped inert minHeight: 0 from FullHeightScreen

Its documented parent is <main>, which has no display property → block container. So the root is a block-level box where min-height: auto already computes to 0. Verified all six call sites are direct children of <main> (App Router adds no DOM wrappers). Removed rather than commented, since inert CSS on a shared primitive reads as load-bearing and gets cargo-culted; callers can still pass it via style.

One correction to my own review

I claimed globals.css's overscroll-behavior-y: none / min-height: 100vh were on body and would blunt the dvh fix. Wrong — they're scoped to .landing-page (opens at :740), the pre-auth marketing page, and never touch the shell. body only sets margin/padding/font (:160-161). So the fix is fully effective, not half.

On testing — read this before trusting the green check

No test in this repo can verify any of this. jsdom has no layout engine; getBoundingClientRect returns 0. There are zero toHaveStyle/getComputedStyle assertions in frontend/src. tsc + eslint + 68 vitest tests pass, but that only proves I didn't break component contracts — it is not evidence the pinning works. The correctness argument rests on <main> having a definite height in both layouts (sidebar: stretched in a row container; top-nav: flex: 1 in a column container), which is what makes height: 100% resolve — the same premise the five already-merged screens rely on. Worth a real device check on /learn and /settings.

Left alone

Tree.tsx:311 and the Gradebook --row-h screens (Landing.tsx:218, Course.tsx:555) — pre-existing, tracked in #341, assigned to you. Notably --row-h is a density token (40/34/48px), not a nav height, so those offsets are semantically wrong and density-dependent.

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The original diff was sound — the 56px TopNav arithmetic checks out and all five converted screens' scroll chains terminate correctly in a flex: 1 + overflowY: auto box, so nothing escapes into <main> and no double-scroll is introduced. The three blockers (dead base branch, missed Settings.tsx, 100vh on iOS) are addressed in the commits above and CI is green on 40fdc91.

Reiterating the caveat from my comment, because the green check overstates what was verified: no test in this repo can observe a layout regression — jsdom has no layout engine. The suite passing proves component contracts are intact, not that the pinning works. A real-device pass on /learn and /settings is worth doing.

@AndresL230
AndresL230 merged commit d00bc54 into mainJul 15, 2026
6 checks passed
AndresL230 added a commit that referenced this pull request Jul 15, 2026
The comment landed before #335 switched the shell root from 100vh to 100dvh.
AndresL230 added a commit that referenced this pull request Jul 15, 2026
* fix(ui): lock body scroll while the shared Dialog is open (#109)
Dialog never called the existing useBodyScrollLock hook, so opening any modal
built on it let the mobile background scroll behind the overlay. Wire
useBodyScrollLock(open) into Dialog — the same pattern the 5 other portal
modals (DisclaimerModal, SessionFeedbackFlow, FeedbackFlow, ReportIssueFlow,
Library) already use.
Part of #109's AC3. The larger #109 work (making Admin/Gradebook/Settings
responsive + migrating the ~8 hand-rolled Gradebook modals onto Dialog) is
separate and not in this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refcount the scroll lock and aim it at the real scrollport
useBodyScrollLock had two defects that made it unfit for the shared
Dialog (#109).
1. It clobbered overlapping locks. Naive save/restore means the second
overlay captures the first one's "hidden" as its "previous" value.
FeedbackFlow is mounted globally by ShellFrame and auto-opens on a
timer, so it routinely overlaps another modal: the first unlock frees
the scroll while the second overlay is still up, and the second unlock
restores the stale "hidden" -- freezing the page until navigation.
Locks are now refcounted, so only 0->1 captures the original value and
only 1->0 restores it.
2. It locked the wrong element. Inside the app shell <body> never
scrolls: ShellFrame roots both layouts at height:100vh/overflow:hidden
and gives <main> flex:1/overflow-y:auto. Setting overflow on <body>
there acts on an element with no scrollport, so the lock did nothing
for Dialog's only consumer (FlashcardImportModal in Study). The target
is now resolved at lock time: the element ShellFrame tags with
data-scroll-container, falling back to <body> for the pre-auth pages
where <body> genuinely is the scroller.
Refcounts are keyed per element (Map<HTMLElement, ...>) so a lock on
<main> and a lock on <body> can't hold each other hostage.
Only the overflow-x/overflow-y longhands are touched, never the overflow
shorthand: ShellFrame sets overflow-y:auto inline via React, and writing
the shorthand then restoring it to its previous value ("") would remove
that inline declaration outright and leave <main> unable to scroll at all
once a modal closed.
Renamed to useScrollLock -- the hook no longer necessarily targets <body>,
and a name that says otherwise would mislead the next reader.
* fix(ui): route the hand-rolled body scroll locks through useScrollLock
Nine sites across eight components set document.body.style.overflow by
hand, and they disagreed with each other. Six of them
(SignInModal, DocumentUploadModal, ManageCoursesModal, Dashboard, Tree,
and both landing-page locks) reset to "" unconditionally on cleanup,
which breaks stacking exactly the way the old hook did -- close one
overlay and any other overlay's lock is silently dropped. The other
three saved and restored the previous value, which stacks no better
because the value they save may itself be another lock's "hidden".
All of them now share the refcounted hook, so overlapping overlays
compose instead of fighting.
Targeting follows from the hook: the in-shell overlays resolve to
<main>, which is what actually scrolls there, while the two pre-auth
locks in (public)/page.tsx keep locking <body> -- there is no shell on
the landing page, so the fallback is correct and the behavior is
unchanged for them.
The scroll-lock concern is lifted out of the effects that were also
managing Escape listeners and reset state; those effects keep only the
work that isn't scroll locking.
* test(ui): cover useScrollLock refcounting, targeting, and style round-trip
Eleven cases over the bookkeeping that was actually broken: overlapping
locks in both release orders, three-deep nesting, a false->true->false
transition while another lock is held, unmount-while-active, per-element
isolation between <main> and <body>, shell vs pre-auth targeting, and
exact restoration of ShellFrame's inline overflow-y:auto.
Checked against the old implementation: 8 of the 11 fail on it, so these
pin the reported bug rather than just the new code's shape.
These tests cannot show the scroll-bleed itself is fixed. jsdom has no
layout engine -- nothing here scrolls, has a scrollport, or paints, so
only the style bookkeeping is observable. Confirming the background no
longer scrolls behind a modal needs a real browser.
* docs(ui): correct the scrollport comment after the 100dvh change
The comment landed before #335 switched the shell root from 100vh to 100dvh.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Co-authored-by: AndresL230 <andreslopez.23061@gmail.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
…#487)
* fix(ui): replace viewport-math residuals with FullHeightScreen (#341)
Three screens still sized themselves by subtracting a magic constant from
100vh. That subtraction can only be correct for ONE of ShellFrame's two
layouts — the sidebar variant gives `<main>` the full 100dvh, the TopNav
variant gives it 100dvh - 56px — and it is blind to the density preference
that retunes the padding tokens above it.
Tree.tsx is the one with a user-visible consequence, not just a stray
scrollbar: the row sized `calc(100vh - 240px)` wraps the element a
ResizeObserver watches, and that contentRect is passed straight through as
`<KnowledgeGraph width height>`. A mis-measured row therefore renders the
graph CANVAS at the wrong size. It now takes `flex: 1` + `minHeight: 0`
inside a FullHeightScreen root, so it absorbs exactly what `<main>` has
left after the TopBar and filter row, in either layout and at any density.
Gradebook Landing/Course used `calc(100vh - var(--row-h))` — subtracting a
DENSITY token (40/34/48px) as though it were a nav height. Both now sit in
a FullHeightScreen with `flex: 1 0 auto` on their `<main>`: fill the
remaining space, keep growing when the content is taller.
Adds frontend/e2e/tree-viewport.spec.ts, which asserts the invariant that
was silently violated — on /tree the graph canvas fits inside the shell
scrollport — in BOTH layouts, driven off the localStorage layout pref. It
lives in its own spec because graph.spec.ts declares itself data-only and
reads no geometry by design.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert fit in both directions, and cover the gradebook (#341)
Review follow-ups on the journey added with this PR.
Two-sided fit: the spec only guarded overshoot, so a future regression that
broke `flex: 1` and left the canvas a few pixels tall would have passed —
`canvasHeight > 0` is not a floor. It now asserts the canvas settles FLUSH
with the scrollport's bottom edge, which is the actual stated invariant, and
polls for it because the canvas size is ResizeObserver-driven and starts at
Tree.tsx's placeholder 900x600.
Gradebook coverage: Landing/Course got the same class of change as Tree with
no geometry assertion behind it — the gap that let the identical
`calc(100vh - N)` bug survive review on Settings.tsx during #335, since jsdom
has no layout engine and cannot catch it. Adds a second test asserting the
seeded gradebook does not manufacture scroll it does not need, in both
layouts.
Renames tree-viewport.spec.ts -> viewport-fit.spec.ts now that it covers two
screens.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert the gradebook adds no phantom height, not that it never scrolls (#341)
The first version of this test asserted the gradebook page must not scroll
at all. That is wrong: under the topnav layout the scrollport is 56px
shorter, and the seeded gradebook's content genuinely needs more than it —
scrolling there is the correct answer, and the assertion failed the FIXED
build for a legitimate reason (16px at topnav, 0 at sidebar).
What the fix actually guarantees is narrower: the container contributes no
height of its own beyond the space it was given or the height its content
needs. That is now what the test measures — <main> must be no taller than
max(available, content), and no shorter than the space available (which
would mean flex-grow is broken).
The unfixed build overshot by exactly 40px at the default density, which is
`--row-h` to the pixel — the density-token-as-nav-height bug, measured.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): measure gradebook content from its children, not scrollHeight (#341)
The previous revision of this assertion passed on the UNFIXED build, which
makes it worthless as a regression test. Caught by running the spec against
the base sha before trusting it green.
Cause: it derived the content height from `main.scrollHeight`, which is
floored at the element's own client height. Any phantom height the box gave
itself was therefore mirrored into the "content" it was being compared
against, so `mainHeight <= max(available, content)` held by construction and
could never fail.
It now measures the content extent from the in-flow children (skipping
AmbientOrbs, which is position:fixed) plus the bottom padding — a number the
box's own sizing cannot influence.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): close three review findings in the viewport-fit spec (#341)
Follow-ups from the review of the spec delta.
Gate on the LOADED gradebook grid, not the chrome. `gradebook-transcript-open`
renders as soon as there is a user, while `loading` is still swapping a
six-card skeleton in for the real grid — measuring that transient made the
phantom-height assertion depend on fetch timing. Now waits on
role="grid"/"Courses", which only exists once the fetch resolved.
Count trailing child margins in the content measurement. A margin-bottom on
a direct in-flow child raises <main>'s height without appearing in any
child's bounding rect, which would have understated the content and turned
a real regression into a pass. Latent today; cheap to close.
Assert on the reading that settled. The tree test polled for convergence and
then measured AGAIN, reopening the window the poll existed to close. A small
settledFit() helper now returns the very reading that satisfied the
condition, and every assertion runs on that one. It also returns the last
reading on timeout, so a failure reports real numbers instead of a bare
"timed out".
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): rename useLayout so eslint stops reading it as a React Hook (#341)
CI's react-hooks/rules-of-hooks rejected the helper: anything named use* is
treated as a Hook, and the spec calls it inside a for-loop over the two shell
layouts. Renamed to switchLayout — behaviour identical.
Local `npm run lint` reported 0 errors on the same code; the installed
eslint-plugin-react-hooks is older than CI's, so this was only visible there.
part of #341
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the scrolling branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat/Tutor: top nav bar scrolls out of view in horizontal-nav mode

2 participants

@Darkest-Teddy@AndresL230