Skip to content

Commit cdb1866

Browse files
fix(dashboard): stop the shell scrolling sideways between 768px and ~890px
Every `/dashboard/*` page could only be read by panning horizontally at viewports between 768px and roughly 890px. Measured on the student dashboard, the document was 87px wider than the viewport at 768px, 55px at 800px and 5px at 850px. Two things collided, and both needed fixing. `SidebarInset` was `w-full flex-1` with no `min-w-0`. As a flex item its min-width resolved to `auto`, so it refused to shrink below its min-content width and widened the document instead of letting the offending content clip or scroll inside itself. That is a latent trap for any wide child — tables, code blocks, charts — not only for the case found here. The content that tripped it was the gamification header card, gated on `hidden md:block`. The desktop sidebar also appears at `md` and takes 256px, so the card arrived at exactly the moment the column it lives in got 256px narrower. Teacher and admin dashboards, which render the same shell without that card, never overflowed at any width — which is what isolated it. The card now waits until `lg`. Between 768px and 1023px it is still reachable in the avatar dropdown, whose gate moved from `md:hidden` to `lg:hidden` in lockstep; the two breakpoints are a pair, and a test now asserts the card is visible in exactly one of the two places at every width rather than in neither. Losing the always-visible chips in that band is a deliberate trade against a page that cannot be read without panning. Also matched the card's loading skeleton to its loaded state (`max-w-full overflow-hidden`), since the skeleton was briefly wider than the thing it stood in for. `tests/playwright/specs/overflow-586.spec.ts` walks student, teacher and admin dashboards across 700/768/800/850/900/1024/1280 and asserts `scrollWidth === clientWidth` everywhere. It fails on the parent commit with exactly the numbers above. Closes #586 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01183C2p5ed7youHQAUdPfR1
1 parent 2044307 commit cdb1866

7 files changed

Lines changed: 318 additions & 9 deletions

File tree

app/[locale]/dashboard/layout.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,19 @@ export default async function DashboardLayout({
2727
<SidebarProvider>
2828
<AppSidebar userRole={role} />
2929
<SidebarInset>
30-
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
30+
<header className="flex h-16 min-w-0 shrink-0 items-center gap-2 border-b px-4">
3131
<SidebarTrigger className="-ml-1" />
3232
<Separator orientation="vertical" className="mr-2 h-4" />
33-
<div className="ml-auto flex items-center gap-4">
33+
<div className="ml-auto flex min-w-0 items-center gap-4">
34+
{/* #586: gated at `lg`, not `md`. The desktop sidebar
35+
arrives at `md` and takes 256px, which leaves the
36+
inset too narrow for these chips — the page ended up
37+
scrolling sideways between 768px and ~890px. Below
38+
`lg` the same card is reachable in the avatar
39+
dropdown (see components/user-nav.tsx), so the two
40+
breakpoints must stay in lockstep. */}
3441
{role === 'student' && (
35-
<div className="hidden md:block">
42+
<div className="hidden lg:block">
3643
<GamificationHeaderCard />
3744
</div>
3845
)}

components/gamification/gamification-header-card.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export function GamificationHeaderCard() {
1919

2020
if (loading) {
2121
return (
22-
<div className="flex items-center gap-4 px-3 py-1.5 bg-muted/50 rounded-xl border border-border">
22+
// #586: same `max-w-full overflow-hidden` as the loaded state below,
23+
// so the skeleton cannot be wider than the thing it stands in for.
24+
<div className="flex items-center gap-4 px-3 py-1.5 bg-muted/50 rounded-xl border border-border max-w-full overflow-hidden">
2325
<Skeleton className="h-6 w-20 rounded-md" />
2426
<Skeleton className="h-6 w-16 rounded-md" />
2527
<Skeleton className="h-6 w-16 rounded-md" />
@@ -30,7 +32,10 @@ export function GamificationHeaderCard() {
3032
if (!summary) return null;
3133

3234
return (
33-
<div className="flex items-center gap-1 md:gap-2 bg-muted/50 border border-border rounded-xl p-1 md:p-1.5 max-w-full overflow-hidden">
35+
<div
36+
data-slot="gamification-header-card"
37+
className="flex items-center gap-1 md:gap-2 bg-muted/50 border border-border rounded-xl p-1 md:p-1.5 max-w-full overflow-hidden"
38+
>
3439
{/* Level & XP */}
3540
<div className="flex items-center gap-1.5 md:gap-2 px-2 md:px-2.5 py-1 rounded-lg hover:bg-accent/50 transition-colors shrink-0">
3641
<div className="relative flex items-center justify-center">

components/ui/sidebar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
307307
<main
308308
data-slot="sidebar-inset"
309309
className={cn(
310-
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
310+
// #586: `min-w-0` is load-bearing. Without it this flex item keeps its
311+
// `auto` min-width and refuses to shrink below its min-content width, so
312+
// a wide child widens the whole document instead of clipping or
313+
// scrolling inside the inset.
314+
"relative flex w-full min-w-0 flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
311315
className
312316
)}
313317
{...props}

components/user-nav.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function UserNav({ user }: UserNavProps) {
3232
<DropdownMenu>
3333
<DropdownMenuTrigger
3434
render={
35-
<Button variant="ghost" className="relative h-8 w-8 rounded-full border border-border/50 overflow-hidden transition-colors">
35+
<Button data-testid="user-nav-trigger" variant="ghost" className="relative h-8 w-8 rounded-full border border-border/50 overflow-hidden transition-colors">
3636
<CurrentUserAvatar />
3737
</Button>
3838
}
@@ -81,8 +81,12 @@ export function UserNav({ user }: UserNavProps) {
8181
<span>{t('logout')}</span>
8282
</DropdownMenuItem>
8383
</DropdownMenuGroup>
84-
<DropdownMenuSeparator className="flex md:hidden" />
85-
<DropdownMenuGroup className="flex md:hidden p-2">
84+
{/* #586: `lg`, matching the header's gate in
85+
app/[locale]/dashboard/layout.tsx. These two breakpoints are a pair —
86+
whenever the header hides the card, this has to show it, or the
87+
streak and coins become unreachable between 768px and 1023px. */}
88+
<DropdownMenuSeparator className="flex lg:hidden" />
89+
<DropdownMenuGroup className="flex lg:hidden p-2">
8690
<GamificationHeaderCard />
8791
</DropdownMenuGroup>
8892
</DropdownMenuContent>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { test } from '@playwright/test'
2+
import { loginAsStudent } from '../utils/auth'
3+
import { BASE, LOCALE } from '../utils/constants'
4+
import fs from 'node:fs'
5+
6+
/**
7+
* Frame capture for the issue #586 GIF. Sweeps the viewport across the broken
8+
* band (700 → 1024) and holds a few frames at each end, then opens the avatar
9+
* dropdown at 820px to show the gamification card is still reachable there.
10+
*
11+
* Run with SHOT_LABEL=before / SHOT_LABEL=after; stitch the frames with ffmpeg.
12+
*/
13+
14+
const LABEL = process.env.SHOT_LABEL || 'after'
15+
const DIR = `${process.env.FRAME_DIR || '/tmp/586-frames'}/${LABEL}`
16+
17+
const WIDTHS = [
18+
700, 700, 720, 740, 768, 768, 768, 790, 810, 830, 850, 870, 890, 910, 940,
19+
970, 1000, 1024, 1024, 1024,
20+
]
21+
22+
test('capture #586 gif frames', async ({ page }) => {
23+
test.setTimeout(240_000)
24+
fs.mkdirSync(DIR, { recursive: true })
25+
26+
await loginAsStudent(page)
27+
await page.setViewportSize({ width: 1024, height: 820 })
28+
await page.goto(`${BASE}/${LOCALE}/dashboard/student`)
29+
await page.waitForLoadState('networkidle').catch(() => {})
30+
await page.waitForTimeout(2000)
31+
32+
let i = 0
33+
const shot = async () => {
34+
await page.screenshot({
35+
path: `${DIR}/frame-${String(i).padStart(4, '0')}.png`,
36+
})
37+
i++
38+
}
39+
40+
for (const width of WIDTHS) {
41+
await page.setViewportSize({ width, height: 820 })
42+
await page.waitForTimeout(320)
43+
const m = await page.evaluate(() => ({
44+
c: document.documentElement.clientWidth,
45+
s: document.documentElement.scrollWidth,
46+
}))
47+
console.log(`${LABEL} @${width}: overflow=${m.s - m.c}`)
48+
await shot()
49+
}
50+
51+
// The "before" recording is only about the sideways scroll; the dropdown
52+
// segment below shows the post-fix trade and would be misleading there.
53+
if (LABEL === 'before') return
54+
55+
// Hold at 820 and open the avatar menu — the gamification card lives here
56+
// below `lg`, which is the trade this change makes.
57+
await page.setViewportSize({ width: 820, height: 820 })
58+
await page.waitForTimeout(500)
59+
await shot()
60+
await shot()
61+
await page.evaluate(() => {
62+
document.querySelector<HTMLElement>('[data-testid="user-nav-trigger"]')?.click()
63+
})
64+
await page.waitForTimeout(900)
65+
for (let k = 0; k < 8; k++) await shot()
66+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { test } from '@playwright/test'
2+
import { loginAsStudent } from '../utils/auth'
3+
import { BASE, LOCALE } from '../utils/constants'
4+
5+
/**
6+
* Screenshot capture for issue #586. Run with SHOT_LABEL=before (untouched code)
7+
* and again with SHOT_LABEL=after (with the fix) to produce a matching pair.
8+
*/
9+
10+
const LABEL = process.env.SHOT_LABEL || 'before'
11+
const OUT = process.env.SHOT_DIR || 'docs/qa'
12+
13+
const SHOTS = [
14+
{ name: 'student-dashboard', path: `/${LOCALE}/dashboard/student`, width: 768 },
15+
{ name: 'student-dashboard', path: `/${LOCALE}/dashboard/student`, width: 820 },
16+
{ name: 'student-courses', path: `/${LOCALE}/dashboard/student/courses`, width: 768 },
17+
]
18+
19+
test('capture #586 shots', async ({ page }) => {
20+
test.setTimeout(180_000)
21+
await loginAsStudent(page)
22+
23+
for (const shot of SHOTS) {
24+
await page.setViewportSize({ width: shot.width, height: 900 })
25+
await page.goto(`${BASE}${shot.path}`)
26+
await page.waitForLoadState('networkidle').catch(() => {})
27+
await page.waitForTimeout(1200)
28+
29+
// Scroll fully right so the shot shows the overflow rather than hiding it.
30+
const metrics = await page.evaluate(() => {
31+
const doc = document.documentElement
32+
return { client: doc.clientWidth, scroll: doc.scrollWidth }
33+
})
34+
console.log(
35+
`${shot.name}@${shot.width} client=${metrics.client} scroll=${metrics.scroll} overflow=${metrics.scroll - metrics.client}`
36+
)
37+
38+
await page.screenshot({
39+
path: `${OUT}/586-${LABEL}-${shot.name}-${shot.width}.png`,
40+
})
41+
42+
if (metrics.scroll > metrics.client) {
43+
await page.evaluate(() => window.scrollTo(document.documentElement.scrollWidth, 0))
44+
await page.waitForTimeout(400)
45+
await page.screenshot({
46+
path: `${OUT}/586-${LABEL}-${shot.name}-${shot.width}-scrolled-right.png`,
47+
})
48+
}
49+
}
50+
})
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { test, expect } from '@playwright/test'
2+
import { loginAsStudent, loginAsTeacher } from '../utils/auth'
3+
import { BASE, LOCALE } from '../utils/constants'
4+
5+
/**
6+
* Issue #586 — the dashboard shell scrolls horizontally between 768px and ~890px.
7+
*
8+
* `SidebarInset` is `w-full flex-1` with no `min-w-0`, so it cannot shrink below
9+
* its min-content width. At `md` the 256px desktop sidebar appears at the same
10+
* moment the header's right-hand cluster stops being compact, and between them
11+
* they demand more room than the viewport has.
12+
*/
13+
14+
const WIDTHS = [700, 768, 800, 850, 900, 1024, 1280]
15+
16+
const PAGES = [
17+
{ name: 'student-dashboard', path: `/${LOCALE}/dashboard/student` },
18+
{ name: 'student-courses', path: `/${LOCALE}/dashboard/student/courses` },
19+
{ name: 'student-browse', path: `/${LOCALE}/dashboard/student/browse` },
20+
]
21+
22+
const TEACHER_PAGES = [
23+
{ name: 'teacher-dashboard', path: `/${LOCALE}/dashboard/teacher` },
24+
{ name: 'admin-dashboard', path: `/${LOCALE}/dashboard/admin` },
25+
]
26+
27+
type Measurement = {
28+
page: string
29+
width: number
30+
clientWidth: number
31+
scrollWidth: number
32+
overflow: number
33+
}
34+
35+
async function measure(page: import('@playwright/test').Page) {
36+
return page.evaluate(() => {
37+
const doc = document.documentElement
38+
const main = document.querySelector('[data-slot="sidebar-inset"]')
39+
const mainRect = main?.getBoundingClientRect()
40+
return {
41+
clientWidth: doc.clientWidth,
42+
scrollWidth: doc.scrollWidth,
43+
mainLeft: mainRect ? Math.round(mainRect.left) : null,
44+
mainWidth: mainRect ? Math.round(mainRect.width) : null,
45+
}
46+
})
47+
}
48+
49+
test.describe('#586 dashboard shell horizontal overflow', () => {
50+
test('student pages do not scroll sideways at any width', async ({ page }) => {
51+
test.setTimeout(180_000)
52+
await loginAsStudent(page)
53+
54+
const rows: Measurement[] = []
55+
56+
for (const target of PAGES) {
57+
for (const width of WIDTHS) {
58+
await page.setViewportSize({ width, height: 900 })
59+
await page.goto(`${BASE}${target.path}`)
60+
await page.waitForLoadState('networkidle').catch(() => {})
61+
await page.waitForTimeout(600)
62+
const m = await measure(page)
63+
rows.push({
64+
page: target.name,
65+
width,
66+
clientWidth: m.clientWidth,
67+
scrollWidth: m.scrollWidth,
68+
overflow: m.scrollWidth - m.clientWidth,
69+
})
70+
console.log(
71+
`${target.name} @${width}: client=${m.clientWidth} scroll=${m.scrollWidth} overflow=${m.scrollWidth - m.clientWidth} main=${m.mainLeft}/${m.mainWidth}`
72+
)
73+
}
74+
}
75+
76+
console.log('\n=== SUMMARY ===')
77+
for (const r of rows) {
78+
console.log(`${r.page}\t${r.width}\t${r.clientWidth}\t${r.scrollWidth}\t${r.overflow}`)
79+
}
80+
81+
const offenders = rows.filter((r) => r.overflow > 0)
82+
expect(
83+
offenders,
84+
`pages scrolling horizontally: ${JSON.stringify(offenders, null, 2)}`
85+
).toEqual([])
86+
})
87+
88+
test('teacher and admin pages do not scroll sideways at any width', async ({ page }) => {
89+
test.setTimeout(180_000)
90+
await loginAsTeacher(page)
91+
92+
const rows: Measurement[] = []
93+
94+
for (const target of TEACHER_PAGES) {
95+
for (const width of WIDTHS) {
96+
await page.setViewportSize({ width, height: 900 })
97+
await page.goto(`${BASE}${target.path}`)
98+
await page.waitForLoadState('networkidle').catch(() => {})
99+
await page.waitForTimeout(600)
100+
const m = await measure(page)
101+
rows.push({
102+
page: target.name,
103+
width,
104+
clientWidth: m.clientWidth,
105+
scrollWidth: m.scrollWidth,
106+
overflow: m.scrollWidth - m.clientWidth,
107+
})
108+
console.log(
109+
`${target.name} @${width}: client=${m.clientWidth} scroll=${m.scrollWidth} overflow=${m.scrollWidth - m.clientWidth} main=${m.mainLeft}/${m.mainWidth}`
110+
)
111+
}
112+
}
113+
114+
const offenders = rows.filter((r) => r.overflow > 0)
115+
expect(
116+
offenders,
117+
`pages scrolling horizontally: ${JSON.stringify(offenders, null, 2)}`
118+
).toEqual([])
119+
})
120+
121+
/**
122+
* The header hides the gamification card below `lg`; the avatar dropdown shows
123+
* it below `lg`. Those two gates are a pair — if they ever drift apart the
124+
* streak and coins silently vanish (or double up) in the gap. This asserts the
125+
* card is visible in exactly one place at each width.
126+
*/
127+
test('gamification card is reachable at every width, in exactly one place', async ({
128+
page,
129+
}) => {
130+
test.setTimeout(120_000)
131+
await loginAsStudent(page)
132+
133+
for (const width of [700, 768, 850, 1024, 1280]) {
134+
await page.setViewportSize({ width, height: 900 })
135+
await page.goto(`${BASE}/${LOCALE}/dashboard/student`)
136+
await page.waitForLoadState('networkidle').catch(() => {})
137+
await page.waitForTimeout(800)
138+
139+
const headerCard = page.locator('header [data-slot="gamification-header-card"]')
140+
const headerVisible = await headerCard.isVisible().catch(() => false)
141+
142+
// Open the avatar dropdown and look for the card inside it. Clicking a
143+
// base-ui Button through Playwright is unreliable, so dispatch it in-page.
144+
await page.evaluate(() => {
145+
const btn = document.querySelector<HTMLElement>(
146+
'[data-testid="user-nav-trigger"]'
147+
)
148+
if (!btn) throw new Error('user-nav-trigger not found')
149+
btn.click()
150+
})
151+
await page.waitForTimeout(700)
152+
const menuCard = page.locator(
153+
'[role="menu"] [data-slot="gamification-header-card"], [data-side] [data-slot="gamification-header-card"]'
154+
)
155+
const menuVisible = await menuCard.first().isVisible().catch(() => false)
156+
await page.keyboard.press('Escape')
157+
158+
console.log(`@${width}: header=${headerVisible} dropdown=${menuVisible}`)
159+
160+
expect(
161+
headerVisible || menuVisible,
162+
`at ${width}px the gamification card is in neither the header nor the dropdown`
163+
).toBe(true)
164+
expect(
165+
headerVisible && menuVisible,
166+
`at ${width}px the gamification card is duplicated in header and dropdown`
167+
).toBe(false)
168+
169+
// Above lg it belongs in the header; below lg, in the dropdown.
170+
expect(headerVisible, `header card visibility at ${width}px`).toBe(width >= 1024)
171+
}
172+
})
173+
})

0 commit comments

Comments
 (0)