diff --git a/e2e/right-pane-scroll.spec.ts b/e2e/right-pane-scroll.spec.ts
new file mode 100644
index 0000000..75b4015
--- /dev/null
+++ b/e2e/right-pane-scroll.spec.ts
@@ -0,0 +1,59 @@
+import { test, expect, type Page } from '@playwright/test'
+
+async function createSemester(page: Page) {
+ await page.goto('/')
+ await page.getByRole('button', { name: 'Create your first semester' }).click()
+ await page.getByRole('button', { name: 'Create Semester' }).click()
+ await expect(page.getByRole('heading', { name: 'Weekly Schedule' })).toBeVisible()
+}
+
+const viewSwitch = (page: Page) => page.getByRole('group', { name: 'Right panel view' })
+// The right pane's scroll field: the homework list's parent (see RightPane).
+const scrollField = (page: Page) => page.locator('#homework-list').locator('xpath=..')
+
+/** The Schedule|Exams switch is pinned; only the content under it scrolls, with no visible bar. */
+test.describe('right pane scrolling', () => {
+ for (const width of [1440, 1100]) {
+ test(`desktop @${width}px: the view switch stays put while the pane scrolls`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 800 })
+ await createSemester(page)
+
+ const field = scrollField(page)
+ const geom = await field.evaluate((el: HTMLElement) => ({
+ overflows: el.scrollHeight > el.clientHeight,
+ scrollbarWidth: el.offsetWidth - el.clientWidth,
+ overflowY: getComputedStyle(el).overflowY,
+ }))
+ expect(geom.overflows).toBe(true)
+ expect(geom.overflowY).toBe('auto')
+ expect(geom.scrollbarWidth).toBe(0)
+
+ const before = await viewSwitch(page).boundingBox()
+ await field.evaluate((el: HTMLElement) => el.scrollBy(0, 400))
+ expect(await field.evaluate((el: HTMLElement) => el.scrollTop)).toBeGreaterThan(0)
+
+ const after = await viewSwitch(page).boundingBox()
+ expect(after!.y).toBeCloseTo(before!.y, 0)
+
+ // The window itself never scrolls on desktop — each pane owns its scrolling.
+ const pageOverflow = await page.evaluate(
+ () => document.documentElement.scrollHeight - document.documentElement.clientHeight,
+ )
+ expect(pageOverflow).toBeLessThanOrEqual(0)
+ })
+ }
+
+ test('mobile: the view switch sticks to the top of the viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await createSemester(page)
+
+ await page.getByRole('heading', { name: 'Weekly Schedule' }).scrollIntoViewIfNeeded()
+ await page.mouse.wheel(0, 1200)
+ await expect
+ .poll(async () => (await viewSwitch(page).boundingBox())!.y, { timeout: 3000 })
+ .toBeLessThan(40)
+ await expect(viewSwitch(page)).toBeInViewport()
+ })
+})
diff --git a/src/App.tsx b/src/App.tsx
index 7ae43c9..15c6ace 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -95,24 +95,30 @@ function RightPane() {
const view = resolveExamViewMode(semester, now)
return (
-
-
+ // Mirrors LeftPane: on desktop the pane fills its height and only the content
+ // below the view switch scrolls. On mobile (stacked, one page-long scroll) the
+ // switch sticks to the top of the viewport while this column is in view, so it
+ // is reachable from anywhere in the schedule/exams content either way.
+
+
- {view === 'exam' ? (
-
- ) : (
- <>
-
-
-
- >
- )}
+
+ {view === 'exam' ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
)
}
diff --git a/src/features/layout/AppShell.tsx b/src/features/layout/AppShell.tsx
index b340020..6e409df 100644
--- a/src/features/layout/AppShell.tsx
+++ b/src/features/layout/AppShell.tsx
@@ -17,7 +17,9 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'
* appears, persisted via autoSaveId ('tollab-split-v3').
* - ≤ 1023px: the panes stack into one scrolling column.
*
- * Both panes cap their content at max-w-4xl and scroll independently.
+ * Both panes cap their content at max-w-4xl and fill the viewport height; each
+ * owns its scrolling internally (see LeftPane/RightPane), so their headers stay
+ * pinned while only the content below them scrolls.
*/
export function AppShell({ left, right }: { left: ReactNode; right: ReactNode }) {
const stacked = useMediaQuery('(max-width: 1023px)')
@@ -36,8 +38,12 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
// the window edge instead of running flush to it. Shared by both desktop
// branches so the fixed and draggable splits are pixel-identical apart from the
// handle.
+ //
+ // The right pane gets no bottom padding here: it is a full-height column whose
+ // own scroll field (inside RightPane) carries the bottom breathing room, so the
+ // padding scrolls with the content rather than clipping the pinned view switch.
const leftInner =
{left}
- const rightInner =
{right}
+ const rightInner =
{right}
if (roomy) {
return (
@@ -46,7 +52,7 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
style={{ gridTemplateColumns: 'minmax(0, 55fr) minmax(0, 45fr)' }}
>
{leftInner}
-
{rightInner}
+
{rightInner}
)
}
@@ -65,7 +71,7 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
-
+
{rightInner}
diff --git a/src/styles/theme.css b/src/styles/theme.css
index 222d28b..eb2e714 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -139,6 +139,16 @@
text-transform: uppercase;
}
+/* A scroll field with no scrollbar chrome. Wheel, touch, and keyboard scrolling
+ all still work; only the bar itself is hidden, so a pane can scroll without a
+ rule down its edge. */
+@utility scrollbar-hidden {
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
@layer base {
body {
@apply bg-surface text-ink antialiased;
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 3396985..6569e83 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -2,7 +2,10 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
- "lib": ["ES2023"],
+ // DOM is here for the e2e specs only: Playwright's page/locator.evaluate
+ // callbacks are typed against browser globals even though the test file runs
+ // in Node.
+ "lib": ["ES2023", "DOM"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,