fix: day header overlap, mobile footer alignment, modal animation

Day headers in message history could cover message rows. Two distinct causes,
both reproduced before fixing:
- On mobile the header was pinned to top-14, the same sticky band the mobile
  search bar already occupies, so it detached and landed on top of rows.
  Being bg-muted/70 with a backdrop blur, row text bled through it, which is
  the distortion that was reported.
- On desktop it was pinned to top-0, behind the app header, and it covered
  rows scrolled beneath it and swallowed their clicks. Playwright surfaced
  this second one: a row click failed with "h3 intercepts pointer events".

Day headers are no longer sticky. A page holds 20 messages, so groups are
short and pinning bought little in exchange for those failures.

Footer links are left-aligned on mobile. Centred links in a single column had
no common edge to scan down.

Modal animation is now a quick fade. Dialogs slid in from the left and top
while zooming, which is the movement that read badly; the slide and zoom are
gone and the duration drops from 200ms to 150ms, applied to both the dialog
and alert-dialog primitives so every modal matches. Sheets still slide, since
that is what a drawer should do.

Guards: the overlap check runs at both mobile and desktop widths and also
asserts a row is still clickable after scrolling. Verified it fails against
the old markup rather than assuming a green test means coverage.

Fixture dates now anchor to local midnight instead of "N hours ago". A
2-hour-old message falls on the previous day when the suite runs shortly
after midnight, which made the Today/Yesterday assertions depend on the wall
clock. It failed exactly that way during this session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-19 00:07:29 +03:00
parent 9913b7cbe7
commit 1999593e07
6 changed files with 99 additions and 13 deletions

View File

@@ -208,7 +208,13 @@ export default function MessageHistory() {
<div className='overflow-hidden rounded-xl border border-border'>
{days.map((day) => (
<section key={day.key}>
<h3 className='sticky top-14 z-10 border-b border-border bg-muted/70 px-3 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur md:top-0'>
{/* Deliberately not sticky. On mobile it pinned to the same
offset as the sticky search bar and landed on top of message
rows, translucent, with text bleeding through. On desktop it
covered rows scrolled beneath it and swallowed their clicks.
A page holds 20 messages, so groups are short and a pinned
header bought little in exchange for that. */}
<h3 className='border-b border-border bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground'>
{day.label}
</h3>
<div className='divide-y divide-border'>

View File

@@ -22,7 +22,9 @@ const linkClass =
export default function Footer() {
return (
<footer className='border-t border-border bg-muted/30'>
<div className='mx-auto flex max-w-7xl flex-col items-center gap-4 px-4 py-6 sm:px-6 md:flex-row md:justify-between lg:px-8'>
{/* Left-aligned on mobile: centred links in a single column read as a
ragged stack with no common edge to scan down. */}
<div className='mx-auto flex max-w-7xl flex-col items-start gap-4 px-4 py-6 sm:items-center sm:px-6 md:flex-row md:justify-between lg:px-8'>
<div className='flex items-center gap-2'>
<Image
src='/images/logo.png'
@@ -38,7 +40,7 @@ export default function Footer() {
{/* Stacked on mobile: wrapped inline links produced a ragged two-line
block that was hard to scan and gave small tap targets. */}
<nav className='flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-center sm:gap-x-5 sm:gap-y-2'>
<nav className='flex w-full flex-col items-start gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-center sm:gap-x-5 sm:gap-y-2'>
{links.map((link) => (
<Link
key={link.label}

View File

@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
className
)}
{...props}

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
className
)}
{...props}

View File

@@ -15,6 +15,75 @@ test.describe('message history (mocked API, no real backend)', () => {
await expect(page.getByText('Reply from a customer')).toBeVisible()
})
// Checked at both widths: the mobile break was a header pinned to the same
// offset as the search bar, and the desktop one was a header covering rows
// and swallowing their clicks.
for (const viewport of [
{ name: 'mobile', width: 390, height: 700 },
{ name: 'desktop', width: 1280, height: 800 },
]) {
test(`day headers never overlap message rows on ${viewport.name}`, async ({
page,
context,
}) => {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
})
await authenticate(context)
await mockApi(page)
// Enough messages across several days to force scrolling.
const at = (days: number, hours: number) =>
new Date(Date.now() - days * 86400000 - hours * 3600000).toISOString()
await page.route('**/api/v1/gateway/devices/*/messages*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: Array.from({ length: 12 }).map((_, i) => ({
_id: `m${i}`,
sender: `+2519403617${40 + i}`,
message: 'A message long enough to wrap onto a second line here',
status: 'received',
type: 'received',
receivedAt: at(Math.floor(i / 3), i),
createdAt: at(Math.floor(i / 3), i),
})),
meta: { total: 12, page: 1, limit: 20, totalPages: 1 },
}),
})
)
await page.goto('/dashboard/messaging/history')
await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible()
await page.evaluate(() => window.scrollTo(0, 420))
const headers = await page.locator('h3').all()
const rows = await page.getByRole('button', { name: /\+2519/ }).all()
for (const header of headers) {
const hb = await header.boundingBox()
if (!hb) continue
for (const row of rows) {
const rb = await row.boundingBox()
if (!rb) continue
const overlap =
Math.min(hb.y + hb.height, rb.y + rb.height) - Math.max(hb.y, rb.y)
expect(
overlap,
'a day header must not cover a message row'
).toBeLessThanOrEqual(1)
}
}
// A covering header also swallowed the row's click, so the row must
// still be clickable after scrolling.
await page.getByRole('button', { name: /\+2519/ }).first().click()
await expect(page.getByRole('dialog')).toBeVisible()
})
}
test('search is sent to the server, not applied to the loaded page', async ({
page,
context,

View File

@@ -107,9 +107,18 @@ export const mockWebhookNotifications = { data: [], total: 0 }
// The real endpoint populates `device` (select: _id brand model buildId
// enabled), so the fixtures carry it too: replying reads message.device._id,
// and without it the mocked path would not exercise what production does.
// Dates are relative so the day-grouped list always has a "Today" section.
const hoursAgo = (hours: number) =>
new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
// Anchored to local midnight rather than "N hours ago": a message 2 hours old
// falls on the previous day when the suite runs just after midnight, which
// made the Today/Yesterday grouping assertions depend on the wall clock.
const startOfToday = () => {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d.getTime()
}
// Now is always today and never in the future.
const todayIso = () => new Date().toISOString()
// An hour before local midnight is always yesterday.
const yesterdayIso = () => new Date(startOfToday() - 60 * 60 * 1000).toISOString()
export const mockMessages = {
data: [
@@ -120,8 +129,8 @@ export const mockMessages = {
status: 'sent',
type: 'sent',
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
requestedAt: hoursAgo(2),
createdAt: hoursAgo(2),
requestedAt: todayIso(),
createdAt: todayIso(),
},
{
_id: 'msg_2',
@@ -130,8 +139,8 @@ export const mockMessages = {
status: 'received',
type: 'received',
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
receivedAt: hoursAgo(30),
createdAt: hoursAgo(30),
receivedAt: yesterdayIso(),
createdAt: yesterdayIso(),
},
],
meta: { total: 2, page: 1, limit: 20, totalPages: 1 },