feat(fase-9): cap reading column at max-w-2xl on desktop detail routes
Closes 9.2 (desktop reading width). - (app)/+layout.svelte wraps detail routes (/lists/[id], /tasks/[id], /notes/[id]) in a max-w-2xl mx-auto container at the md breakpoint and up. Mobile (<md) keeps the existing full-width layout — the wrapper only kicks in via md:max-w-2xl. The shopping session (/lists/[id]/session) is explicitly excluded so the immersive mode still occupies the whole viewport. - A new readingRoutePattern lives alongside the existing detailRoutePattern so the two concerns stay independently testable (detail = hide global top bar; reading = cap width). - RW-01..RW-03 in tests/e2e/reading-width.test.ts measure the bounding box of [data-testid="reading-column"] under 1280px and 390px viewports, and confirm the wrapper is absent from /session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,13 @@
|
|||||||
const detailRoutePattern = /^\/(lists\/[0-9a-f-]+(?:\/session)?|tasks\/[0-9a-f-]+|notes\/[0-9a-f-]+)\/?$/;
|
const detailRoutePattern = /^\/(lists\/[0-9a-f-]+(?:\/session)?|tasks\/[0-9a-f-]+|notes\/[0-9a-f-]+)\/?$/;
|
||||||
const isDetailRoute = $derived(detailRoutePattern.test($page.url.pathname));
|
const isDetailRoute = $derived(detailRoutePattern.test($page.url.pathname));
|
||||||
|
|
||||||
|
// Fase 9.2: cap the reading column at max-w-2xl on >=md viewports for
|
||||||
|
// content detail routes (lists/[id], tasks/[id], notes/[id]). Mobile
|
||||||
|
// stays full-width. Shopping session is explicitly excluded — the mode
|
||||||
|
// is immersive and fills the viewport.
|
||||||
|
const readingRoutePattern = /^\/(lists\/[0-9a-f-]+|tasks\/[0-9a-f-]+|notes\/[0-9a-f-]+)\/?$/;
|
||||||
|
const isReadingRoute = $derived(readingRoutePattern.test($page.url.pathname));
|
||||||
|
|
||||||
// When auth resolves as unauthenticated, redirect to Keycloak.
|
// When auth resolves as unauthenticated, redirect to Keycloak.
|
||||||
// This runs after onAuthStateChange fires (authLoading = false), so there
|
// This runs after onAuthStateChange fires (authLoading = false), so there
|
||||||
// is no race with Supabase's async localStorage initialisation.
|
// is no race with Supabase's async localStorage initialisation.
|
||||||
@@ -82,7 +89,17 @@
|
|||||||
<MobileDrawer bind:open={drawerOpen} />
|
<MobileDrawer bind:open={drawerOpen} />
|
||||||
|
|
||||||
<main class="flex flex-1 flex-col overflow-hidden bg-background pb-16 md:pb-0">
|
<main class="flex flex-1 flex-col overflow-hidden bg-background pb-16 md:pb-0">
|
||||||
{@render children()}
|
{#if isReadingRoute}
|
||||||
|
<!-- Fase 9.2: cap reading column at 42rem on >=md, full-width on mobile. -->
|
||||||
|
<div
|
||||||
|
data-testid="reading-column"
|
||||||
|
class="flex flex-1 flex-col overflow-hidden md:mx-auto md:w-full md:max-w-2xl"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
|
|||||||
78
apps/web/tests/e2e/reading-width.test.ts
Normal file
78
apps/web/tests/e2e/reading-width.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* RW-series: Desktop reading-width cap (Fase 9.2).
|
||||||
|
*
|
||||||
|
* RW-01 detail routes (/lists/[id], /tasks/[id], /notes/[id]) cap the
|
||||||
|
* content column at max-w-2xl on >=md viewports — measured by the
|
||||||
|
* main element's bounding box being narrower than a 1280px viewport.
|
||||||
|
* RW-02 mobile viewports (<md) keep the content full-width.
|
||||||
|
* RW-03 shopping session (/lists/[id]/session) stays full-width on desktop
|
||||||
|
* because the mode is immersive.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { USERS } from '../fixtures/users.js';
|
||||||
|
import { loginAs } from '../fixtures/login.js';
|
||||||
|
|
||||||
|
const DESKTOP = { width: 1280, height: 900 };
|
||||||
|
const MOBILE = { width: 390, height: 800 };
|
||||||
|
|
||||||
|
/** max-w-2xl in Tailwind = 42rem = 672px. */
|
||||||
|
const READING_WIDTH = 672;
|
||||||
|
|
||||||
|
async function firstListId(page: Page): Promise<string> {
|
||||||
|
await page.goto('/lists');
|
||||||
|
// Click any list card to get into a detail route.
|
||||||
|
const card = page.getByRole('heading', { level: 3 }).or(page.getByRole('heading', { level: 2 })).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||||
|
await card.click();
|
||||||
|
await page.waitForURL(/\/lists\/[0-9a-f-]+\/?$/, { timeout: 10_000 });
|
||||||
|
const url = new URL(page.url());
|
||||||
|
const segments = url.pathname.split('/');
|
||||||
|
return segments[segments.indexOf('lists') + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Reading width — desktop max-w-2xl cap', () => {
|
||||||
|
test('RW-01: list detail page caps content at max-w-2xl on desktop', async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({ viewport: DESKTOP });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await loginAs(page, USERS.ana);
|
||||||
|
await firstListId(page);
|
||||||
|
|
||||||
|
// The detail content should live inside an element whose width is
|
||||||
|
// capped at max-w-2xl (672px). We assert the inner scroll container
|
||||||
|
// reports a width <= 672 + slack for padding.
|
||||||
|
const wrapper = page.locator('[data-testid="reading-column"]').first();
|
||||||
|
await expect(wrapper).toBeVisible({ timeout: 10_000 });
|
||||||
|
const box = await wrapper.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.width).toBeLessThanOrEqual(READING_WIDTH + 4);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RW-02: list detail page is full-width on mobile (<md)', async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({ viewport: MOBILE });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await loginAs(page, USERS.ana);
|
||||||
|
await firstListId(page);
|
||||||
|
|
||||||
|
const wrapper = page.locator('[data-testid="reading-column"]').first();
|
||||||
|
await expect(wrapper).toBeVisible({ timeout: 10_000 });
|
||||||
|
const box = await wrapper.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
// Mobile viewport is 390 — wrapper should fill it (allow some padding).
|
||||||
|
expect(box!.width).toBeGreaterThan(380);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RW-03: shopping session stays full-width on desktop (immersive)', async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({ viewport: DESKTOP });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await loginAs(page, USERS.ana);
|
||||||
|
const id = await firstListId(page);
|
||||||
|
await page.goto(`/lists/${id}/session`);
|
||||||
|
// The session view is allowed to occupy the full viewport. The
|
||||||
|
// reading-column wrapper must NOT be present in this route.
|
||||||
|
await expect(page.locator('[data-testid="reading-column"]')).toHaveCount(0);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user