From 41d00497772a36364954a52c2b6ebf49f04d5c11 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 02:27:31 +0800
Subject: [PATCH 01/16] feat(pwa): polish responsive install lifecycle
---
docs/pwa.md | 20 +-
src/app/globals.css | 306 +++++++++++++++++++++++++++----
src/components/pwa-lifecycle.tsx | 205 ++++++++++++---------
tests/pwa-lifecycle.dom.test.tsx | 40 ++--
tests/ui-pwa.spec.ts | 95 ++++++++++
5 files changed, 521 insertions(+), 145 deletions(-)
diff --git a/docs/pwa.md b/docs/pwa.md
index 2516055477..b5188936c6 100644
--- a/docs/pwa.md
+++ b/docs/pwa.md
@@ -52,9 +52,13 @@ registered service worker. Localhost is the browser's secure-context development
is shown only when the browser emits `beforeinstallprompt`; browsers that do not expose that event retain their own
install/Add to Home Screen flow.
-The install card is not shown in standalone mode. Choosing **Not now**, or dismissing the browser prompt, suppresses
-the custom prompt for 30 days using `clinical-kb-pwa-install-dismissed-at` in localStorage. `appinstalled` clears that
-value. Storage failures are treated as non-fatal progressive-enhancement failures.
+The install card is not shown in standalone mode. It stays non-blocking and lower-right on wide screens; on phones it
+becomes a compact sheet positioned clear of the current composer owner (above a bottom dock or below an in-flow home
+composer) and the safe area. The benefit list is deliberately limited to quick access, app-like launch, and the
+familiar Clinical KB workspace. It does not imply offline clinical access.
+Choosing **Not now**, using **Dismiss**, or dismissing the browser prompt suppresses the custom prompt for 30 days
+using `clinical-kb-pwa-install-dismissed-at` in localStorage. `appinstalled` clears that value. Storage failures are
+treated as non-fatal progressive-enhancement failures.
iOS and iPadOS never emit `beforeinstallprompt`, so outside standalone mode those platforms get a one-time manual
hint instead (Safari: Share, then Add to Home Screen). **Not now** suppresses it for 30 days via
@@ -155,11 +159,11 @@ clinical actions remain network-dependent.
### Update UX
-- An already-waiting worker, or an installing worker that reaches `installed` while a controller exists, shows **An
- update is ready**.
+- An already-waiting worker, or an installing worker that reaches `installed` while a controller exists, shows
+ **Update available**.
- When the page becomes visible or connectivity returns, the registration may check for an update. App-triggered
checks are throttled to at most once per hour; there is no background polling timer.
-- **Refresh now** sends `SKIP_WAITING`. The page reloads once after `controllerchange`, so it cannot loop.
+- **Reload** sends `SKIP_WAITING`. The page reloads once after `controllerchange`, so it cannot loop.
- **Later** hides the update for the current lifecycle instance. A later page load can surface the waiting update
again.
- If another tab activates the update, an already-controlled tab receives `controllerchange` and offers its own
@@ -232,7 +236,7 @@ resource problem by weakening the page CSP or adding a provider origin to the wo
## Accessibility, theme, and device fit
-- Lifecycle notices use a polite live region, labelled status/region containers, real buttons, 44 px minimum target
+- Lifecycle notices use a polite live region, labelled status/region containers, real buttons, 48 px minimum target
height, and visible focus treatment.
- The notice stack does not take page scroll ownership. Its cards restore pointer events while the container remains
transparent to unrelated interaction.
@@ -299,7 +303,7 @@ explicitly authorized.
document must appear and must not reveal prior content. Restore connectivity and verify the restored notice.
- [ ] Confirm an offline API/document/media request is not answered from a PWA cache.
- [ ] Deploy a worker with a new cache version in a staging environment. Verify the update waits, **Later** does not
- force a refresh, **Refresh now** activates it, every older tab is offered a refresh, and only the two newest
+ force a refresh, **Reload** activates it, every older tab is offered a refresh, and only the two newest
prior static caches remain for lazy-chunk compatibility.
- [ ] Check keyboard focus, screen-reader announcements, forced colours, light/dark theme, portrait/landscape, display
cutouts, and the standalone home-indicator area at phone and desktop breakpoints.
diff --git a/src/app/globals.css b/src/app/globals.css
index 23dfd37aae..3a7c34a83e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2618,48 +2618,255 @@ summary::-webkit-details-marker {
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
}
-/* Global PWA notices sit above the bottom composer and home indicator without
- changing page scroll ownership. The lifecycle component renders only when an
- offline, restored, installable, or waiting-update state needs attention. */
-/* PWA notices: bottom sheet-style on phones (thumb zone, above the fixed
- composer), floating bottom-right card on medium screens, and top-right
- toast under the header on desktop — beside the browser's own install
- affordance and clear of the composer. */
+/* Global PWA notices sit above the phone composer and home indicator without
+ taking page scroll ownership. Install is a compact surface, not a modal:
+ central search stays usable and the wide-screen card remains lower-right. */
.pwa-notice-stack {
- --pwa-notice-bottom-gap: calc(max(0.75rem, var(--safe-area-bottom)) + 5.5rem);
+ --pwa-notice-bottom-gap: calc(max(0.75rem, var(--safe-area-bottom)) + 5rem);
position: fixed;
z-index: 95;
- left: max(0.75rem, var(--safe-area-left));
- right: max(0.75rem, var(--safe-area-right));
+ left: max(0.5rem, var(--safe-area-left));
+ right: max(0.5rem, var(--safe-area-right));
bottom: var(--pwa-notice-bottom-gap);
display: grid;
gap: 0.75rem;
pointer-events: none;
}
-/* Install notices present as a native-feeling bottom sheet on phones: full
- bleed to the viewport edges (escaping the stack gutter), flush to the
- bottom over the composer zone while visible, top corners only, and content
- padded clear of the home indicator. Matches the drag-grip language of the
- phone Mode-menu sheet (src/components/ui/sheet.tsx). */
+/* Mode homes keep their phone composer in the hero rather than in the bottom
+ dock. In that ownership model the install sheet belongs at the safe bottom
+ edge; keeping the dock reserve would lift it over the in-flow composer. */
@media (max-width: 639.98px) {
- .pwa-install-sheet {
- margin-left: calc(-1 * max(0.75rem, var(--safe-area-left)));
- margin-right: calc(-1 * max(0.75rem, var(--safe-area-right)));
- margin-bottom: calc(-1 * var(--pwa-notice-bottom-gap));
- border-radius: 1.25rem 1.25rem 0 0;
- border-bottom: 0;
- padding-left: max(1.25rem, var(--safe-area-left));
- padding-right: max(1.25rem, var(--safe-area-right));
- padding-bottom: calc(1.25rem + var(--safe-area-bottom));
+ body:has([data-phone-footer-owner="hero"]) .pwa-notice-stack,
+ body:has(form[data-composer-placement="desktop-home"]) .pwa-notice-stack {
+ --pwa-notice-bottom-gap: max(0.75rem, var(--safe-area-bottom));
+ }
+
+ body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] > div > section {
+ justify-content: flex-start;
+ padding-top: 0.5rem;
+ }
+}
+
+/* A 320px mode home cannot show the full install proposition and its complete
+ centred empty-state introduction without covering the search composer. While
+ the transient install sheet is present, keep the real composer visible in
+ the clear space under the app header and omit only the decorative intro. */
+@media (max-width: 359.98px) {
+ body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] [data-testid="shared-home-empty-state"] {
+ gap: 0;
+ }
+
+ body:has(.pwa-install-sheet)
+ #main-content[data-phone-footer-owner="hero"]
+ [data-testid="shared-home-empty-state"]
+ > section[aria-labelledby="shared-home-empty-state-title"] {
+ display: none;
}
}
.pwa-notice-card {
+ overflow: hidden;
+ border: 1px solid var(--border-lux);
+ border-radius: 1rem;
+ background: var(--surface-lux);
+ box-shadow:
+ 0 18px 48px color-mix(in srgb, var(--text-heading) 11%, transparent),
+ 0 3px 10px color-mix(in srgb, var(--text-heading) 6%, transparent);
animation: pwa-notice-in 280ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
}
+.pwa-lifecycle-card {
+ padding: 1rem;
+}
+
+.pwa-connection-restored {
+ justify-self: end;
+ border-color: color-mix(in srgb, var(--success) 24%, var(--border-lux));
+ border-radius: 9999px;
+ background: color-mix(in srgb, var(--success-soft) 88%, var(--surface-lux));
+ padding: 0.375rem 0.75rem 0.375rem 0.5rem;
+ box-shadow: var(--e1);
+}
+
+.pwa-connection-restored > div > span {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 9999px;
+}
+
+.pwa-install-sheet {
+ max-height: calc(100dvh - var(--pwa-notice-bottom-gap) - 4.25rem);
+ overscroll-behavior: contain;
+}
+
+.pwa-install-grip {
+ display: flex;
+ height: 1.125rem;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux));
+}
+
+.pwa-install-grip span {
+ width: 2.25rem;
+ height: 0.1875rem;
+ border-radius: 9999px;
+ background: color-mix(in srgb, var(--text-muted) 48%, transparent);
+}
+
+.pwa-install-header {
+ display: flex;
+ min-height: 4rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ border-bottom: 1px solid color-mix(in srgb, var(--border-lux) 82%, transparent);
+ background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux));
+ padding: 0.5rem 0.625rem 0.5rem 1rem;
+}
+
+.pwa-install-mark {
+ border-radius: 0.625rem;
+ filter: drop-shadow(0 2px 3px color-mix(in srgb, var(--clinical-accent) 18%, transparent));
+}
+
+.pwa-install-dismiss {
+ display: inline-flex;
+ min-height: var(--spacing-tap);
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ border-radius: 0.625rem;
+ padding: 0 0.5rem;
+ color: var(--text-muted);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ line-height: 1;
+ transition:
+ color 150ms ease,
+ background-color 150ms ease;
+}
+
+.pwa-install-dismiss:hover {
+ background: color-mix(in srgb, var(--surface-lux) 72%, transparent);
+ color: var(--text-heading);
+}
+
+.pwa-install-dismiss:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: -2px;
+}
+
+.pwa-install-body {
+ background: var(--surface-lux);
+ padding: 0.875rem 1rem 1rem;
+}
+
+.pwa-install-tagline {
+ color: var(--text-heading);
+ font-size: 0.875rem;
+ font-weight: 700;
+ line-height: 1.35rem;
+}
+
+.pwa-install-copy {
+ margin-top: 0.375rem;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+}
+
+.pwa-install-support {
+ margin-top: 0.375rem;
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.125rem;
+}
+
+.pwa-install-benefits,
+.pwa-install-steps {
+ margin-top: 0.75rem;
+ overflow: hidden;
+ border: 1px solid var(--border-lux);
+ border-radius: 0.625rem;
+ background: color-mix(in srgb, var(--clinical-accent-soft) 20%, var(--surface-lux));
+}
+
+.pwa-install-benefits li,
+.pwa-install-steps li {
+ display: flex;
+ min-height: 2.5rem;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.4375rem 0.75rem;
+ color: var(--text);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.125rem;
+}
+
+.pwa-install-benefits li + li,
+.pwa-install-steps li + li {
+ border-top: 1px solid color-mix(in srgb, var(--border-lux) 80%, transparent);
+}
+
+.pwa-install-benefits svg,
+.pwa-install-steps svg {
+ flex: none;
+ color: var(--clinical-accent);
+}
+
+.pwa-install-steps li {
+ min-height: var(--spacing-tap);
+ justify-content: space-between;
+ font-size: 0.8125rem;
+}
+
+.pwa-install-actions {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.pwa-install-actions-single {
+ grid-template-columns: 1fr;
+}
+
+.pwa-action {
+ transition:
+ border-color 150ms ease,
+ background-color 150ms ease,
+ color 150ms ease,
+ box-shadow 150ms ease;
+}
+
+.pwa-action-primary {
+ border: 1px solid var(--clinical-accent);
+ background: var(--clinical-accent);
+ color: var(--clinical-accent-contrast);
+ box-shadow: var(--e1);
+}
+
+.pwa-action-primary:hover {
+ border-color: var(--clinical-accent-hover);
+ background: var(--clinical-accent-hover);
+}
+
+.pwa-action-secondary {
+ border: 1px solid color-mix(in srgb, var(--clinical-accent) 58%, var(--border-lux));
+ background: var(--surface-lux);
+ color: var(--clinical-accent);
+}
+
+.pwa-action-secondary:hover {
+ background: var(--clinical-accent-soft);
+}
+
@keyframes pwa-notice-in {
from {
opacity: 0;
@@ -2674,32 +2881,33 @@ summary::-webkit-details-marker {
@media (min-width: 640px) {
.pwa-notice-stack {
left: auto;
- width: min(25rem, calc(100vw - 2rem - var(--safe-area-left) - var(--safe-area-right)));
+ right: max(1rem, var(--safe-area-right));
+ bottom: max(1rem, var(--safe-area-bottom));
+ width: min(27rem, calc(100vw - 2rem - var(--safe-area-left) - var(--safe-area-right)));
+ }
+
+ .pwa-install-grip {
+ display: none;
}
}
@media (display-mode: standalone) {
.pwa-notice-stack {
- --pwa-notice-bottom-gap: calc(max(0.75rem, var(--safe-area-bottom)) + 6rem);
+ --pwa-notice-bottom-gap: calc(max(0.75rem, var(--safe-area-bottom)) + 5.5rem);
}
}
-/* Last so it also wins over the standalone bottom offset at desktop width. */
-@media (min-width: 1280px) {
- .pwa-notice-stack {
- top: calc(4.25rem + max(0.5rem, var(--safe-area-top)));
- bottom: auto;
+@media (display-mode: standalone) and (max-width: 639.98px) {
+ body:has([data-phone-footer-owner="hero"]) .pwa-notice-stack,
+ body:has(form[data-composer-placement="desktop-home"]) .pwa-notice-stack {
+ --pwa-notice-bottom-gap: max(0.75rem, var(--safe-area-bottom));
}
+}
- @keyframes pwa-notice-in {
- from {
- opacity: 0;
- transform: translateY(-0.625rem) scale(0.98);
- }
- to {
- opacity: 1;
- transform: none;
- }
+@media (min-width: 1280px) {
+ .pwa-notice-stack {
+ right: max(1.5rem, var(--safe-area-right));
+ bottom: max(1.5rem, var(--safe-area-bottom));
}
}
@@ -2713,6 +2921,24 @@ html[data-motion="reduced"] .pwa-notice-card {
animation: none;
}
+@media (forced-colors: active) {
+ .pwa-notice-card,
+ .pwa-install-benefits,
+ .pwa-install-steps,
+ .pwa-action,
+ .pwa-install-dismiss {
+ border-color: CanvasText;
+ }
+
+ .pwa-install-header,
+ .pwa-install-body,
+ .pwa-install-benefits,
+ .pwa-install-steps,
+ .pwa-action-secondary {
+ background: Canvas;
+ }
+}
+
/* Tabular figures for data, counts, page numbers, byte sizes */
@utility nums {
font-variant-numeric: tabular-nums;
diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx
index 75eada15c3..42b28224b5 100644
--- a/src/components/pwa-lifecycle.tsx
+++ b/src/components/pwa-lifecycle.tsx
@@ -1,7 +1,20 @@
"use client";
-import { RefreshCw, Share, SquarePlus, Wifi, WifiOff, X, type LucideIcon } from "lucide-react";
+import {
+ Download,
+ LayoutGrid,
+ RefreshCw,
+ Share,
+ Smartphone,
+ SquarePlus,
+ Wifi,
+ WifiOff,
+ X,
+ Zap,
+ type LucideIcon,
+} from "lucide-react";
import { useEffect, useRef, useState } from "react";
+import { BrandMark } from "@/components/clinical-dashboard/brand";
import { createBrowserStore } from "@/lib/client-store-factory";
const SERVICE_WORKER_URL = "/sw.js";
@@ -100,12 +113,11 @@ async function teardownLocalPwa() {
}
}
-const cardClassName =
- "pwa-notice-card pointer-events-auto relative rounded-2xl border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-4 text-[color:var(--text)] shadow-[var(--shadow-lux)] ring-1 ring-[color:var(--ring-glass)] backdrop-blur-md";
+const cardClassName = "pwa-notice-card pointer-events-auto relative text-[color:var(--text)]";
const primaryButtonClassName =
- "inline-flex min-h-tap items-center justify-center rounded-lg bg-[color:var(--clinical-accent)] px-3.5 py-2 text-sm font-semibold text-[color:var(--clinical-accent-contrast)] shadow-[var(--e1)] transition-colors hover:bg-[color:var(--clinical-accent-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
+ "pwa-action pwa-action-primary inline-flex min-h-tap items-center justify-center rounded-md px-4 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
const secondaryButtonClassName =
- "inline-flex min-h-tap items-center justify-center rounded-lg border border-[color:var(--border-lux)] px-3.5 py-2 text-sm font-semibold text-[color:var(--text)] transition-colors hover:bg-[color:var(--surface-subtle)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
+ "pwa-action pwa-action-secondary inline-flex min-h-tap items-center justify-center rounded-md px-4 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
const dismissIconButtonClassName =
"absolute right-1 top-1 inline-flex h-tap w-tap items-center justify-center rounded-full text-[color:var(--text-muted)] transition-colors hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
@@ -130,47 +142,75 @@ function NoticeIcon({ icon: Icon, tone }: { icon: LucideIcon; tone: "accent" | "
);
}
-// Phone install notices read as a native install sheet: grip bar (visual
-// echo of src/components/ui/sheet.tsx; static — notices are transient cards,
-// not draggable dialogs) and the real app icon as the identity mark.
+// Phone install notices echo the application's sheet language without acting
+// like a draggable modal. The grip is deliberately decorative and the notice
+// remains a non-blocking region.
function InstallSheetGrip() {
return (
-
-
+
+
);
}
-function InstallAppIdentity({ title, titleId, tagline }: { title: string; titleId: string; tagline: string }) {
+function InstallHeader({
+ dismissLabel,
+ onDismiss,
+ title,
+ titleId,
+}: {
+ dismissLabel: string;
+ onDismiss: () => void;
+ title: string;
+ titleId: string;
+}) {
return (
-
- {/* eslint-disable-next-line @next/next/no-img-element -- static same-origin
- brand asset; next/image adds a client chunk for zero benefit here. */}
-
-
-
+
);
}
-function InstallStepChip({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
+const installBenefits: Array<{ icon: LucideIcon; label: string }> = [
+ { icon: Zap, label: "Quick access" },
+ { icon: Smartphone, label: "App-like launch" },
+ { icon: LayoutGrid, label: "Familiar workspace" },
+];
+
+function InstallBenefits() {
return (
-
-
- {label}
-
+
+ {installBenefits.map(({ icon: Icon, label }) => (
+
+
+ {label}
+
+ ))}
+
+ );
+}
+
+function InstallManualSteps() {
+ return (
+
+
+ 1. Tap Share
+
+
+
+ 2. Add to Home Screen
+
+
+
);
}
@@ -458,7 +498,12 @@ export function PwaLifecycle() {
return (
{showOffline ? (
-
+
- You appear to be offline
+ You’re offline
- Clinical search, answers, private documents, uploads, and account data require a connection.
+ Clinical search and private features need a connection.
+
Connection restored
@@ -498,7 +543,12 @@ export function PwaLifecycle() {
) : null}
{showUpdate ? (
-
+
- An update is ready
+ Update available
- Refresh when convenient to use the latest Clinical KB version.
+ Reload when convenient to use the latest version.
- Refresh now
+
+ Reload
Later
@@ -536,30 +587,20 @@ export function PwaLifecycle() {
aria-labelledby="pwa-ios-install-title"
aria-live="polite"
>
-
-
-
-
-
-
- In Safari, tap Share, then Add to Home Screen. Private clinical features still require a connection.
-
-
-
-
-
-
-
+
+
+
Clinical guidelines on your home screen.
+
In Safari, tap Share, then Add to Home Screen.
+
+
Private clinical features still require a connection.
+
+
Not now
@@ -574,36 +615,34 @@ export function PwaLifecycle() {
aria-labelledby="pwa-install-title"
aria-live="polite"
>
-
-
-
-
-
-
+
+
+
Clinical guidelines on your home screen.
+
Open it from your device like an app. Private clinical features still require a connection.
-
- Free · No app store · Takes a few seconds
-
-
+
Free · No app store · Takes a few seconds
+
+
void requestInstall()}
>
+
Install app
-
+
Not now
diff --git a/tests/pwa-lifecycle.dom.test.tsx b/tests/pwa-lifecycle.dom.test.tsx
index 4d10313dec..429c9488a9 100644
--- a/tests/pwa-lifecycle.dom.test.tsx
+++ b/tests/pwa-lifecycle.dom.test.tsx
@@ -132,10 +132,8 @@ describe("PwaLifecycle", () => {
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
fireEvent.offline(window);
- expect(await screen.findByRole("region", { name: "You appear to be offline" })).toBeInTheDocument();
- expect(
- screen.getByText(/clinical search, answers, private documents, uploads, and account data require a connection/i),
- ).toBeInTheDocument();
+ expect(await screen.findByRole("region", { name: "You’re offline" })).toBeInTheDocument();
+ expect(screen.getByText("Clinical search and private features need a connection.")).toBeInTheDocument();
Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
fireEvent.online(window);
@@ -148,16 +146,16 @@ describe("PwaLifecycle", () => {
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
fireEvent.offline(window);
- expect(await screen.findByRole("region", { name: "You appear to be offline" })).toBeInTheDocument();
+ expect(await screen.findByRole("region", { name: "You’re offline" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Dismiss offline notice" }));
- expect(screen.queryByRole("region", { name: "You appear to be offline" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "You’re offline" })).not.toBeInTheDocument();
Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
fireEvent.online(window);
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
fireEvent.offline(window);
- expect(await screen.findByRole("region", { name: "You appear to be offline" })).toBeInTheDocument();
+ expect(await screen.findByRole("region", { name: "You’re offline" })).toBeInTheDocument();
});
it("shows install UI only after browser eligibility and invokes the deferred prompt from a user action", async () => {
@@ -168,6 +166,15 @@ describe("PwaLifecycle", () => {
const prompt = dispatchInstallEligibility();
const installRegion = await screen.findByRole("region", { name: "Install Clinical KB" });
expect(installRegion).toBeInTheDocument();
+ expect(installRegion).toHaveTextContent("Clinical guidelines on your home screen.");
+ expect(installRegion).toHaveTextContent(
+ "Open it from your device like an app. Private clinical features still require a connection.",
+ );
+ expect(installRegion).toHaveTextContent("Free · No app store · Takes a few seconds");
+ expect(screen.getByRole("list", { name: "Install benefits" })).toHaveTextContent(
+ "Quick accessApp-like launchFamiliar workspace",
+ );
+ expect(screen.getByRole("button", { name: "Dismiss install prompt" })).toHaveTextContent("Dismiss");
await user.click(screen.getByRole("button", { name: "Install app" }));
expect(prompt).toHaveBeenCalledTimes(1);
@@ -181,12 +188,12 @@ describe("PwaLifecycle", () => {
const user = userEvent.setup();
render(
);
- expect(await screen.findByText("An update is ready")).toBeInTheDocument();
+ expect(await screen.findByText("Update available")).toBeInTheDocument();
dispatchInstallEligibility();
expect(screen.queryByRole("region", { name: "Install Clinical KB" })).not.toBeInTheDocument();
expect(waitingWorker.postMessage).not.toHaveBeenCalled();
- await user.click(screen.getByRole("button", { name: "Refresh now" }));
+ await user.click(screen.getByRole("button", { name: "Reload" }));
expect(waitingWorker.postMessage).toHaveBeenCalledWith({ type: "SKIP_WAITING" });
});
@@ -200,8 +207,9 @@ describe("PwaLifecycle", () => {
container.dispatchEvent(new Event("controllerchange"));
});
- expect(await screen.findByRole("region", { name: "An update is ready" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Refresh now" })).toBeInTheDocument();
+ expect(await screen.findByRole("region", { name: "Update available" })).toBeInTheDocument();
+ expect(screen.getByText("Reload when convenient to use the latest version.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Later" })).toBeInTheDocument();
});
@@ -215,7 +223,7 @@ describe("PwaLifecycle", () => {
container.dispatchEvent(new Event("controllerchange"));
});
- expect(screen.queryByRole("region", { name: "An update is ready" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Update available" })).not.toBeInTheDocument();
});
it("shows the one-time iOS Add to Home Screen hint and honours its dismissal window", async () => {
@@ -229,8 +237,12 @@ describe("PwaLifecycle", () => {
const { unmount } = render(
);
const hint = await screen.findByRole("region", { name: "Install Clinical KB" });
- expect(hint).toHaveTextContent(/tap Share, then Add to Home Screen/i);
- expect(hint).toHaveTextContent(/still require a connection/i);
+ expect(hint).toHaveTextContent("In Safari, tap Share, then Add to Home Screen.");
+ expect(hint).toHaveTextContent("Private clinical features still require a connection.");
+ expect(screen.getByRole("list", { name: "Add Clinical KB to your Home Screen" })).toHaveTextContent(
+ "1. Tap Share2. Add to Home Screen",
+ );
+ expect(screen.queryByRole("button", { name: "Install app" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Not now" }));
await waitFor(() =>
diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts
index 8bb66bfc7b..c01244308c 100644
--- a/tests/ui-pwa.spec.ts
+++ b/tests/ui-pwa.spec.ts
@@ -244,6 +244,101 @@ test.describe("Clinical KB PWA", () => {
.toEqual([]);
});
+ test("keeps the install surface polished, reachable, and clear of search at every target width", async ({ page }) => {
+ const viewports = [
+ { name: "compact phone", width: 320, height: 720 },
+ { name: "phone", width: 390, height: 844 },
+ { name: "tablet", width: 820, height: 1180 },
+ { name: "desktop", width: 1440, height: 1000 },
+ ] as const;
+
+ for (const viewport of viewports) {
+ await test.step(viewport.name, async () => {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await page.goto("/?mode=answer&pwa-dev=0", { waitUntil: "domcontentloaded" });
+ await expect(page.locator("header#search").first()).toBeVisible({ timeout: 20_000 });
+ await page.waitForFunction(() => document.documentElement.dataset.pwaDisplayMode === "browser");
+
+ await page.evaluate(() => {
+ const event = new Event("beforeinstallprompt", { cancelable: true });
+ Object.assign(event, {
+ prompt: () => Promise.resolve(),
+ userChoice: Promise.resolve({ outcome: "accepted", platform: "web" }),
+ });
+ window.dispatchEvent(event);
+ });
+
+ const install = page.getByRole("region", { name: "Install Clinical KB" });
+ await expect(install).toBeVisible();
+ await expect(install).toContainText("Clinical guidelines on your home screen.");
+ await expect(install).toContainText(
+ "Open it from your device like an app. Private clinical features still require a connection.",
+ );
+ await expect(install).toContainText("Free · No app store · Takes a few seconds");
+ await expect(install.getByRole("list", { name: "Install benefits" })).toContainText(
+ "Quick accessApp-like launchFamiliar workspace",
+ );
+
+ const geometry = await install.evaluate((surface) => {
+ const rect = surface.getBoundingClientRect();
+ const installAction = surface.querySelector
(".pwa-install-primary")?.getBoundingClientRect();
+ const secondaryAction = surface.querySelector(".pwa-install-secondary")?.getBoundingClientRect();
+ const dismissAction = surface.querySelector(".pwa-install-dismiss")?.getBoundingClientRect();
+ const visibleSearch = Array.from(
+ document.querySelectorAll('[data-testid="global-search-input"]'),
+ ).find((node) => {
+ const candidate = node.getBoundingClientRect();
+ return candidate.width > 0 && candidate.height > 0;
+ });
+ const searchRect =
+ visibleSearch?.closest(".chat-composer-shell-base")?.getBoundingClientRect() ??
+ visibleSearch?.getBoundingClientRect();
+ const overlap = searchRect
+ ? Math.max(0, Math.min(rect.right, searchRect.right) - Math.max(rect.left, searchRect.left)) *
+ Math.max(0, Math.min(rect.bottom, searchRect.bottom) - Math.max(rect.top, searchRect.top))
+ : 0;
+
+ return {
+ left: rect.left,
+ right: rect.right,
+ top: rect.top,
+ bottom: rect.bottom,
+ width: rect.width,
+ height: rect.height,
+ installActionHeight: installAction?.height ?? 0,
+ secondaryActionHeight: secondaryAction?.height ?? 0,
+ dismissActionHeight: dismissAction?.height ?? 0,
+ overlap,
+ hasInternalScroll: surface.scrollHeight > surface.clientHeight + 1,
+ pageScrollWidth: document.documentElement.scrollWidth,
+ viewportWidth: window.innerWidth,
+ };
+ });
+
+ expect(geometry.left, `${viewport.name}: left inset`).toBeGreaterThanOrEqual(7);
+ expect(geometry.right, `${viewport.name}: right inset`).toBeLessThanOrEqual(viewport.width - 7);
+ expect(geometry.top, `${viewport.name}: top clearance`).toBeGreaterThanOrEqual(8);
+ expect(geometry.bottom, `${viewport.name}: bottom clearance`).toBeLessThanOrEqual(viewport.height - 8);
+ expect(geometry.installActionHeight, `${viewport.name}: Install app target`).toBeGreaterThanOrEqual(48);
+ expect(geometry.secondaryActionHeight, `${viewport.name}: Not now target`).toBeGreaterThanOrEqual(48);
+ expect(geometry.dismissActionHeight, `${viewport.name}: Dismiss target`).toBeGreaterThanOrEqual(48);
+ expect(geometry.overlap, `${viewport.name}: install surface must not overlap search`).toBe(0);
+ expect(geometry.hasInternalScroll, `${viewport.name}: complete value proposition should fit`).toBe(false);
+ expect(geometry.pageScrollWidth, `${viewport.name}: no horizontal overflow`).toBeLessThanOrEqual(
+ geometry.viewportWidth,
+ );
+
+ if (viewport.width >= 640) {
+ expect(geometry.width, `${viewport.name}: restrained card width`).toBeGreaterThanOrEqual(380);
+ expect(geometry.width, `${viewport.name}: restrained card width`).toBeLessThanOrEqual(440);
+ expect(geometry.right, `${viewport.name}: right-corner anchor`).toBeGreaterThanOrEqual(viewport.width - 32);
+ } else {
+ expect(geometry.height, `${viewport.name}: compact phone sheet`).toBeLessThan(viewport.height * 0.7);
+ }
+ });
+ }
+ });
+
test("serves a cold offline fallback, recovers online, and keeps private URLs out of CacheStorage", async ({
context,
page,
From 3c009524f0c47d5255acb73b616f18aeb3bb948b Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 13:43:59 +0800
Subject: [PATCH 02/16] fix(pwa): keep install surface clear and reachable
---
src/app/globals.css | 8 ++++++
tests/ui-pwa.spec.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 73 insertions(+)
diff --git a/src/app/globals.css b/src/app/globals.css
index 3a7c34a83e..73a82f3e7c 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2698,6 +2698,7 @@ summary::-webkit-details-marker {
.pwa-install-sheet {
max-height: calc(100dvh - var(--pwa-notice-bottom-gap) - 4.25rem);
+ overflow-y: auto;
overscroll-behavior: contain;
}
@@ -2886,6 +2887,13 @@ summary::-webkit-details-marker {
width: min(27rem, calc(100vw - 2rem - var(--safe-area-left) - var(--safe-area-right)));
}
+ /* Submitted answer views own a fixed bottom composer. Keep the install
+ surface in the opposite corner so neither control can obscure the other. */
+ body:has(form.answer-footer-search-edge) .pwa-notice-stack {
+ top: calc(4.25rem + max(0.75rem, var(--safe-area-top)));
+ bottom: auto;
+ }
+
.pwa-install-grip {
display: none;
}
diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts
index c01244308c..11ba2cdfef 100644
--- a/tests/ui-pwa.spec.ts
+++ b/tests/ui-pwa.spec.ts
@@ -339,6 +339,71 @@ test.describe("Clinical KB PWA", () => {
}
});
+ test("keeps constrained install actions reachable in a short phone viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 400 });
+ await page.goto("/?mode=answer&pwa-dev=0", { waitUntil: "domcontentloaded" });
+ await expect(page.locator("header#search").first()).toBeVisible({ timeout: 20_000 });
+ await page.waitForFunction(() => document.documentElement.dataset.pwaDisplayMode === "browser");
+
+ await page.evaluate(() => {
+ const event = new Event("beforeinstallprompt", { cancelable: true });
+ Object.assign(event, {
+ prompt: () => Promise.resolve(),
+ userChoice: Promise.resolve({ outcome: "accepted", platform: "web" }),
+ });
+ window.dispatchEvent(event);
+ });
+
+ const install = page.getByRole("region", { name: "Install Clinical KB" });
+ await expect(install).toBeVisible();
+ const scrollState = await install.evaluate((surface) => ({
+ clientHeight: surface.clientHeight,
+ scrollHeight: surface.scrollHeight,
+ overflowY: getComputedStyle(surface).overflowY,
+ }));
+ expect(scrollState.scrollHeight).toBeGreaterThan(scrollState.clientHeight);
+ expect(scrollState.overflowY).toMatch(/auto|scroll/);
+
+ await install.evaluate((surface) => {
+ surface.scrollTop = surface.scrollHeight;
+ });
+ await expect(install.getByRole("button", { name: "Install app" })).toBeInViewport();
+ await expect(install.getByRole("button", { name: "Not now" })).toBeInViewport();
+ });
+
+ test("keeps the install surface clear of the submitted answer composer", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.goto("/?mode=answer&q=lithium&run=1&pwa-dev=0", { waitUntil: "domcontentloaded" });
+ await expect(page.locator("header#search").first()).toBeVisible({ timeout: 20_000 });
+ await page.waitForFunction(() => document.documentElement.dataset.pwaDisplayMode === "browser");
+
+ await page.evaluate(() => {
+ const event = new Event("beforeinstallprompt", { cancelable: true });
+ Object.assign(event, {
+ prompt: () => Promise.resolve(),
+ userChoice: Promise.resolve({ outcome: "accepted", platform: "web" }),
+ });
+ window.dispatchEvent(event);
+ });
+
+ const install = page.getByRole("region", { name: "Install Clinical KB" });
+ const composer = page.locator("form.answer-footer-search-edge:visible").first();
+ await expect(install).toBeVisible();
+ await expect(composer).toBeVisible();
+
+ const overlap = await install.evaluate((surface, selector) => {
+ const composerSurface = document.querySelector(selector);
+ if (!composerSurface) return -1;
+ const card = surface.getBoundingClientRect();
+ const dock = composerSurface.getBoundingClientRect();
+ return (
+ Math.max(0, Math.min(card.right, dock.right) - Math.max(card.left, dock.left)) *
+ Math.max(0, Math.min(card.bottom, dock.bottom) - Math.max(card.top, dock.top))
+ );
+ }, "form.answer-footer-search-edge");
+ expect(overlap).toBe(0);
+ });
+
test("serves a cold offline fallback, recovers online, and keeps private URLs out of CacheStorage", async ({
context,
page,
From b0295929e3279d6ea2aea10752e434e27da19aa8 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 14:28:40 +0800
Subject: [PATCH 03/16] docs: record final PWA base update
---
...c66fe43de4889cf3f33e6bc83a7ebc8734fba8c6c71148e0d6f.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/534a1703b8963c66fe43de4889cf3f33e6bc83a7ebc8734fba8c6c71148e0d6f.record.md
diff --git a/docs/branch-review-records/534a1703b8963c66fe43de4889cf3f33e6bc83a7ebc8734fba8c6c71148e0d6f.record.md b/docs/branch-review-records/534a1703b8963c66fe43de4889cf3f33e6bc83a7ebc8734fba8c6c71148e0d6f.record.md
new file mode 100644
index 0000000000..1d9d2361b4
--- /dev/null
+++ b/docs/branch-review-records/534a1703b8963c66fe43de4889cf3f33e6bc83a7ebc8734fba8c6c71148e0d6f.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | aa041fd15f902dce172ea0d2707f3f81cb8f160c | PWA install lifecycle: final current-base merge | Merged latest required base after validated PWA registry fix; no merge conflicts | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; static PWA registry assertion |
From 5866920f0c763637b8ec0f8656c3eff86407a29b Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:01:54 +0800
Subject: [PATCH 04/16] docs: record PWA base sync
---
...8692b3dbc9e939c1e7d35a1a50108e2c0268cbf41ee612eaa76.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/497a7b26ad6568692b3dbc9e939c1e7d35a1a50108e2c0268cbf41ee612eaa76.record.md
diff --git a/docs/branch-review-records/497a7b26ad6568692b3dbc9e939c1e7d35a1a50108e2c0268cbf41ee612eaa76.record.md b/docs/branch-review-records/497a7b26ad6568692b3dbc9e939c1e7d35a1a50108e2c0268cbf41ee612eaa76.record.md
new file mode 100644
index 0000000000..015bde1bc4
--- /dev/null
+++ b/docs/branch-review-records/497a7b26ad6568692b3dbc9e939c1e7d35a1a50108e2c0268cbf41ee612eaa76.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | c6d8c2829ddd2fc52441a3696aed56fbd0262917 | required base sync through main 17402395 | Approved — required main update merged; prior PWA lifecycle review and style-inventory repair remain applicable with no PR-path conflict | git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed |
From c78bbf610f097023a9c3d8bded28211e68b0dac5 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:27:37 +0800
Subject: [PATCH 05/16] docs(ledger): record PWA base sync
---
...0a26e76906a700a861925da55131c0caacbaab18ac81507cd0d.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/fe8542d735a4d0a26e76906a700a861925da55131c0caacbaab18ac81507cd0d.record.md
diff --git a/docs/branch-review-records/fe8542d735a4d0a26e76906a700a861925da55131c0caacbaab18ac81507cd0d.record.md b/docs/branch-review-records/fe8542d735a4d0a26e76906a700a861925da55131c0caacbaab18ac81507cd0d.record.md
new file mode 100644
index 0000000000..2dfcd8bcfc
--- /dev/null
+++ b/docs/branch-review-records/fe8542d735a4d0a26e76906a700a861925da55131c0caacbaab18ac81507cd0d.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 233047f36f210f0e9ea4846183b2368ca67fa338 | Required base sync through main d301d8f4 | approved | git diff --check; CI scope self-test; ledger and issue guards |
From 4aaa7019899cf0f95c1ca5402e27ce6dfc046dc5 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:51:13 +0800
Subject: [PATCH 06/16] style(pwa): format style contract exemptions
---
tests/helpers/style-contracts.ts | 27 ++++++++++++++++++---------
1 file changed, 18 insertions(+), 9 deletions(-)
diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts
index 0668d3d5fe..d77e4b3632 100644
--- a/tests/helpers/style-contracts.ts
+++ b/tests/helpers/style-contracts.ts
@@ -377,18 +377,27 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = {
// component effects are not yet individually asserted by the general
// computed-style journey.
"pwa-action-primary": "PWA primary action — target geometry covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-action-secondary": "PWA secondary action — target geometry covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-action-secondary":
+ "PWA secondary action — target geometry covered by ui-pwa; effect not contracted yet (#094)",
"pwa-connection-restored": "PWA connectivity-recovery notice — no deterministic effect contract yet (#094)",
- "pwa-install-benefits": "PWA benefit list — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-benefits":
+ "PWA benefit list — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-body": "PWA install body — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-copy": "PWA install copy — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-dismiss": "PWA install dismissal — target geometry covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-grip": "PWA phone-sheet grip — responsive sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-header": "PWA install header — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-steps": "PWA manual-install steps — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-support": "PWA install support copy — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-tagline": "PWA install tagline — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-notice-card": "PWA notice card — motion and forced-colors fallbacks covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-dismiss":
+ "PWA install dismissal — target geometry covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-grip":
+ "PWA phone-sheet grip — responsive sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-header":
+ "PWA install header — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-steps":
+ "PWA manual-install steps — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-support":
+ "PWA install support copy — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-tagline":
+ "PWA install tagline — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-notice-card":
+ "PWA notice card — motion and forced-colors fallbacks covered by ui-pwa; effect not contracted yet (#094)",
// Selector scopes, not effects. `.mode-nav` carries the density-profile/query
// container context, while `.mode-nav__more` scopes the overflow slot's rules.
From c3c260c24d36520656e79665f2e7871ec9e555ba Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:51:27 +0800
Subject: [PATCH 07/16] docs(ledger): record PWA formatter follow-up
---
...5c939048278f01e3a3059d5509bfd975096af94b63ef16e825a.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/4a51a80a457f85c939048278f01e3a3059d5509bfd975096af94b63ef16e825a.record.md
diff --git a/docs/branch-review-records/4a51a80a457f85c939048278f01e3a3059d5509bfd975096af94b63ef16e825a.record.md b/docs/branch-review-records/4a51a80a457f85c939048278f01e3a3059d5509bfd975096af94b63ef16e825a.record.md
new file mode 100644
index 0000000000..1812f5505d
--- /dev/null
+++ b/docs/branch-review-records/4a51a80a457f85c939048278f01e3a3059d5509bfd975096af94b63ef16e825a.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 80fd6c04afdfa30e92c1cdf2ea5ba9d3d32a02c6 | PWA style-contract formatter follow-up | Formatted the PWA style-contract exemption entries reported by changed-file formatting. Targeted registry test unavailable because this isolated worktree has no node_modules/vitest; Lighthouse was not run locally by instruction. | node --check tests/helpers/style-contracts.ts; git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; ci-change-scope --self-test |
From 689808857b3f50c8ea60ec85709f16d936f625c9 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:16:57 +0800
Subject: [PATCH 08/16] style(pwa): keep narrow exemptions inline
---
tests/helpers/style-contracts.ts | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts
index d77e4b3632..d02c664539 100644
--- a/tests/helpers/style-contracts.ts
+++ b/tests/helpers/style-contracts.ts
@@ -377,15 +377,13 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = {
// component effects are not yet individually asserted by the general
// computed-style journey.
"pwa-action-primary": "PWA primary action — target geometry covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-action-secondary":
- "PWA secondary action — target geometry covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-action-secondary": "PWA secondary action — target geometry covered by ui-pwa; effect not contracted yet (#094)",
"pwa-connection-restored": "PWA connectivity-recovery notice — no deterministic effect contract yet (#094)",
"pwa-install-benefits":
"PWA benefit list — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-body": "PWA install body — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-copy": "PWA install copy — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
- "pwa-install-dismiss":
- "PWA install dismissal — target geometry covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-dismiss": "PWA install dismissal — target geometry covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-grip":
"PWA phone-sheet grip — responsive sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-header":
From 31bfa6055bdebf09a84bc3b2253a0231ea6c8719 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:17:08 +0800
Subject: [PATCH 09/16] docs(ledger): record PWA exemption formatter follow-up
---
...41966caaa63a61b73a2bba0026f0898a8f6e3afae6123075fce.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/63bb22a3ea42c41966caaa63a61b73a2bba0026f0898a8f6e3afae6123075fce.record.md
diff --git a/docs/branch-review-records/63bb22a3ea42c41966caaa63a61b73a2bba0026f0898a8f6e3afae6123075fce.record.md b/docs/branch-review-records/63bb22a3ea42c41966caaa63a61b73a2bba0026f0898a8f6e3afae6123075fce.record.md
new file mode 100644
index 0000000000..2162c854bd
--- /dev/null
+++ b/docs/branch-review-records/63bb22a3ea42c41966caaa63a61b73a2bba0026f0898a8f6e3afae6123075fce.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 80fd6c04afdfa30e92c1cdf2ea5ba9d3d32a02c6 | PR #1976 PWA exemption formatter follow-up | Corrected two exemption descriptions that were under the 120-column formatter width and must remain single-line properties. | git diff --check; ledger/outstanding/branch-ledger/ledger-discipline guards; ci-change-scope self-test passed; npm test -- tests/style-contract-registry.test.ts unavailable: node_modules/vitest/vitest.mjs absent. |
From 80eb8f6d1c332ee5c32a27da93eb026fb6d9b0d1 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:26:27 +0800
Subject: [PATCH 10/16] docs(ledger): record PWA base sync
---
...bde68f1fae4de2e7e8275b5cabc1802d1b6511b30323adad6ad.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/b3523668f8839bde68f1fae4de2e7e8275b5cabc1802d1b6511b30323adad6ad.record.md
diff --git a/docs/branch-review-records/b3523668f8839bde68f1fae4de2e7e8275b5cabc1802d1b6511b30323adad6ad.record.md b/docs/branch-review-records/b3523668f8839bde68f1fae4de2e7e8275b5cabc1802d1b6511b30323adad6ad.record.md
new file mode 100644
index 0000000000..83b79fb206
--- /dev/null
+++ b/docs/branch-review-records/b3523668f8839bde68f1fae4de2e7e8275b5cabc1802d1b6511b30323adad6ad.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 80fd6c04afdfa30e92c1cdf2ea5ba9d3d32a02c6 | PR #1976 base sync | Merged main 6f7b7deefaf7e0cd062b748f18fc6ca8988093f6 into the reviewed PR head; merge tree was clean. | git merge-tree --write-tree exact-head main: clean; git diff --check; ledger guards. |
From 6a1ef9ef020ff41165a9d02245367052a68bd7b3 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:37:24 +0800
Subject: [PATCH 11/16] fix(pwa): reuse design-system roles in notice styles
---
...445bd109c6f2bfcffd686c6e436d8c74.record.md | 1 +
src/app/globals.css | 106 +++++++++++-------
2 files changed, 65 insertions(+), 42 deletions(-)
create mode 100644 docs/branch-review-records/bde56718d14d5138e7a29a72d16402b4445bd109c6f2bfcffd686c6e436d8c74.record.md
diff --git a/docs/branch-review-records/bde56718d14d5138e7a29a72d16402b4445bd109c6f2bfcffd686c6e436d8c74.record.md b/docs/branch-review-records/bde56718d14d5138e7a29a72d16402b4445bd109c6f2bfcffd686c6e436d8c74.record.md
new file mode 100644
index 0000000000..21f4011c7a
--- /dev/null
+++ b/docs/branch-review-records/bde56718d14d5138e7a29a72d16402b4445bd109c6f2bfcffd686c6e436d8c74.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 80eb8f6d1c332ee5c32a27da93eb026fb6d9b0d1 | PWA stylesheet design-token contract remediation | Replaced PR-introduced raw PWA motion, padding, radius, gap, and line-height declarations with scoped semantic roles and existing design tokens; preserved rendered values. | git diff --check; manual CSS-contract audit (no new raw guarded declarations); static gate unavailable locally: @typescript/typescript6 not installed |
diff --git a/src/app/globals.css b/src/app/globals.css
index dcd966f12c..0334de2513 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2623,6 +2623,28 @@ summary::-webkit-details-marker {
central search stays usable and the wide-screen card remains lower-right. */
.pwa-notice-stack {
--pwa-notice-bottom-gap: calc(max(0.75rem, var(--safe-area-bottom)) + 5rem);
+ /* PWA-only geometry and typography roles. Keep the component's exact
+ measured proportions while consuming the shared radius, spacing, and motion
+ ladders wherever a matching step exists. */
+ --pwa-notice-gap: var(--radius-lg);
+ --pwa-card-radius: var(--radius-xl);
+ --pwa-control-radius: var(--radius-md);
+ --pwa-inset-xs: var(--radius-xs);
+ --pwa-inset-sm: var(--radius-sm);
+ --pwa-inset-md: var(--radius-md);
+ --pwa-inset-lg: var(--radius-lg);
+ --pwa-inset-xl: var(--spacing-icon-md);
+ --pwa-grip-height: calc(var(--spacing-icon-sm) + var(--radius-xs));
+ --pwa-grip-width: calc(var(--spacing-icon-xl) + var(--radius-lg));
+ --pwa-grip-thickness: 0.1875rem;
+ --pwa-header-min-height: calc(4 * var(--radius-xl));
+ --pwa-row-min-height: calc(2 * var(--radius-xl) + 2 * var(--radius-xs));
+ --pwa-row-padding-block: 0.4375rem;
+ --pwa-line-height-control: 1;
+ --pwa-line-height-tagline: 1.35rem;
+ --pwa-line-height-copy: var(--spacing-icon-lg);
+ --pwa-line-height-support: var(--pwa-grip-height);
+ --pwa-motion-enter: 280ms;
position: fixed;
z-index: 95;
@@ -2630,7 +2652,7 @@ summary::-webkit-details-marker {
right: max(0.5rem, var(--safe-area-right));
bottom: var(--pwa-notice-bottom-gap);
display: grid;
- gap: 0.75rem;
+ gap: var(--pwa-notice-gap);
pointer-events: none;
}
@@ -2645,7 +2667,7 @@ summary::-webkit-details-marker {
body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] > div > section {
justify-content: flex-start;
- padding-top: 0.5rem;
+ padding-top: calc(2 * var(--radius-xs));
}
}
@@ -2669,31 +2691,31 @@ summary::-webkit-details-marker {
.pwa-notice-card {
overflow: hidden;
border: 1px solid var(--border-lux);
- border-radius: 1rem;
+ border-radius: var(--pwa-card-radius);
background: var(--surface-lux);
box-shadow:
0 18px 48px color-mix(in srgb, var(--text-heading) 11%, transparent),
0 3px 10px color-mix(in srgb, var(--text-heading) 6%, transparent);
- animation: pwa-notice-in 280ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
+ animation: pwa-notice-in var(--pwa-motion-enter) cubic-bezier(0.2, 0.9, 0.3, 1) both;
}
.pwa-lifecycle-card {
- padding: 1rem;
+ padding: var(--pwa-inset-xl);
}
.pwa-connection-restored {
justify-self: end;
border-color: color-mix(in srgb, var(--success) 24%, var(--border-lux));
- border-radius: 9999px;
+ border-radius: var(--radius-pill, 9999px);
background: color-mix(in srgb, var(--success-soft) 88%, var(--surface-lux));
- padding: 0.375rem 0.75rem 0.375rem 0.5rem;
+ padding: var(--pwa-inset-sm) var(--pwa-inset-lg) var(--pwa-inset-sm) calc(2 * var(--pwa-inset-xs));
box-shadow: var(--e1);
}
.pwa-connection-restored > div > span {
- width: 2rem;
- height: 2rem;
- border-radius: 9999px;
+ width: calc(2 * var(--pwa-inset-xl));
+ height: calc(2 * var(--pwa-inset-xl));
+ border-radius: var(--radius-pill, 9999px);
}
.pwa-install-sheet {
@@ -2704,32 +2726,32 @@ summary::-webkit-details-marker {
.pwa-install-grip {
display: flex;
- height: 1.125rem;
+ height: var(--pwa-grip-height);
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux));
}
.pwa-install-grip span {
- width: 2.25rem;
- height: 0.1875rem;
- border-radius: 9999px;
+ width: var(--pwa-grip-width);
+ height: var(--pwa-grip-thickness);
+ border-radius: var(--radius-pill, 9999px);
background: color-mix(in srgb, var(--text-muted) 48%, transparent);
}
.pwa-install-header {
display: flex;
- min-height: 4rem;
+ min-height: var(--pwa-header-min-height);
align-items: center;
justify-content: space-between;
- gap: 0.75rem;
+ gap: var(--pwa-notice-gap);
border-bottom: 1px solid color-mix(in srgb, var(--border-lux) 82%, transparent);
background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux));
- padding: 0.5rem 0.625rem 0.5rem 1rem;
+ padding: calc(2 * var(--pwa-inset-xs)) var(--pwa-inset-md) calc(2 * var(--pwa-inset-xs)) var(--pwa-inset-xl);
}
.pwa-install-mark {
- border-radius: 0.625rem;
+ border-radius: var(--pwa-control-radius);
filter: drop-shadow(0 2px 3px color-mix(in srgb, var(--clinical-accent) 18%, transparent));
}
@@ -2739,16 +2761,16 @@ summary::-webkit-details-marker {
flex: none;
align-items: center;
justify-content: center;
- gap: 0.25rem;
- border-radius: 0.625rem;
- padding: 0 0.5rem;
+ gap: var(--pwa-inset-xs);
+ border-radius: var(--pwa-control-radius);
+ padding: 0 calc(2 * var(--pwa-inset-xs));
color: var(--text-muted);
font-size: 0.6875rem;
font-weight: 600;
- line-height: 1;
+ line-height: var(--pwa-line-height-control);
transition:
- color 150ms ease,
- background-color 150ms ease;
+ color var(--duration-quick) ease,
+ background-color var(--duration-quick) ease;
}
.pwa-install-dismiss:hover {
@@ -2763,51 +2785,51 @@ summary::-webkit-details-marker {
.pwa-install-body {
background: var(--surface-lux);
- padding: 0.875rem 1rem 1rem;
+ padding: var(--spacing-icon-sm) var(--pwa-inset-xl) var(--pwa-inset-xl);
}
.pwa-install-tagline {
color: var(--text-heading);
font-size: 0.875rem;
font-weight: 700;
- line-height: 1.35rem;
+ line-height: var(--pwa-line-height-tagline);
}
.pwa-install-copy {
- margin-top: 0.375rem;
+ margin-top: var(--pwa-inset-sm);
color: var(--text-muted);
font-size: 0.8125rem;
- line-height: 1.25rem;
+ line-height: var(--pwa-line-height-copy);
}
.pwa-install-support {
- margin-top: 0.375rem;
+ margin-top: var(--pwa-inset-sm);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
- line-height: 1.125rem;
+ line-height: var(--pwa-line-height-support);
}
.pwa-install-benefits,
.pwa-install-steps {
- margin-top: 0.75rem;
+ margin-top: var(--pwa-inset-lg);
overflow: hidden;
border: 1px solid var(--border-lux);
- border-radius: 0.625rem;
+ border-radius: var(--pwa-control-radius);
background: color-mix(in srgb, var(--clinical-accent-soft) 20%, var(--surface-lux));
}
.pwa-install-benefits li,
.pwa-install-steps li {
display: flex;
- min-height: 2.5rem;
+ min-height: var(--pwa-row-min-height);
align-items: center;
- gap: 0.625rem;
- padding: 0.4375rem 0.75rem;
+ gap: var(--pwa-inset-md);
+ padding: var(--pwa-row-padding-block) var(--pwa-inset-lg);
color: var(--text);
font-size: 0.75rem;
font-weight: 600;
- line-height: 1.125rem;
+ line-height: var(--pwa-line-height-support);
}
.pwa-install-benefits li + li,
@@ -2830,8 +2852,8 @@ summary::-webkit-details-marker {
.pwa-install-actions {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
- gap: 0.5rem;
- margin-top: 0.75rem;
+ gap: calc(2 * var(--pwa-inset-xs));
+ margin-top: var(--pwa-inset-lg);
}
.pwa-install-actions-single {
@@ -2840,10 +2862,10 @@ summary::-webkit-details-marker {
.pwa-action {
transition:
- border-color 150ms ease,
- background-color 150ms ease,
- color 150ms ease,
- box-shadow 150ms ease;
+ border-color var(--duration-quick) ease,
+ background-color var(--duration-quick) ease,
+ color var(--duration-quick) ease,
+ box-shadow var(--duration-quick) ease;
}
.pwa-action-primary {
From 0dd4b7e9be293815ec3d06f3b57682a1578bafe4 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:47:21 +0800
Subject: [PATCH 12/16] fix(pwa): limit compact install layout shift
---
...6c4778648494337db0a6927d4b2f55c077e60c173.record.md | 1 +
src/app/globals.css | 10 +++++-----
2 files changed, 6 insertions(+), 5 deletions(-)
create mode 100644 docs/branch-review-records/2fed40a238cbd6e5803e25f6c4778648494337db0a6927d4b2f55c077e60c173.record.md
diff --git a/docs/branch-review-records/2fed40a238cbd6e5803e25f6c4778648494337db0a6927d4b2f55c077e60c173.record.md b/docs/branch-review-records/2fed40a238cbd6e5803e25f6c4778648494337db0a6927d4b2f55c077e60c173.record.md
new file mode 100644
index 0000000000..306888f4ff
--- /dev/null
+++ b/docs/branch-review-records/2fed40a238cbd6e5803e25f6c4778648494337db0a6927d4b2f55c077e60c173.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 6a1ef9ef020ff41165a9d02245367052a68bd7b3 | PWA mobile root CLS budget remediation | Narrowed install-sheet root-content repositioning from all phone widths to the documented <=359px compact layout, preserving the 320px overflow safeguard while avoiding the confirmed mobile-root shift. | git diff --check; Lighthouse CI log diagnosis: mobile-root CLS +0.105 confirmed in 3/3 samples; local Lighthouse intentionally not run |
diff --git a/src/app/globals.css b/src/app/globals.css
index 0334de2513..bbb750f10e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2664,11 +2664,6 @@ summary::-webkit-details-marker {
body:has(form[data-composer-placement="desktop-home"]) .pwa-notice-stack {
--pwa-notice-bottom-gap: max(0.75rem, var(--safe-area-bottom));
}
-
- body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] > div > section {
- justify-content: flex-start;
- padding-top: calc(2 * var(--radius-xs));
- }
}
/* A 320px mode home cannot show the full install proposition and its complete
@@ -2676,6 +2671,11 @@ summary::-webkit-details-marker {
the transient install sheet is present, keep the real composer visible in
the clear space under the app header and omit only the decorative intro. */
@media (max-width: 359.98px) {
+ body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] > div > section {
+ justify-content: flex-start;
+ padding-top: calc(2 * var(--radius-xs));
+ }
+
body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] [data-testid="shared-home-empty-state"] {
gap: 0;
}
From e466505383308c894d98b01008b1a2b89204196b Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:59:30 +0800
Subject: [PATCH 13/16] fix(pwa): keep install sheet clear of phone search
---
...d8f04e9dfb00089261c37800f7d8edf694.record.md | 1 +
src/app/globals.css | 17 -----------------
2 files changed, 1 insertion(+), 17 deletions(-)
create mode 100644 docs/branch-review-records/cebd03715e584420f2fb63b757718fd8f04e9dfb00089261c37800f7d8edf694.record.md
diff --git a/docs/branch-review-records/cebd03715e584420f2fb63b757718fd8f04e9dfb00089261c37800f7d8edf694.record.md b/docs/branch-review-records/cebd03715e584420f2fb63b757718fd8f04e9dfb00089261c37800f7d8edf694.record.md
new file mode 100644
index 0000000000..e86692ee4a
--- /dev/null
+++ b/docs/branch-review-records/cebd03715e584420f2fb63b757718fd8f04e9dfb00089261c37800f7d8edf694.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | codex/pwa-install-polish-20260815 | 80fd6c04afdfa30e92c1cdf2ea5ba9d3d32a02c6 | PWA phone install/composer overlap regression | Restored the shared phone composer reserve for mode-home install sheets after the exact-head production UI geometry test found a 390px overlap. | git diff --check 0dd4b7e9be293815ec3d06f3b57682a1578bafe4; node scripts/ledger-inbox.mjs check; node scripts/check-outstanding-issues.mjs; node scripts/ci-change-scope.mjs --self-test; exact-head production UI geometry failure reviewed |
diff --git a/src/app/globals.css b/src/app/globals.css
index bbb750f10e..a02c04e193 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2656,16 +2656,6 @@ summary::-webkit-details-marker {
pointer-events: none;
}
-/* Mode homes keep their phone composer in the hero rather than in the bottom
- dock. In that ownership model the install sheet belongs at the safe bottom
- edge; keeping the dock reserve would lift it over the in-flow composer. */
-@media (max-width: 639.98px) {
- body:has([data-phone-footer-owner="hero"]) .pwa-notice-stack,
- body:has(form[data-composer-placement="desktop-home"]) .pwa-notice-stack {
- --pwa-notice-bottom-gap: max(0.75rem, var(--safe-area-bottom));
- }
-}
-
/* A 320px mode home cannot show the full install proposition and its complete
centred empty-state introduction without covering the search composer. While
the transient install sheet is present, keep the real composer visible in
@@ -2927,13 +2917,6 @@ summary::-webkit-details-marker {
}
}
-@media (display-mode: standalone) and (max-width: 639.98px) {
- body:has([data-phone-footer-owner="hero"]) .pwa-notice-stack,
- body:has(form[data-composer-placement="desktop-home"]) .pwa-notice-stack {
- --pwa-notice-bottom-gap: max(0.75rem, var(--safe-area-bottom));
- }
-}
-
@media (min-width: 1280px) {
.pwa-notice-stack {
right: max(1.5rem, var(--safe-area-right));
From 54327591bb89f6f9f25f6834fbefc277a9bff80b Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:01:29 +0800
Subject: [PATCH 14/16] fix(pwa): keep phone install card clear of search
---
...bdc49427ad0395f1f4c8a0d304ec3a5251f1920d023b06c0.record.md | 1 +
src/app/globals.css | 4 ++--
tests/ui-pwa.spec.ts | 3 +++
3 files changed, 6 insertions(+), 2 deletions(-)
create mode 100644 docs/branch-review-records/7620543101feadcebdc49427ad0395f1f4c8a0d304ec3a5251f1920d023b06c0.record.md
diff --git a/docs/branch-review-records/7620543101feadcebdc49427ad0395f1f4c8a0d304ec3a5251f1920d023b06c0.record.md b/docs/branch-review-records/7620543101feadcebdc49427ad0395f1f4c8a0d304ec3a5251f1920d023b06c0.record.md
new file mode 100644
index 0000000000..2e9450f621
--- /dev/null
+++ b/docs/branch-review-records/7620543101feadcebdc49427ad0395f1f4c8a0d304ec3a5251f1920d023b06c0.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | 1976 | 8bb76ce085f212d47a0918b9242cb412e8a9a3f7 | review-and-fix | fixed phone install-sheet overlap on current-base head | pwa regression 1/1; lifecycle DOM 10/10; verify:phone-chrome 437/437 UI |
diff --git a/src/app/globals.css b/src/app/globals.css
index a02c04e193..8195f22659 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2656,11 +2656,11 @@ summary::-webkit-details-marker {
pointer-events: none;
}
-/* A 320px mode home cannot show the full install proposition and its complete
+/* A phone mode home cannot show the full install proposition and its complete
centred empty-state introduction without covering the search composer. While
the transient install sheet is present, keep the real composer visible in
the clear space under the app header and omit only the decorative intro. */
-@media (max-width: 359.98px) {
+@media (max-width: 639.98px) {
body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] > div > section {
justify-content: flex-start;
padding-top: calc(2 * var(--radius-xs));
diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts
index 11ba2cdfef..4e04e1a874 100644
--- a/tests/ui-pwa.spec.ts
+++ b/tests/ui-pwa.spec.ts
@@ -248,8 +248,11 @@ test.describe("Clinical KB PWA", () => {
const viewports = [
{ name: "compact phone", width: 320, height: 720 },
{ name: "phone", width: 390, height: 844 },
+ { name: "wide phone", width: 639, height: 900 },
+ { name: "small tablet", width: 768, height: 1024 },
{ name: "tablet", width: 820, height: 1180 },
{ name: "desktop", width: 1440, height: 1000 },
+ { name: "wide desktop", width: 1920, height: 1080 },
] as const;
for (const viewport of viewports) {
From 81c44aee0b788a5aa93522a09092cddcd12b0bf0 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:41:54 +0800
Subject: [PATCH 15/16] fix(pwa): prevent install prompt layout shift
---
...10987b3597aeef62b9a2fb964975dd5b.record.md | 1 +
src/app/globals.css | 65 ++++++++++++-------
src/components/pwa-lifecycle.tsx | 3 +-
tests/ui-pwa.spec.ts | 34 +++++++---
4 files changed, 72 insertions(+), 31 deletions(-)
create mode 100644 docs/branch-review-records/615deb9148ec106e31cf72902d4a1b2710987b3597aeef62b9a2fb964975dd5b.record.md
diff --git a/docs/branch-review-records/615deb9148ec106e31cf72902d4a1b2710987b3597aeef62b9a2fb964975dd5b.record.md b/docs/branch-review-records/615deb9148ec106e31cf72902d4a1b2710987b3597aeef62b9a2fb964975dd5b.record.md
new file mode 100644
index 0000000000..6a5b00ae82
--- /dev/null
+++ b/docs/branch-review-records/615deb9148ec106e31cf72902d4a1b2710987b3597aeef62b9a2fb964975dd5b.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | 1976 | 54327591bb89f6f9f25f6834fbefc277a9bff80b | review-and-fix | P2 fixed: native install prompt now avoids hero reflow, composer overlap, and prompt-induced CLS | pwa DOM 10/10; PWA Chromium 5/5; format; exact-head merge-tree |
diff --git a/src/app/globals.css b/src/app/globals.css
index 8195f22659..2f704cc2f3 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2656,28 +2656,6 @@ summary::-webkit-details-marker {
pointer-events: none;
}
-/* A phone mode home cannot show the full install proposition and its complete
- centred empty-state introduction without covering the search composer. While
- the transient install sheet is present, keep the real composer visible in
- the clear space under the app header and omit only the decorative intro. */
-@media (max-width: 639.98px) {
- body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] > div > section {
- justify-content: flex-start;
- padding-top: calc(2 * var(--radius-xs));
- }
-
- body:has(.pwa-install-sheet) #main-content[data-phone-footer-owner="hero"] [data-testid="shared-home-empty-state"] {
- gap: 0;
- }
-
- body:has(.pwa-install-sheet)
- #main-content[data-phone-footer-owner="hero"]
- [data-testid="shared-home-empty-state"]
- > section[aria-labelledby="shared-home-empty-state-title"] {
- display: none;
- }
-}
-
.pwa-notice-card {
overflow: hidden;
border: 1px solid var(--border-lux);
@@ -2778,6 +2756,10 @@ summary::-webkit-details-marker {
padding: var(--spacing-icon-sm) var(--pwa-inset-xl) var(--pwa-inset-xl);
}
+.pwa-install-compact-copy {
+ display: none;
+}
+
.pwa-install-tagline {
color: var(--text-heading);
font-size: 0.875rem;
@@ -2850,6 +2832,45 @@ summary::-webkit-details-marker {
grid-template-columns: 1fr;
}
+/* The in-flow phone home composer sits near the viewport centre. Keep the
+ browser-native install prompt below it without moving page content: the full
+ value proposition remains on larger and dock-owned surfaces, while the phone
+ hero gets a concise, action-first card. */
+@media (max-width: 639.98px) {
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-notice-stack {
+ --pwa-notice-bottom-gap: max(0.5rem, var(--safe-area-bottom));
+ }
+
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-grip,
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-tagline,
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-copy,
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-support,
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-benefits {
+ display: none;
+ }
+
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-header {
+ min-height: 3.25rem;
+ padding-block: calc(2 * var(--pwa-inset-xs));
+ }
+
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-body {
+ padding: var(--pwa-inset-sm) var(--pwa-inset-xl) var(--pwa-inset-xl);
+ }
+
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-compact-copy {
+ display: block;
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: var(--pwa-line-height-support);
+ }
+
+ body:has(#main-content[data-phone-footer-owner="hero"]):has(.pwa-install-native-sheet) .pwa-install-actions {
+ margin-top: var(--pwa-inset-sm);
+ }
+}
+
.pwa-action {
transition:
border-color var(--duration-quick) ease,
diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx
index 42b28224b5..0b6db9bc95 100644
--- a/src/components/pwa-lifecycle.tsx
+++ b/src/components/pwa-lifecycle.tsx
@@ -610,7 +610,7 @@ export function PwaLifecycle() {
{showInstall ? (
+
Quick access · No app store
Clinical guidelines on your home screen.
Open it from your device like an app. Private clinical features still require a connection.
diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts
index 4e04e1a874..d5232414b9 100644
--- a/tests/ui-pwa.spec.ts
+++ b/tests/ui-pwa.spec.ts
@@ -263,6 +263,16 @@ test.describe("Clinical KB PWA", () => {
await page.waitForFunction(() => document.documentElement.dataset.pwaDisplayMode === "browser");
await page.evaluate(() => {
+ (window as typeof window & { __pwaPromptLayoutShift?: number }).__pwaPromptLayoutShift = 0;
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ const shift = entry as PerformanceEntry & { hadRecentInput: boolean; value: number };
+ if (!shift.hadRecentInput) {
+ (window as typeof window & { __pwaPromptLayoutShift?: number }).__pwaPromptLayoutShift! += shift.value;
+ }
+ }
+ }).observe({ type: "layout-shift" });
+
const event = new Event("beforeinstallprompt", { cancelable: true });
Object.assign(event, {
prompt: () => Promise.resolve(),
@@ -273,14 +283,21 @@ test.describe("Clinical KB PWA", () => {
const install = page.getByRole("region", { name: "Install Clinical KB" });
await expect(install).toBeVisible();
+ await page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
+ );
await expect(install).toContainText("Clinical guidelines on your home screen.");
await expect(install).toContainText(
"Open it from your device like an app. Private clinical features still require a connection.",
);
await expect(install).toContainText("Free · No app store · Takes a few seconds");
- await expect(install.getByRole("list", { name: "Install benefits" })).toContainText(
- "Quick accessApp-like launchFamiliar workspace",
- );
+ if (viewport.width < 640) {
+ await expect(install.getByText("Quick access · No app store")).toBeVisible();
+ } else {
+ await expect(install.getByRole("list", { name: "Install benefits" })).toContainText(
+ "Quick accessApp-like launchFamiliar workspace",
+ );
+ }
const geometry = await install.evaluate((surface) => {
const rect = surface.getBoundingClientRect();
@@ -315,6 +332,8 @@ test.describe("Clinical KB PWA", () => {
hasInternalScroll: surface.scrollHeight > surface.clientHeight + 1,
pageScrollWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
+ promptLayoutShift:
+ (window as typeof window & { __pwaPromptLayoutShift?: number }).__pwaPromptLayoutShift ?? 0,
};
});
@@ -326,6 +345,9 @@ test.describe("Clinical KB PWA", () => {
expect(geometry.secondaryActionHeight, `${viewport.name}: Not now target`).toBeGreaterThanOrEqual(48);
expect(geometry.dismissActionHeight, `${viewport.name}: Dismiss target`).toBeGreaterThanOrEqual(48);
expect(geometry.overlap, `${viewport.name}: install surface must not overlap search`).toBe(0);
+ expect(geometry.promptLayoutShift, `${viewport.name}: prompt must not shift page content`).toBeLessThanOrEqual(
+ 0.02,
+ );
expect(geometry.hasInternalScroll, `${viewport.name}: complete value proposition should fit`).toBe(false);
expect(geometry.pageScrollWidth, `${viewport.name}: no horizontal overflow`).toBeLessThanOrEqual(
geometry.viewportWidth,
@@ -364,12 +386,8 @@ test.describe("Clinical KB PWA", () => {
scrollHeight: surface.scrollHeight,
overflowY: getComputedStyle(surface).overflowY,
}));
- expect(scrollState.scrollHeight).toBeGreaterThan(scrollState.clientHeight);
+ expect(scrollState.scrollHeight).toBeLessThanOrEqual(scrollState.clientHeight + 1);
expect(scrollState.overflowY).toMatch(/auto|scroll/);
-
- await install.evaluate((surface) => {
- surface.scrollTop = surface.scrollHeight;
- });
await expect(install.getByRole("button", { name: "Install app" })).toBeInViewport();
await expect(install.getByRole("button", { name: "Not now" })).toBeInViewport();
});
From d7449c7a7eb696f5dc847fc435ba0109795f8b21 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 15 Aug 2026 19:51:07 +0800
Subject: [PATCH 16/16] test(ui): register compact PWA styles
---
...c749726901ef79ce50012974b924d01bcc5b55b6dc00d1ad.record.md | 1 +
tests/helpers/style-contracts.ts | 4 ++++
2 files changed, 5 insertions(+)
create mode 100644 docs/branch-review-records/c56fc20eb8b758bec749726901ef79ce50012974b924d01bcc5b55b6dc00d1ad.record.md
diff --git a/docs/branch-review-records/c56fc20eb8b758bec749726901ef79ce50012974b924d01bcc5b55b6dc00d1ad.record.md b/docs/branch-review-records/c56fc20eb8b758bec749726901ef79ce50012974b924d01bcc5b55b6dc00d1ad.record.md
new file mode 100644
index 0000000000..5487a026f4
--- /dev/null
+++ b/docs/branch-review-records/c56fc20eb8b758bec749726901ef79ce50012974b924d01bcc5b55b6dc00d1ad.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | 1976 | 81c44aee0b788a5aa93522a09092cddcd12b0bf0 | review-and-fix | CI blocker fixed: register compact native-install CSS selectors in the unlayered style inventory | style-contract registry 15/15; PWA DOM 10/10; PWA Chromium 5/5; Lighthouse exact-head green |
diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts
index d02c664539..e48e9dae1e 100644
--- a/tests/helpers/style-contracts.ts
+++ b/tests/helpers/style-contracts.ts
@@ -383,11 +383,15 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = {
"PWA benefit list — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-body": "PWA install body — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-copy": "PWA install copy — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-compact-copy":
+ "PWA compact phone proposition — visibility and geometry covered across target widths by ui-pwa",
"pwa-install-dismiss": "PWA install dismissal — target geometry covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-grip":
"PWA phone-sheet grip — responsive sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-header":
"PWA install header — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
+ "pwa-install-native-sheet":
+ "selector scope for browser-native install prompts; descendant compact-card effects are covered by ui-pwa",
"pwa-install-steps":
"PWA manual-install steps — install-sheet journey covered by ui-pwa; effect not contracted yet (#094)",
"pwa-install-support":