diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 3ac0425c..35379bd0 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1105,3 +1105,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-26 | `codex/phone-footer-glass` | `5ad4f8b28` | Final phone footer glass and scroll-stability review | APPROVE. Replaced the opaque phone footer/safe-area slab with localized translucent glass across shared docks and page-owned calculator/document composers; hidden chrome releases paint, pointer ownership, and reserve. Final review found and fixed calculator reserve under-budgeting plus Chromium scroll anchoring feedback, with insufficient-runway collapse refusal and sufficient-runway frame-monotonic hide/reveal. No P0-P3 findings remain. Highest residual risk is physical iOS/WebKit safe-area and momentum compositing beyond simulated Chromium. | `verify:cheap` PASS (393 files; 3518 passed / 2 skipped); focused Services/Calculators and calculator transition Chromium PASS; `verify:ui` production build 312/313 with the sole unchanged Answer short-runway geometry outlier immediately passing exact rerun 1/1; final diff review APPROVE; no provider-backed checks. | | 2026-07-26 | `codex/phone-footer-glass` | `7e4fd1a23` | Review-follow-up and CI hydration-race review | APPROVE. Scoped the expanded collapse runway only to combined in-flow header plus reserve owners, cleared the calculator dock focus latch after sheet teardown, and made mode-home UI assertions wait for one settled owner during production hydration. Both automated review threads were addressed and resolved. No P0-P3 findings remain; physical iOS/WebKit compositing remains the only material unverified surface. | `verify:cheap` PASS; focused scroll-hide/static contracts 25/25 PASS; focused calculator teardown/geometry Chromium 3/3 PASS; affected mode-home production Chromium 5/5 PASS; no provider-backed checks. | +| 2026-07-27 | `codex/phone-footer-paint` | `e1657a4a4e3e25b4a35a1dcf544b3efddb123c3f` | Protected-main release-readiness review of phone footer safe-area paint | APPROVE. The prior footer remained visually opaque because its normal/fallback terminal tints reached 72-90%, all blur masks painted through the physical edge, and hidden WebKit paint had no explicit terminal visibility state. The shared dock now has bounded localized tint, transparent gradient/mask terminals, an 88% pill, and delayed `visibility: hidden` synchronized to the 240ms exit; reduced-motion hides immediately. Therapy and calculator ownership prove the standard shared behavior. No P0-P3 finding remains. Highest residual risk is physical iOS momentum/compositor behavior beyond local WebKit emulation. | `verify:cheap` PASS (393 files; 3518 passed / 2 skipped); focused Therapy Chromium + WebKit PASS; calculator shared-dock Chromium + WebKit PASS; `verify:ui` PASS (314/314); `verify:pr-local` PASS including production build/client-secret scan and 36-case offline RAG fixtures; no live provider-backed checks. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index d1452b17..dd7a2379 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -19,7 +19,7 @@ This repo uses one shared search experience across the global shell, dashboard r 2. Keep the CSS token `--phone-dock-hidden-pad` aligned with `mobileComposerHiddenReserve`. 3. A visible fixed phone dock may include `var(--safe-area-bottom)` so the pill clears the home indicator. 4. A hidden phone dock must release the content-facing reserve to `0rem`; do not use `env(safe-area-inset-bottom)` or `var(--safe-area-bottom)` for hidden content padding. -5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. Keep the dock form transparent and use its absolute `.answer-footer-search-backdrop` child for a localized translucent gradient/blur through the safe area; it must move and fade with the dock, never become a viewport-fixed or opaque slab. +5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. Keep the dock form transparent and use its absolute `.answer-footer-search-backdrop` child for localized translucent gradient/blur around the pill. The gradient and every blur mask must return to fully transparent at the physical bottom edge. It must move and fade with the dock, then become `visibility: hidden` after the hide transition so WebKit cannot retain a safe-area compositor strip; it must never become a viewport-fixed or opaque slab. 6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide: when the phone top bar is hidden, `chrome-safe-area-top` and the controls both release to `0rem` so underlying content paints to the physical viewport edge. The visible phone header still owns `var(--safe-area-top)`; tablet/desktop sticky chrome keeps its pinned inset. Top-bar hide/reveal is cross-breakpoint; the search field stays pinned on tablets, while desktop search belongs to page flow and scrolls away naturally; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. diff --git a/src/app/globals.css b/src/app/globals.css index 127e9da5..a056a59a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -870,19 +870,39 @@ summary::-webkit-details-marker { z-index: 0; height: var(--footer-scrim-height); overflow: hidden; - /* The safe-area tint stays visibly translucent: this is localized glass - around the pill, never a second opaque footer surface. */ + /* Localized glass only: tint around the pill, then return to true zero at + the physical edge. A high terminal tint reads as an opaque safe-area bar + on light pages even though color-mix technically contains transparency. */ background: linear-gradient( 180deg, transparent 0%, - color-mix(in srgb, var(--background) 12%, transparent) 32%, - color-mix(in srgb, var(--background) 48%, transparent) 62%, - color-mix(in srgb, var(--background) 64%, transparent) 84%, - color-mix(in srgb, var(--background) 72%, transparent) 100% + color-mix(in srgb, var(--background) 8%, transparent) 30%, + color-mix(in srgb, var(--background) 28%, transparent) 58%, + color-mix(in srgb, var(--background) 18%, transparent) 78%, + transparent 100% ); backdrop-filter: blur(2px) saturate(130%); - mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 55%) 28%, black 52%, black 100%); - -webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 55%) 28%, black 52%, black 100%); + mask-image: linear-gradient( + 180deg, + transparent 0%, + rgb(0 0 0 / 32%) 26%, + black 50%, + rgb(0 0 0 / 45%) 78%, + transparent 100% + ); + -webkit-mask-image: linear-gradient( + 180deg, + transparent 0%, + rgb(0 0 0 / 32%) 26%, + black 50%, + rgb(0 0 0 / 45%) 78%, + transparent 100% + ); + opacity: 1; + visibility: visible; + transition: + opacity 180ms var(--ease-out-soft), + visibility 0s linear; } .answer-footer-search-dock .answer-footer-search-backdrop::before, @@ -894,22 +914,43 @@ summary::-webkit-details-marker { } .answer-footer-search-dock .answer-footer-search-backdrop::before { - backdrop-filter: blur(8px) saturate(130%); - mask-image: linear-gradient(180deg, transparent 0%, rgb(0 0 0 / 35%) 20%, black 45%, black 80%, transparent 100%); + backdrop-filter: blur(6px) saturate(125%); + mask-image: linear-gradient( + 180deg, + transparent 0%, + rgb(0 0 0 / 30%) 24%, + black 48%, + rgb(0 0 0 / 40%) 72%, + transparent 100% + ); -webkit-mask-image: linear-gradient( 180deg, transparent 0%, - rgb(0 0 0 / 35%) 20%, - black 45%, - black 80%, + rgb(0 0 0 / 30%) 24%, + black 48%, + rgb(0 0 0 / 40%) 72%, transparent 100% ); } .answer-footer-search-dock .answer-footer-search-backdrop::after { - backdrop-filter: blur(22px) saturate(140%); - mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%); - -webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%); + backdrop-filter: blur(12px) saturate(130%); + mask-image: linear-gradient( + 180deg, + transparent 0%, + transparent 46%, + black 64%, + rgb(0 0 0 / 35%) 80%, + transparent 100% + ); + -webkit-mask-image: linear-gradient( + 180deg, + transparent 0%, + transparent 46%, + black 64%, + rgb(0 0 0 / 35%) 80%, + transparent 100% + ); } @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @@ -923,9 +964,10 @@ summary::-webkit-details-marker { background: linear-gradient( 180deg, transparent 0%, - color-mix(in srgb, var(--background) 45%, transparent) 36%, - color-mix(in srgb, var(--background) 82%, transparent) 68%, - color-mix(in srgb, var(--background) 88%, transparent) 100% + color-mix(in srgb, var(--background) 16%, transparent) 34%, + color-mix(in srgb, var(--background) 38%, transparent) 62%, + color-mix(in srgb, var(--background) 20%, transparent) 80%, + transparent 100% ); } @@ -968,9 +1010,10 @@ summary::-webkit-details-marker { background: linear-gradient( 180deg, transparent 0%, - color-mix(in srgb, var(--background) 50%, transparent) 36%, - color-mix(in srgb, var(--background) 86%, transparent) 72%, - color-mix(in srgb, var(--background) 90%, transparent) 100% + color-mix(in srgb, var(--background) 24%, transparent) 34%, + color-mix(in srgb, var(--background) 52%, transparent) 62%, + color-mix(in srgb, var(--background) 32%, transparent) 80%, + transparent 100% ); } @@ -1867,6 +1910,16 @@ summary::-webkit-details-marker { pointer-events: none; } + /* WebKit can retain a backdrop-filter compositor layer after its translated + parent reaches opacity: 0. Fade the scrim with the dock, then make that + paint layer non-rendering at the end of the hide transition. Visibility + becomes visible immediately on reveal, so the reverse path stays smooth. */ + .answer-footer-search-dock[data-scroll-hidden="true"] .answer-footer-search-backdrop { + opacity: 0; + visibility: hidden; + transition-delay: 0ms, 240ms; + } + /* Compare addon adds in-flow chrome above the pill; clear a little extra so the Compare bar does not leave a 1px/subpixel peep on the viewport edge. */ .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-footer-addon="differentials-compare"][data-scroll-hidden="true"] { @@ -1897,7 +1950,7 @@ summary::-webkit-details-marker { .answer-footer-search-dock .answer-footer-search-pill { border-color: var(--border-strong); - background: color-mix(in srgb, var(--surface) 92%, transparent); + background: color-mix(in srgb, var(--surface) 88%, transparent); box-shadow: 0 -1px 0 color-mix(in srgb, var(--border) 60%, transparent), 0 1px 3px rgb(16 24 40 / 5%); @@ -1929,6 +1982,18 @@ summary::-webkit-details-marker { } } +@media (max-width: 639px) and (prefers-reduced-motion: reduce) { + .answer-footer-search-dock[data-scroll-hidden="true"] .answer-footer-search-backdrop { + transition-delay: 0ms; + } +} + +@media (max-width: 639px) { + html[data-motion="reduced"] .answer-footer-search-dock[data-scroll-hidden="true"] .answer-footer-search-backdrop { + transition-delay: 0ms; + } +} + /* Tablet rail range: the collapsed icon rail occupies the left edge from md until the full sidebar takes over at lg, so fixed composers must centre on the remaining content area instead of the viewport. Shells set diff --git a/tests/mobile-chrome-paint-contract.test.ts b/tests/mobile-chrome-paint-contract.test.ts index 77a1dd02..ca544107 100644 --- a/tests/mobile-chrome-paint-contract.test.ts +++ b/tests/mobile-chrome-paint-contract.test.ts @@ -31,11 +31,14 @@ describe("mobile chrome paint baseline", () => { /\.answer-footer-search-dock\.answer-footer-search-edge\[data-scroll-hidden="true"\][\s\S]*?opacity: 0;[\s\S]*?pointer-events: none/, ); expect(phoneStyles).toMatch( - /\.answer-footer-search-dock \.answer-footer-search-pill[\s\S]*?color-mix\(in srgb, var\(--surface\) 92%, transparent\)/, + /\.answer-footer-search-dock \.answer-footer-search-pill[\s\S]*?color-mix\(in srgb, var\(--surface\) 88%, transparent\)/, + ); + expect(phoneStyles).toMatch( + /\.answer-footer-search-dock\[data-scroll-hidden="true"\] \.answer-footer-search-backdrop[\s\S]*?opacity: 0;[\s\S]*?visibility: hidden;[\s\S]*?transition-delay: 0ms, 240ms/, ); }); - it("keeps every footer-glass fallback terminal translucent", () => { + it("keeps every footer-glass variant localized with a fully transparent physical edge", () => { const footerBackdropStart = globalStylesSource.indexOf( ".answer-footer-search-dock .answer-footer-search-backdrop {", ); @@ -45,12 +48,13 @@ describe("mobile chrome paint baseline", () => { const supportsFallbackSource = globalStylesSource.slice(supportsFallbackStart, reducedTransparencyStart); const reducedTransparencySource = globalStylesSource.slice(reducedTransparencyStart); - expect(footerBackdropSource).toContain("color-mix(in srgb, var(--background) 72%, transparent) 100%"); - expect(supportsFallbackSource).toContain("color-mix(in srgb, var(--background) 88%, transparent) 100%"); - expect(reducedTransparencySource).toContain("color-mix(in srgb, var(--background) 90%, transparent) 100%"); - expect(`${footerBackdropSource}\n${supportsFallbackSource}\n${reducedTransparencySource}`).not.toContain( - "var(--background) 100%", - ); + expect(footerBackdropSource).toContain("color-mix(in srgb, var(--background) 28%, transparent) 58%"); + expect(supportsFallbackSource).toContain("color-mix(in srgb, var(--background) 38%, transparent) 62%"); + expect(reducedTransparencySource).toContain("color-mix(in srgb, var(--background) 52%, transparent) 62%"); + for (const variant of [footerBackdropSource, supportsFallbackSource, reducedTransparencySource]) { + expect(variant).toContain("transparent 100%"); + expect(variant).not.toMatch(/color-mix\([^\n]+\) 100%/); + } }); it("registers the calculator page reserve with the shared hide and transition contracts", () => { diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 536f93df..3688937d 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -200,14 +200,14 @@ test.beforeEach(async ({ page }) => { await blockExternalRequests(page); }); -test("phone chrome keeps an opaque header and a soft-glass Services footer that fully releases when hidden", async ({ +test("phone chrome keeps an opaque header and a light edge-to-edge Therapy footer that fully releases when hidden", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.setViewportSize(phoneViewport); - // A submitted Services search is a stable GlobalSearchShell result surface. - // Its exaggerated bottom inset catches paint that only leaks through a notch. - await gotoPhoneSurface(page, "/services?q=clinic&run=1&focus=1", 112); + // The user's failing surface is a submitted Therapy search. Its exaggerated + // bottom inset catches paint that only leaks through a notched-phone safe area. + await gotoPhoneSurface(page, "/therapy-compass/search?q=CBT&run=1", 112); await expect(page.locator("form.answer-footer-search-dock")).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId("global-search-input")).not.toBeFocused({ timeout: 5_000 }); @@ -218,6 +218,20 @@ test("phone chrome keeps an opaque header and a soft-glass Services footer that const dockBackdrop = dock?.querySelector(".answer-footer-search-backdrop"); const pill = dock?.querySelector(".answer-footer-search-pill"); const dockRect = dock?.getBoundingClientRect(); + const backdropPaint = dockBackdrop ? getComputedStyle(dockBackdrop).backgroundImage : ""; + const readMaskImage = (element: Element, pseudo?: string) => { + const style = getComputedStyle(element, pseudo); + return style.maskImage !== "none" ? style.maskImage : style.getPropertyValue("-webkit-mask-image"); + }; + const backdropMask = dockBackdrop ? readMaskImage(dockBackdrop) : ""; + const backdropBeforeMask = dockBackdrop ? readMaskImage(dockBackdrop, "::before") : ""; + const backdropAfterMask = dockBackdrop ? readMaskImage(dockBackdrop, "::after") : ""; + const paintAlphaValues = [ + ...[...backdropPaint.matchAll(/\/\s*(0(?:\.\d+)?|1(?:\.0+)?)\)/g)].map((match) => Number(match[1])), + ...[...backdropPaint.matchAll(/rgba\([^)]*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\)/g)].map((match) => Number(match[1])), + ]; + const hasTransparentTerminal = (paint: string) => + /rgba\(0,\s*0,\s*0,\s*0\)(?: 100%)?\)$/.test(paint) || /transparent(?: 100%)?\)$/.test(paint); return { headerBackground: header ? getComputedStyle(header).backgroundColor : "", headerBackdropFilter: header ? getComputedStyle(header).backdropFilter : "", @@ -225,12 +239,17 @@ test("phone chrome keeps an opaque header and a soft-glass Services footer that dockBackdropDisplay: dockBackdrop ? getComputedStyle(dockBackdrop).display : "missing", dockBackground: dock ? getComputedStyle(dock).backgroundColor : "", dockBackdropPosition: dockBackdrop ? getComputedStyle(dockBackdrop).position : "missing", - dockBackdropPaint: dockBackdrop ? getComputedStyle(dockBackdrop).backgroundImage : "", + dockBackdropPaint: backdropPaint, dockBackdropFilter: dockBackdrop ? getComputedStyle(dockBackdrop).backdropFilter : "", dockBackdropPointerEvents: dockBackdrop ? getComputedStyle(dockBackdrop).pointerEvents : "", dockBackdropHasTranslucentStop: dockBackdrop ? /transparent|\/\s*(?:0?\.)\d+/.test(getComputedStyle(dockBackdrop).backgroundImage) : false, + dockBackdropMaxAlpha: paintAlphaValues.length > 0 ? Math.max(...paintAlphaValues) : 1, + dockBackdropTerminalTransparent: hasTransparentTerminal(backdropPaint), + dockBackdropMaskTerminalTransparent: hasTransparentTerminal(backdropMask), + dockBackdropBeforeMaskTerminalTransparent: hasTransparentTerminal(backdropBeforeMask), + dockBackdropAfterMaskTerminalTransparent: hasTransparentTerminal(backdropAfterMask), dockLeft: dockRect?.left ?? -1, dockRight: dockRect?.right ?? -1, dockBottom: dockRect?.bottom ?? -1, @@ -246,6 +265,11 @@ test("phone chrome keeps an opaque header and a soft-glass Services footer that expect(visible.dockBackdropPosition).toBe("absolute"); expect(visible.dockBackdropPaint).toContain("gradient"); expect(visible.dockBackdropHasTranslucentStop).toBe(true); + expect(visible.dockBackdropMaxAlpha).toBeLessThanOrEqual(0.52); + expect(visible.dockBackdropTerminalTransparent).toBe(true); + expect(visible.dockBackdropMaskTerminalTransparent).toBe(true); + expect(visible.dockBackdropBeforeMaskTerminalTransparent).toBe(true); + expect(visible.dockBackdropAfterMaskTerminalTransparent).toBe(true); // The runner may emulate reduced transparency; the fallback deliberately // removes blur but keeps this translucent gradient instead of a solid slab. expect(["none", "blur(2px) saturate(1.3)"]).toContain(visible.dockBackdropFilter); @@ -253,7 +277,7 @@ test("phone chrome keeps an opaque header and a soft-glass Services footer that expect(visible.dockLeft).toBeCloseTo(0, 0); expect(visible.dockRight).toBeCloseTo(phoneViewport.width, 0); expect(visible.dockBottom).toBeCloseTo(phoneViewport.height, 0); - expect(visible.pillBackground).toMatch(/(?:^rgba\([^)]+,\s*0\.92\)|\/ 0\.92\))/); + expect(visible.pillBackground).toMatch(/(?:^rgba\([^)]+,\s*0\.88\)|\/ 0\.88\))/); const geometry = await readGeometry(page); await dragScrollBy(page, Math.min(geometry.maxOffset, 500), 24); @@ -271,6 +295,8 @@ test("phone chrome keeps an opaque header and a soft-glass Services footer that dockTop: dock?.getBoundingClientRect().top ?? -1, dockOpacity: dock ? getComputedStyle(dock).opacity : "", backdropTop: backdrop?.getBoundingClientRect().top ?? -1, + backdropOpacity: backdrop ? getComputedStyle(backdrop).opacity : "", + backdropVisibility: backdrop ? getComputedStyle(backdrop).visibility : "", reserve: main ? getComputedStyle(main).getPropertyValue("--mobile-composer-reserve").trim() : "", }; }); @@ -278,6 +304,8 @@ test("phone chrome keeps an opaque header and a soft-glass Services footer that expect(hidden.dockTop).toBeGreaterThanOrEqual(phoneViewport.height - 1); expect(hidden.dockOpacity).toBe("0"); expect(hidden.backdropTop).toBeGreaterThanOrEqual(phoneViewport.height - 1); + expect(hidden.backdropOpacity).toBe("0"); + expect(hidden.backdropVisibility).toBe("hidden"); expect(hidden.reserve).toBe("0rem"); });