diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f548d6cf1..f80044a4d 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -636,3 +636,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-19 | PR #938 / `cursor/fix-differentials-results-top-d760` | efe472fac3489473e2465da2d92980508c20fd81 | merge main (#934 favourites) + #941 sync | Merged favourites guest-gate (#934) then Specifiers results UI (#941). Product conflicts none: kept `contentAlign="startOnPhone"` on Specifiers home while taking #941 results chrome. `ui-overlap` gotoHome wait conflict resolved with settled single-header short-circuit. No new P0–P2. Residual: human approving review required for squash auto-merge. | Local: align Vitest; favourites+app-modes+tools 42 tests; Chromium ui-overlap+ui-tools 95/95; verify:cheap unit stage green. Hosted CI green on pre-#941 tip; re-running after this merge. | | 2026-07-20 | cursor/documents-search-header-3eab / PR #936 | bdc333a9fa7e420e2beb2f146c6f271196869cb5 (squash on main) | post-merge closeout + branch-cleanup | Squash-merged to main. Product proof on main: `DocumentResultsControls`, identity-first documents results chrome, governance notice under controls, Also-in-library strip removed from documents results path. Remote feature ref already deleted by protected-main workflow; local tip `fd559a07` retained only merge/review commits with no unique product patch vs main. | Hosted pre-merge and post-merge required checks green (Static/Unit/Build/Production UI/PR required). Squash content proof via main tree symbols; remote `ls-remote` empty after prune; local branch deleted after this ledger row. No OpenAI/Supabase provider calls. | | 2026-07-20 | cursor/fix-ui-overlap-duplicate-header-3eab / PR #944 | 08181b8b45cf23d53ff7fb8682fb5f83f4417fb0 (squash on main) | post-merge closeout + branch-cleanup | Squash-merged to main. Product proof: `gotoHome` pins `/?mode=answer` and waits for exactly one `header#search` before overlap measurement, closing the landing-preference dual-banner flake that failed Production UI then PR required on #936. Remote feature ref already deleted; local tip `418de1b9` is merge-only after squash. | Local before merge: verify:pr-local (2961 unit) + Chromium ui-overlap 12/12. Hosted exact-head and post-merge: Production UI, PR required, Static, Unit green (Build skipped for test-only). No OpenAI/Supabase provider calls. | +| 2026-07-19 | cursor/mobile-header-new-chat-inset-66c0 (PR #940) | 84eb0b6c3782e27fbbd1ec79b87b10327802ec1e | final mobile header new-chat edge inset review + merge readiness | No high-confidence P0-P1. Root cause: unlayered `@media (max-width:639px)` zeroed `.edge-glass-header` padding and beat `@layer components`. Fixed with tokenized `--header-edge-pad: 1rem` shared by layered base + unlayered phone guard; Playwright symmetry checks at 360/390; source contract blocks a `max(0px, safe-area)` regression. Merged latest `origin/main` (including #933/#942/#943) while keeping the header-edge-pad token. Residual: headless Chromium cannot exercise asymmetric safe-area `max()`; DocumentViewer gains the same pad but is outside the symmetry test. | Local geometry probe 360/390/640 = 16px/16px symmetric; CSS contract Vitest 5/5; `ui-overlap` Chromium 14/14; prior `verify:ui` 242/242 on the functional head; `verify:cheap` unit suite hit only the known container-only `pdf-extraction-budget` python ENOENT artifact (also fails on clean main / hosted-CI-green elsewhere). PR marked ready; squash auto-merge enabled. No OpenAI/live Supabase/provider calls. | diff --git a/src/app/globals.css b/src/app/globals.css index eb75cb666..e3f4ac2cf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -267,6 +267,10 @@ --safe-area-right: env(safe-area-inset-right, 0px); --safe-area-bottom: env(safe-area-inset-bottom, 0px); --safe-area-left: env(safe-area-inset-left, 0px); + /* Phone/sm header chrome inset; matches mode-home px-4. Referenced by both + the layered .edge-glass-header base and the unlayered max-width:639px + guard so the values cannot drift apart. lg overrides to 1.5rem. */ + --header-edge-pad: 1rem; /* Keep in sync with mobile-composer-reserve.ts (phone dock clearance). */ --phone-dock-hidden-pad: 0.75rem; --phone-dock-differentials-compare-clearance: 12.5rem; @@ -556,8 +560,12 @@ summary::-webkit-details-marker { @layer components { .edge-glass-header { isolation: isolate; - padding-left: max(0.75rem, var(--safe-area-left)); - padding-right: max(0.75rem, var(--safe-area-right)); + /* Uses --header-edge-pad (1rem) so phone icons align with mode-home px-4. + Keep safe-area max() so notched devices never under-inset. The unlayered + max-width:639px block below must also use this token — unlayered rules + beat @layer components, so a literal there would reintroduce drift. */ + padding-left: max(var(--header-edge-pad), var(--safe-area-left)); + padding-right: max(var(--header-edge-pad), var(--safe-area-right)); /* Translucent glass bar: the header's backdrop-blur utilities frost the content scrolling beneath it (they were inert over the old opaque var(--surface)). The .edge-glass-header-backdrop scrim supersedes the @@ -596,15 +604,9 @@ summary::-webkit-details-marker { box-shadow: none; } - /* Responsive header padding (moved in from the shared @media blocks below so - the whole .edge-glass-header class is layered together). */ - @media (min-width: 640px) { - .edge-glass-header { - padding-left: max(1rem, var(--safe-area-left)); - padding-right: max(1rem, var(--safe-area-right)); - } - } - + /* Desktop header padding (moved in from the shared @media blocks below so + the whole .edge-glass-header class is layered together). Base/sm share + --header-edge-pad. */ @media (min-width: 1024px) { .edge-glass-header { padding-left: max(1.5rem, var(--safe-area-left)); @@ -1771,9 +1773,13 @@ summary::-webkit-details-marker { } @media (max-width: 639px) { + /* Unlayered on purpose: a prior max(0px, safe-area) here beat the layered + inset and pinned new-chat to the phone bezel. Keep the tokenized phone + value so unlayered media cannot reintroduce a 0px override or drift from + the @layer components base. */ .edge-glass-header { - padding-left: max(0px, var(--safe-area-left)); - padding-right: max(0px, var(--safe-area-right)); + padding-left: max(var(--header-edge-pad), var(--safe-area-left)); + padding-right: max(var(--header-edge-pad), var(--safe-area-right)); } .dashboard-composer-edge.answer-footer-search-edge:not(.answer-footer-search-dock) { diff --git a/tests/ui-overlap.spec.ts b/tests/ui-overlap.spec.ts index 46a19a4c8..46a323e25 100644 --- a/tests/ui-overlap.spec.ts +++ b/tests/ui-overlap.spec.ts @@ -128,6 +128,39 @@ test.describe("Header element overlap coverage", () => { }); } + for (const viewport of [ + { name: "narrow-phone", width: 360, height: 780 }, + { name: "phone", width: 390, height: 820 }, + ] as const) { + test(`header menu and new-chat insets stay symmetric on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await mockDemoDashboard(page); + await gotoHome(page); + + const menu = page.getByRole("button", { name: "Open Clinical Guide menu" }); + const newChat = page.getByRole("button", { name: "Start a new chat" }); + await expect(menu).toBeVisible(); + await expect(newChat).toBeVisible(); + + // Headless Chromium reports env(safe-area-inset-*) as 0, so this asserts + // the --header-edge-pad (1rem) chrome inset — not notch asymmetry. + const menuBox = await menu.boundingBox(); + const newChatBox = await newChat.boundingBox(); + expect(menuBox, "menu control must have geometry").not.toBeNull(); + expect(newChatBox, "new-chat control must have geometry").not.toBeNull(); + + const leftInset = menuBox!.x; + const rightInset = viewport.width - (newChatBox!.x + newChatBox!.width); + // 1rem header pad (~16px) with 2px subpixel tolerance. + expect(leftInset, "left menu inset should be at least ~1rem").toBeGreaterThanOrEqual(14); + expect(rightInset, "right new-chat inset should be at least ~1rem").toBeGreaterThanOrEqual(14); + expect( + Math.abs(leftInset - rightInset), + `left/right insets should match (left=${leftInset}, right=${rightInset})`, + ).toBeLessThanOrEqual(2); + }); + } + for (const viewport of [ { name: "mobile", width: 390, height: 820 }, { name: "desktop", width: 1280, height: 900 }, diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index d6ad76ed5..8380d0be1 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -39,4 +39,17 @@ describe("overlay and global CSS contracts", () => { expect(baseBlock).not.toContain("border-color:"); expect(globalStylesSource).toMatch(/\.answer-footer-search-pill:focus-within\s*\{[\s\S]*?border-color:/); }); + + it("keeps phone header edge padding tokenized and never zeroed by unlayered media", () => { + // --header-edge-pad is the single phone/sm inset shared by the layered + // .edge-glass-header base and the unlayered max-width:639px guard. A bare + // max(0px, safe-area) override previously pinned new-chat to the bezel. + expect(occurrenceCount(globalStylesSource, "--header-edge-pad:")).toBe(1); + expect(globalStylesSource).toMatch(/--header-edge-pad:\s*1rem;/); + expect(occurrenceCount(globalStylesSource, "max(var(--header-edge-pad), var(--safe-area-left))")).toBe(2); + expect(occurrenceCount(globalStylesSource, "max(var(--header-edge-pad), var(--safe-area-right))")).toBe(2); + expect(globalStylesSource).not.toMatch( + /\.edge-glass-header\s*\{[^}]*padding-left:\s*max\(0px,\s*var\(--safe-area-left\)\)/s, + ); + }); });