# FN-398 — Subscription Page Mobile Remainder (P1) ## Task - **ID:** FN-398 - **Priority:** P1 — bottom-of-funnel, revenue-critical - **Single source file:** `apps/web/src/routes/dashboard/subscription/index.tsx` - **Single test folder:** `apps/web/src/routes/dashboard/subscription/__tests__/` - Existing files to extend: `cls-regression.test.tsx`, `grid-parity.test.tsx` - **Dependencies:** FN-395 (P1-A shortlist; informational only — no code dependency). This task fixes three specific defects (one CLS, one dialog overflow, one safe-area padding) on the subscription / plan-selection page. All requirements below are stated inline; you do **not** need to read any external audit document to execute. --- ## In-scope defects (verbatim requirements — inline, authoritative) ### Defect A — Skeleton grid CLS parity (audit §6) **Symptom:** When the subscription page is loading, the skeleton placeholders use a different grid than the real plan-cards grid. When the real data arrives, the layout jumps (Cumulative Layout Shift). **Real grid (already correct in source, currently `index.tsx` line ~989):** ```tsx
{plans.map((plan) => /* plan card */)}
``` - `plans` is the exported array of plan definitions in `index.tsx`. `plans.length === 4`. **Required skeleton (final state) at the `isLoading` early-return (currently `index.tsx` ~503–512):** ```tsx
{plans.map((_, i) => ( ))}
``` **Mechanical acceptance:** - Skeleton grid `className` **string-equals** `"grid gap-4 sm:grid-cols-2 lg:grid-cols-4"`. - Skeleton grid renders exactly `plans.length` (i.e. 4) children, and the count is derived from `plans` — **not** a literal `[0, 1, 2, 3]`. - Each child has `className="h-48 w-full rounded-2xl"`. - The real grid `className` is unchanged. **Why `plans.map` and not a literal:** future plan additions (e.g. a 5th tier) must keep skeleton/real-grid item counts in sync automatically. Hard-coded `[0,1,2,3]` would silently drift. --- ### Defect B — Dialog overflow on mobile (audit §9) **Symptom:** On screens ≤ 375px wide, the downgrade-offer dialog and the cancel-confirm dialog (rendered from `index.tsx`) overflow horizontally and/or push their footer buttons off-screen vertically when content is tall (e.g. a long brand list inside the downgrade dialog). **Affected elements:** every `` JSX node rendered from `apps/web/src/routes/dashboard/subscription/index.tsx`. As of baseline there is at least one at `index.tsx` line ~1669; there is also a cancel-confirm dialog rendered later in the same file. **Required final state for every `` in this file:** ```tsx
{/* body content that was previously a direct child of DialogContent */}
``` **Mechanical acceptance:** 1. Every `` in `index.tsx` has `max-w-[calc(100vw-2rem)]` and `sm:max-w-md` in its `className`. 2. Every `` has `max-h-[calc(100dvh-2rem)]` and `overflow-hidden flex flex-col`. 3. Between `` and `` there is exactly one wrapper `
` with `overflow-y-auto` containing the dialog body. (If the existing layout already has a body wrapper, add `overflow-y-auto` to it rather than nesting another `
`.) 4. Existing dialog body content, props, handlers, and copy are unchanged. **Why `100dvh`:** dynamic viewport height accounts for mobile browser chrome (URL bar collapse). The arbitrary value `max-h-[calc(100dvh-2rem)]` already works in Tailwind ≥ 3.4, which this repo uses. --- ### Defect C — Sticky checkout bar safe-area padding (audit §10) **Symptom:** The fixed-bottom sticky checkout bar overlaps the iOS home indicator on notched devices. **Affected element:** the outer `
` of the sticky checkout bar (`index.tsx` ~line 1206). It currently looks like: ```tsx
``` **Required final state:** the outermost fixed container of the sticky checkout bar contains the Tailwind arbitrary-value token `pb-[env(safe-area-inset-bottom,0px)]`. Inner spacing classes (`py-3`, `px-4`, etc.) must remain — the safe-area padding is **additive**, not a replacement. **Mechanical acceptance:** - `rg -n "fixed inset-x-0 bottom-0" apps/web/src/routes/dashboard/subscription/index.tsx` → the matched element's `className` contains `pb-[env(safe-area-inset-bottom,0px)]`. - No inner content wrapper has duplicate `env(safe-area-inset-bottom)` padding. --- ## Pre-implementation step (mandatory, no-op-friendly) The audit is dated 2026-05-13. Memory note `2026-05-13.md`: > *"FN-319 (CLS regression) verified as no-op: Skeleton grid at line 527 already matches real grid at line 1076. The audit doc post-p0-subscription-audit.md section 6 is stale — this was already fixed in FN-199."* Therefore, before editing **any** of A/B/C: 1. `cd` to repo root. Locate the canonical subscription page: ``` rg -l "checkout_started" apps/web/src/routes ``` If the path differs from `apps/web/src/routes/dashboard/subscription/index.tsx`, update all references in this prompt to the canonical path before continuing. 2. For each of Defect A / B / C, open the file and **verify the current source against the "Required final state" snippets above**: - If **already correct** → do not edit the source. Skip to the Testing section and add/strengthen the regression test for that invariant. - If **partially correct** (e.g. dialog has `max-w-[calc(100vw-2rem)]` but no `max-h` or no scroll wrapper) → implement only the missing piece. - If **absent** → implement the full required final state. 3. Record findings in the PR description as a 3-row table: `Defect | Pre-state | Action taken`. --- ## Implementation steps Execute in order. Each step is a single concrete change. ### Step 1 — Skeleton grid (Defect A) - Open `apps/web/src/routes/dashboard/subscription/index.tsx`. - Find the `if (isLoading) { return (…) }` block (≈ line 503–512). - Replace the skeleton grid `
` and its children with the required final state (Defect A above), using `plans.map((_, i) => …)` for the children. - If `plans` is not in scope at the early-return, hoist its export to top-level of the file (it already is exported per the regression test `grid-parity.test.tsx` which does `import { PlanGrid, plans } from "@/routes/dashboard/subscription/index"`). Do **not** change any other code. ### Step 2 — Dialog overflow (Defect B) - Find every `` in the file. As of baseline there are 2 (downgrade offer ≈ line 1669 and cancel confirm later in the file). - For each, apply the required final state above: - Edit the `className` of `` to: `max-w-[calc(100vw-2rem)] sm:max-w-md max-h-[calc(100dvh-2rem)] overflow-hidden flex flex-col`. - Wrap the JSX between `` and `` in `
`. If only one of those siblings exists, wrap whatever sits between the header (or top of `DialogContent`) and the footer (or bottom of `DialogContent`). - Do not change dialog body content, copy, props, handlers, or the cancel/confirm logic. ### Step 3 — Sticky bar safe-area (Defect C) - Find the sticky checkout bar (`
` (visually confirm or use `rg -A 50 "` of this PR's single commit. - After merge, verify the FN-348 post-deploy verification harness (`qa/post-deploy/fn348-verify.mjs`) still passes; in particular the P0-8 check ("Bundle accessible — FN-345 CLS fix deployed"). --- ## Deliverables 1. Updated `apps/web/src/routes/dashboard/subscription/index.tsx` — only the regions covering Defects A/B/C, and only where pre-state did not already satisfy the required final state. 2. Extended tests in `apps/web/src/routes/dashboard/subscription/__tests__/cls-regression.test.tsx` and `__tests__/grid-parity.test.tsx`. 3. PR description containing: - The 3-row pre-implementation table (Defect | Pre-state | Action taken). - Per-test fail→pass evidence lines (T-A1, T-B1, T-B2, T-C1). - Screenshot or DOM-snippet evidence for the iPhone-SE manual check (or explicit documentation of any environment-skipped check).