diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte
index c180e08..f3e832a 100644
--- a/apps/web/src/routes/(app)/+layout.svelte
+++ b/apps/web/src/routes/(app)/+layout.svelte
@@ -25,6 +25,13 @@
const detailRoutePattern = /^\/(lists\/[0-9a-f-]+(?:\/session)?|tasks\/[0-9a-f-]+|notes\/[0-9a-f-]+)\/?$/;
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.
// This runs after onAuthStateChange fires (authLoading = false), so there
// is no race with Supabase's async localStorage initialisation.
@@ -82,7 +89,17 @@
- {@render children()}
+ {#if isReadingRoute}
+
+
+ {@render children()}
+
+ {:else}
+ {@render children()}
+ {/if}
diff --git a/apps/web/tests/e2e/reading-width.test.ts b/apps/web/tests/e2e/reading-width.test.ts
new file mode 100644
index 0000000..83dacd8
--- /dev/null
+++ b/apps/web/tests/e2e/reading-width.test.ts
@@ -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 ( {
+ 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 ( {
+ 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();
+ });
+});