Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions app/[locale]/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,19 @@ export default async function DashboardLayout({
<SidebarProvider>
<AppSidebar userRole={role} />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<header className="flex h-16 min-w-0 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="ml-auto flex items-center gap-4">
<div className="ml-auto flex min-w-0 items-center gap-4">
{/* #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' && (
<div className="hidden md:block">
<div className="hidden lg:block">
<GamificationHeaderCard />
</div>
)}
Expand Down
9 changes: 7 additions & 2 deletions components/gamification/gamification-header-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export function GamificationHeaderCard() {

if (loading) {
return (
<div className="flex items-center gap-4 px-3 py-1.5 bg-muted/50 rounded-xl border border-border">
// #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.
<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">
<Skeleton className="h-6 w-20 rounded-md" />
<Skeleton className="h-6 w-16 rounded-md" />
<Skeleton className="h-6 w-16 rounded-md" />
Expand All @@ -30,7 +32,10 @@ export function GamificationHeaderCard() {
if (!summary) return null;

return (
<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">
<div
data-slot="gamification-header-card"
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"
>
{/* Level & XP */}
<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">
<div className="relative flex items-center justify-center">
Expand Down
6 changes: 5 additions & 1 deletion components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"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",
// #586: `min-w-0` is load-bearing. Without it this flex item keeps its
// `auto` min-width and refuses to shrink below its min-content width, so
// a wide child widens the whole document instead of clipping or
// scrolling inside the inset.
"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",
className
)}
{...props}
Expand Down
10 changes: 7 additions & 3 deletions components/user-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function UserNav({ user }: UserNavProps) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" className="relative h-8 w-8 rounded-full border border-border/50 overflow-hidden transition-colors">
<Button data-testid="user-nav-trigger" variant="ghost" className="relative h-8 w-8 rounded-full border border-border/50 overflow-hidden transition-colors">
<CurrentUserAvatar />
</Button>
}
Expand Down Expand Up @@ -81,8 +81,12 @@ export function UserNav({ user }: UserNavProps) {
<span>{t('logout')}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator className="flex md:hidden" />
<DropdownMenuGroup className="flex md:hidden p-2">
{/* #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. */}
<DropdownMenuSeparator className="flex lg:hidden" />
<DropdownMenuGroup className="flex lg:hidden p-2">
<GamificationHeaderCard />
</DropdownMenuGroup>
</DropdownMenuContent>
Expand Down
Binary file added docs/qa/586-after-student-courses-768.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-after-student-dashboard-768.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-after-student-dashboard-820.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-after.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-before-student-courses-768.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-before-student-dashboard-768.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-before-student-dashboard-820.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/qa/586-before.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions tests/playwright/specs/overflow-586-gif.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[data-testid="user-nav-trigger"]')?.click()
})
await page.waitForTimeout(900)
for (let k = 0; k < 8; k++) await shot()
})
50 changes: 50 additions & 0 deletions tests/playwright/specs/overflow-586-shots.spec.ts
Original file line number Diff line number Diff line change
@@ -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`,
})
}
}
})
173 changes: 173 additions & 0 deletions tests/playwright/specs/overflow-586.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(
'[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)
}
})
})
Loading