diff --git a/app/[locale]/dashboard/layout.tsx b/app/[locale]/dashboard/layout.tsx index 7b6c94954..5f9e4a23f 100644 --- a/app/[locale]/dashboard/layout.tsx +++ b/app/[locale]/dashboard/layout.tsx @@ -27,12 +27,19 @@ export default async function DashboardLayout({ -
+
-
+
+ {/* #586: gated at `lg`, not `md`. The desktop sidebar + arrives at `md` and takes 256px, which leaves the + inset too narrow for these chips — the page ended up + scrolling sideways between 768px and ~890px. Below + `lg` the same card is reachable in the avatar + dropdown (see components/user-nav.tsx), so the two + breakpoints must stay in lockstep. */} {role === 'student' && ( -
+
)} diff --git a/components/gamification/gamification-header-card.tsx b/components/gamification/gamification-header-card.tsx index 495981f1e..44949449f 100644 --- a/components/gamification/gamification-header-card.tsx +++ b/components/gamification/gamification-header-card.tsx @@ -19,7 +19,9 @@ export function GamificationHeaderCard() { if (loading) { return ( -
+ // #586: same `max-w-full overflow-hidden` as the loaded state below, + // so the skeleton cannot be wider than the thing it stands in for. +
@@ -30,7 +32,10 @@ export function GamificationHeaderCard() { if (!summary) return null; return ( -
+
{/* Level & XP */}
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index 05aaf00ca..ebebd14b3 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -307,7 +307,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ } @@ -81,8 +81,12 @@ export function UserNav({ user }: UserNavProps) { {t('logout')} - - + {/* #586: `lg`, matching the header's gate in + app/[locale]/dashboard/layout.tsx. These two breakpoints are a pair — + whenever the header hides the card, this has to show it, or the + streak and coins become unreachable between 768px and 1023px. */} + + diff --git a/docs/qa/586-after-student-courses-768.png b/docs/qa/586-after-student-courses-768.png new file mode 100644 index 000000000..ea80af195 Binary files /dev/null and b/docs/qa/586-after-student-courses-768.png differ diff --git a/docs/qa/586-after-student-dashboard-768.png b/docs/qa/586-after-student-dashboard-768.png new file mode 100644 index 000000000..c8d4a70e9 Binary files /dev/null and b/docs/qa/586-after-student-dashboard-768.png differ diff --git a/docs/qa/586-after-student-dashboard-820.png b/docs/qa/586-after-student-dashboard-820.png new file mode 100644 index 000000000..28eceb6fb Binary files /dev/null and b/docs/qa/586-after-student-dashboard-820.png differ diff --git a/docs/qa/586-after.gif b/docs/qa/586-after.gif new file mode 100644 index 000000000..68165f9a3 Binary files /dev/null and b/docs/qa/586-after.gif differ diff --git a/docs/qa/586-before-student-courses-768.png b/docs/qa/586-before-student-courses-768.png new file mode 100644 index 000000000..959a3d223 Binary files /dev/null and b/docs/qa/586-before-student-courses-768.png differ diff --git a/docs/qa/586-before-student-dashboard-768-scrolled-right.png b/docs/qa/586-before-student-dashboard-768-scrolled-right.png new file mode 100644 index 000000000..e8c3da7fd Binary files /dev/null and b/docs/qa/586-before-student-dashboard-768-scrolled-right.png differ diff --git a/docs/qa/586-before-student-dashboard-768.png b/docs/qa/586-before-student-dashboard-768.png new file mode 100644 index 000000000..5146d2678 Binary files /dev/null and b/docs/qa/586-before-student-dashboard-768.png differ diff --git a/docs/qa/586-before-student-dashboard-820.png b/docs/qa/586-before-student-dashboard-820.png new file mode 100644 index 000000000..8c9505ca5 Binary files /dev/null and b/docs/qa/586-before-student-dashboard-820.png differ diff --git a/docs/qa/586-before.gif b/docs/qa/586-before.gif new file mode 100644 index 000000000..332edbafd Binary files /dev/null and b/docs/qa/586-before.gif differ diff --git a/tests/playwright/specs/overflow-586-gif.spec.ts b/tests/playwright/specs/overflow-586-gif.spec.ts new file mode 100644 index 000000000..d388f20c0 --- /dev/null +++ b/tests/playwright/specs/overflow-586-gif.spec.ts @@ -0,0 +1,66 @@ +import { test } from '@playwright/test' +import { loginAsStudent } from '../utils/auth' +import { BASE, LOCALE } from '../utils/constants' +import fs from 'node:fs' + +/** + * Frame capture for the issue #586 GIF. Sweeps the viewport across the broken + * band (700 → 1024) and holds a few frames at each end, then opens the avatar + * dropdown at 820px to show the gamification card is still reachable there. + * + * Run with SHOT_LABEL=before / SHOT_LABEL=after; stitch the frames with ffmpeg. + */ + +const LABEL = process.env.SHOT_LABEL || 'after' +const DIR = `${process.env.FRAME_DIR || '/tmp/586-frames'}/${LABEL}` + +const WIDTHS = [ + 700, 700, 720, 740, 768, 768, 768, 790, 810, 830, 850, 870, 890, 910, 940, + 970, 1000, 1024, 1024, 1024, +] + +test('capture #586 gif frames', async ({ page }) => { + test.setTimeout(240_000) + fs.mkdirSync(DIR, { recursive: true }) + + await loginAsStudent(page) + await page.setViewportSize({ width: 1024, height: 820 }) + await page.goto(`${BASE}/${LOCALE}/dashboard/student`) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(2000) + + let i = 0 + const shot = async () => { + await page.screenshot({ + path: `${DIR}/frame-${String(i).padStart(4, '0')}.png`, + }) + i++ + } + + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 820 }) + await page.waitForTimeout(320) + const m = await page.evaluate(() => ({ + c: document.documentElement.clientWidth, + s: document.documentElement.scrollWidth, + })) + console.log(`${LABEL} @${width}: overflow=${m.s - m.c}`) + await shot() + } + + // The "before" recording is only about the sideways scroll; the dropdown + // segment below shows the post-fix trade and would be misleading there. + if (LABEL === 'before') return + + // Hold at 820 and open the avatar menu — the gamification card lives here + // below `lg`, which is the trade this change makes. + await page.setViewportSize({ width: 820, height: 820 }) + await page.waitForTimeout(500) + await shot() + await shot() + await page.evaluate(() => { + document.querySelector('[data-testid="user-nav-trigger"]')?.click() + }) + await page.waitForTimeout(900) + for (let k = 0; k < 8; k++) await shot() +}) diff --git a/tests/playwright/specs/overflow-586-shots.spec.ts b/tests/playwright/specs/overflow-586-shots.spec.ts new file mode 100644 index 000000000..0fabb4b51 --- /dev/null +++ b/tests/playwright/specs/overflow-586-shots.spec.ts @@ -0,0 +1,50 @@ +import { test } from '@playwright/test' +import { loginAsStudent } from '../utils/auth' +import { BASE, LOCALE } from '../utils/constants' + +/** + * Screenshot capture for issue #586. Run with SHOT_LABEL=before (untouched code) + * and again with SHOT_LABEL=after (with the fix) to produce a matching pair. + */ + +const LABEL = process.env.SHOT_LABEL || 'before' +const OUT = process.env.SHOT_DIR || 'docs/qa' + +const SHOTS = [ + { name: 'student-dashboard', path: `/${LOCALE}/dashboard/student`, width: 768 }, + { name: 'student-dashboard', path: `/${LOCALE}/dashboard/student`, width: 820 }, + { name: 'student-courses', path: `/${LOCALE}/dashboard/student/courses`, width: 768 }, +] + +test('capture #586 shots', async ({ page }) => { + test.setTimeout(180_000) + await loginAsStudent(page) + + for (const shot of SHOTS) { + await page.setViewportSize({ width: shot.width, height: 900 }) + await page.goto(`${BASE}${shot.path}`) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(1200) + + // Scroll fully right so the shot shows the overflow rather than hiding it. + const metrics = await page.evaluate(() => { + const doc = document.documentElement + return { client: doc.clientWidth, scroll: doc.scrollWidth } + }) + console.log( + `${shot.name}@${shot.width} client=${metrics.client} scroll=${metrics.scroll} overflow=${metrics.scroll - metrics.client}` + ) + + await page.screenshot({ + path: `${OUT}/586-${LABEL}-${shot.name}-${shot.width}.png`, + }) + + if (metrics.scroll > metrics.client) { + await page.evaluate(() => window.scrollTo(document.documentElement.scrollWidth, 0)) + await page.waitForTimeout(400) + await page.screenshot({ + path: `${OUT}/586-${LABEL}-${shot.name}-${shot.width}-scrolled-right.png`, + }) + } + } +}) diff --git a/tests/playwright/specs/overflow-586.spec.ts b/tests/playwright/specs/overflow-586.spec.ts new file mode 100644 index 000000000..fbdc5bded --- /dev/null +++ b/tests/playwright/specs/overflow-586.spec.ts @@ -0,0 +1,173 @@ +import { test, expect } from '@playwright/test' +import { loginAsStudent, loginAsTeacher } from '../utils/auth' +import { BASE, LOCALE } from '../utils/constants' + +/** + * Issue #586 — the dashboard shell scrolls horizontally between 768px and ~890px. + * + * `SidebarInset` is `w-full flex-1` with no `min-w-0`, so it cannot shrink below + * its min-content width. At `md` the 256px desktop sidebar appears at the same + * moment the header's right-hand cluster stops being compact, and between them + * they demand more room than the viewport has. + */ + +const WIDTHS = [700, 768, 800, 850, 900, 1024, 1280] + +const PAGES = [ + { name: 'student-dashboard', path: `/${LOCALE}/dashboard/student` }, + { name: 'student-courses', path: `/${LOCALE}/dashboard/student/courses` }, + { name: 'student-browse', path: `/${LOCALE}/dashboard/student/browse` }, +] + +const TEACHER_PAGES = [ + { name: 'teacher-dashboard', path: `/${LOCALE}/dashboard/teacher` }, + { name: 'admin-dashboard', path: `/${LOCALE}/dashboard/admin` }, +] + +type Measurement = { + page: string + width: number + clientWidth: number + scrollWidth: number + overflow: number +} + +async function measure(page: import('@playwright/test').Page) { + return page.evaluate(() => { + const doc = document.documentElement + const main = document.querySelector('[data-slot="sidebar-inset"]') + const mainRect = main?.getBoundingClientRect() + return { + clientWidth: doc.clientWidth, + scrollWidth: doc.scrollWidth, + mainLeft: mainRect ? Math.round(mainRect.left) : null, + mainWidth: mainRect ? Math.round(mainRect.width) : null, + } + }) +} + +test.describe('#586 dashboard shell horizontal overflow', () => { + test('student pages do not scroll sideways at any width', async ({ page }) => { + test.setTimeout(180_000) + await loginAsStudent(page) + + const rows: Measurement[] = [] + + for (const target of PAGES) { + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 900 }) + await page.goto(`${BASE}${target.path}`) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(600) + const m = await measure(page) + rows.push({ + page: target.name, + width, + clientWidth: m.clientWidth, + scrollWidth: m.scrollWidth, + overflow: m.scrollWidth - m.clientWidth, + }) + console.log( + `${target.name} @${width}: client=${m.clientWidth} scroll=${m.scrollWidth} overflow=${m.scrollWidth - m.clientWidth} main=${m.mainLeft}/${m.mainWidth}` + ) + } + } + + console.log('\n=== SUMMARY ===') + for (const r of rows) { + console.log(`${r.page}\t${r.width}\t${r.clientWidth}\t${r.scrollWidth}\t${r.overflow}`) + } + + const offenders = rows.filter((r) => r.overflow > 0) + expect( + offenders, + `pages scrolling horizontally: ${JSON.stringify(offenders, null, 2)}` + ).toEqual([]) + }) + + test('teacher and admin pages do not scroll sideways at any width', async ({ page }) => { + test.setTimeout(180_000) + await loginAsTeacher(page) + + const rows: Measurement[] = [] + + for (const target of TEACHER_PAGES) { + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 900 }) + await page.goto(`${BASE}${target.path}`) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(600) + const m = await measure(page) + rows.push({ + page: target.name, + width, + clientWidth: m.clientWidth, + scrollWidth: m.scrollWidth, + overflow: m.scrollWidth - m.clientWidth, + }) + console.log( + `${target.name} @${width}: client=${m.clientWidth} scroll=${m.scrollWidth} overflow=${m.scrollWidth - m.clientWidth} main=${m.mainLeft}/${m.mainWidth}` + ) + } + } + + const offenders = rows.filter((r) => r.overflow > 0) + expect( + offenders, + `pages scrolling horizontally: ${JSON.stringify(offenders, null, 2)}` + ).toEqual([]) + }) + + /** + * The header hides the gamification card below `lg`; the avatar dropdown shows + * it below `lg`. Those two gates are a pair — if they ever drift apart the + * streak and coins silently vanish (or double up) in the gap. This asserts the + * card is visible in exactly one place at each width. + */ + test('gamification card is reachable at every width, in exactly one place', async ({ + page, + }) => { + test.setTimeout(120_000) + await loginAsStudent(page) + + for (const width of [700, 768, 850, 1024, 1280]) { + await page.setViewportSize({ width, height: 900 }) + await page.goto(`${BASE}/${LOCALE}/dashboard/student`) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(800) + + const headerCard = page.locator('header [data-slot="gamification-header-card"]') + const headerVisible = await headerCard.isVisible().catch(() => false) + + // Open the avatar dropdown and look for the card inside it. Clicking a + // base-ui Button through Playwright is unreliable, so dispatch it in-page. + await page.evaluate(() => { + const btn = document.querySelector( + '[data-testid="user-nav-trigger"]' + ) + if (!btn) throw new Error('user-nav-trigger not found') + btn.click() + }) + await page.waitForTimeout(700) + const menuCard = page.locator( + '[role="menu"] [data-slot="gamification-header-card"], [data-side] [data-slot="gamification-header-card"]' + ) + const menuVisible = await menuCard.first().isVisible().catch(() => false) + await page.keyboard.press('Escape') + + console.log(`@${width}: header=${headerVisible} dropdown=${menuVisible}`) + + expect( + headerVisible || menuVisible, + `at ${width}px the gamification card is in neither the header nor the dropdown` + ).toBe(true) + expect( + headerVisible && menuVisible, + `at ${width}px the gamification card is duplicated in header and dropdown` + ).toBe(false) + + // Above lg it belongs in the header; below lg, in the dropdown. + expect(headerVisible, `header card visibility at ${width}px`).toBe(width >= 1024) + } + }) +})