diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f71a6555d..d34e98657 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -599,3 +599,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-15 | HEAD detached 570e6ba56 + WIP tree | 570e6ba56ae60bea56a32801b9cc96c5a8dfde4f | thorough multi-lens review: WIP RAG/schema + clinical design/UI + architecture/bug-hunt | Changes requested: no P0. Confirmed P1s in WIP � registryCorpusDetailHref typecheck break; ChunkLoadCache error/null poisoning across parallel hydrations; registry cleanup `::uuid` cast abort; corrector GIN unused by query path; new table-facts trgm index expression mismatch vs trgm_matches. Design: production clinical shell stays token/a11y-aligned; favourites nav multi-gradient bars and mockup hex drift fight clinical density. Residual: concurrent cache race, SECURITY DEFINER revoke gaps, schema/migration lifecycle drift, accidental pnpm-lock.yaml. | `npm run typecheck` (red: registry link callers + stale .next apps types); static SQL/expr/diff review; architecture + bug-hunt agents; design-system grep (tokens, reduced-motion, forced-colors). Not run: vitest, verify:*, ensure/browser screenshots, live Supabase/OpenAI. frontend-ui-reviewer subagent blocked by usage limit � design pass done inline. | | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #826) | 3d9ee5f44dea9edb1ef5af28f5f265d88d8b9f29 | PWA hardening implementation (plan Phase 1) | Implemented the three open findings from the 2026-07-17 PWA setup review with zero cache-semantics change: committed the rule-6 retirement worker `public/sw-kill-switch.js` with a five-test lock (`tests/pwa-kill-switch.test.ts`), bound the `offline.html` sha256 to the sw.js `CACHE_VERSION` pairing in `tests/pwa-manifest.test.ts` (drift trap closed), added the `?pwa-dev=0` local teardown to `pwa-lifecycle.tsx` with a dom test proving foreign workers and caches stay untouched, and updated `docs/pwa.md` rules 1 and 6 plus the local-dev cleanup step. Phase 0 of the approved plan (pr-policy `base_ref` checkout fix + the Set-Cookie worker-test case) was found already merged to main and skipped. | Focused Vitest 53/53. `verify:cheap` and the `verify:pr-local` unit stage green except `tests/pdf-extraction-budget.test.ts`, which fails identically on clean main in this container (child-process semantics; baselined twice). `verify:ui` 218 passed with 2 container-baselined pre-existing failures: the `ui-pwa` installability test (Chromium `in-incognito` artifact, reproduced from a clean-main detached worktree with its own server) and the `ui-smoke` document-viewer PDF-canvas mobile test (also fails on clean main `54229f0`; flagged as possible upstream regression). `format:check` clean for repo files. Conditional build/bundle stages deferred to the blocking hosted CI Build job on PR #826. No provider-backed checks run. | +| 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #835) | d46f381ac27b53b1bd5ac0ef77962fbd48cf3aa7 | PWA manifest and install-UX polish (plan Phase 2) | Implemented Phase 2 of the PWA plan with cache semantics untouched: `launch_handler` navigate-existing/auto and `display_override` standalone/minimal-ui in `manifest.ts`; `monochrome-192/512` icon variants rendered as a white alpha-only silhouette from the shared brand mark via `BRAND_MONOCHROME`; a one-time iOS/iPadOS Add to Home Screen hint in `pwa-lifecycle.tsx` (30-day dismissal key, never in standalone, timer-deferred eligibility for the set-state-in-effect lint rule). Docs Installability section updated; manifest screenshots remain deferred per the production-capture precondition. Phase 2 re-scout confirmed zero upstream drift before implementation. | Focused Vitest 55/55. `verify:cheap` 2773 passed/1 failed and `verify:pr-local` unit stage identical — the lone failure is the known container-only `pdf-extraction-budget` artifact (clean-main baselined; hosted CI green on #826). `test:e2e:pwa`: privacy journey passed; icon probes validated both new monochrome PNGs; sole installability error remains the container `in-incognito` artifact. `verify:ui` 219 passed/2 failed — exactly the two clean-main-baselined container artifacts, no new failures. `format:check` clean. Build/bundle stages deferred to the blocking hosted CI Build job on PR #835. No provider-backed checks run. | diff --git a/docs/pwa.md b/docs/pwa.md index da56c7f74..251605547 100644 --- a/docs/pwa.md +++ b/docs/pwa.md @@ -38,7 +38,10 @@ The manifest defines a stable app identity and root scope: - `display` is `standalone`; the document viewport uses `viewport-fit=cover`. - Language and direction are `en-AU` and `ltr`. - Categories are `medical`, `productivity`, and `utilities`; related native applications are not preferred. -- The SVG icon is accompanied by generated 192 px and 512 px PNG icons for both `any` and `maskable` purposes. +- The SVG icon is accompanied by generated 192 px and 512 px PNG icons for the `any`, `maskable`, and `monochrome` + purposes; the monochrome pair is a white alpha-only silhouette that platforms recolour (badges, themed icons). +- `display_override` prefers `standalone` with a `minimal-ui` fallback and never requests `fullscreen`; + `launch_handler` focuses an existing app window (`navigate-existing`, then `auto`) instead of spawning duplicates. - The 180 px Apple icon is opaque so iOS does not render transparency as black or fall back to a page screenshot. - Manifest shortcuts open Ask, Documents, Medication guidance, and Differentials. They are launch shortcuts, not offline features; each destination still requires the normal network/auth capabilities. @@ -53,6 +56,10 @@ The install card is not shown in standalone mode. Choosing **Not now**, or dismi 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 +`clinical-kb-pwa-ios-install-dismissed-at`; the same storage-failure tolerance applies. + The manifest deliberately leaves orientation unrestricted so zoom, rotation, desktop windows, and split-screen use remain available. Manifest screenshots are also omitted until current production UI can be captured and reviewed at the declared form factors; prototype/mockup screenshots must not be advertised as the installed product. diff --git a/src/app/icons/[variant]/route.tsx b/src/app/icons/[variant]/route.tsx index c9ccab94f..eb4fa84ec 100644 --- a/src/app/icons/[variant]/route.tsx +++ b/src/app/icons/[variant]/route.tsx @@ -1,15 +1,18 @@ import { ImageResponse } from "next/og"; -import { BRAND_ICON_FIELD, BrandIconImage } from "@/lib/brand-image"; +import { BRAND_ICON_FIELD, BRAND_MONOCHROME, BrandIconImage } from "@/lib/brand-image"; // PWA icon set referenced by app/manifest.ts. "any" icons are transparent so the // mark's own rounded tile shows; "maskable" icons are full-bleed teal with the -// mark inside the ~72% safe zone so platform circular/rounded masks don't crop it. +// mark inside the ~72% safe zone so platform circular/rounded masks don't crop it; +// "monochrome" icons are a white alpha-only silhouette that platforms recolour. const VARIANTS = { "icon-192": { size: 192, background: "transparent", inset: 1 }, "icon-512": { size: 512, background: "transparent", inset: 1 }, "maskable-192": { size: 192, background: BRAND_ICON_FIELD, inset: 0.72 }, "maskable-512": { size: 512, background: BRAND_ICON_FIELD, inset: 0.72 }, + "monochrome-192": { size: 192, background: "transparent", inset: 1, colors: BRAND_MONOCHROME }, + "monochrome-512": { size: 512, background: "transparent", inset: 1, colors: BRAND_MONOCHROME }, } as const; export function generateStaticParams() { @@ -22,8 +25,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ var const { variant } = await params; const conf = VARIANTS[variant as keyof typeof VARIANTS]; if (!conf) return new Response("Not found", { status: 404 }); - return new ImageResponse(, { - width: conf.size, - height: conf.size, - }); + return new ImageResponse( + , + { width: conf.size, height: conf.size }, + ); } diff --git a/src/app/manifest.ts b/src/app/manifest.ts index f776114d1..2c8b94b61 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -16,6 +16,12 @@ export default function manifest(): MetadataRoute.Manifest { lang: "en-AU", dir: "ltr", display: "standalone", + // Prefer the standalone window but allow a graceful minimal-ui fallback on + // platforms that cannot honour it; never fall back to fullscreen. + display_override: ["standalone", "minimal-ui"], + // Focus the already-open app window on launch instead of spawning a second + // instance; "auto" lets platforms without the capability use their default. + launch_handler: { client_mode: ["navigate-existing", "auto"] }, background_color: APP_THEME_COLORS[DEFAULT_THEME], theme_color: APP_THEME_COLORS[DEFAULT_THEME], categories: ["medical", "productivity", "utilities"], @@ -26,6 +32,8 @@ export default function manifest(): MetadataRoute.Manifest { { src: "/icons/icon-512", type: "image/png", sizes: "512x512", purpose: "any" }, { src: "/icons/maskable-192", type: "image/png", sizes: "192x192", purpose: "maskable" }, { src: "/icons/maskable-512", type: "image/png", sizes: "512x512", purpose: "maskable" }, + { src: "/icons/monochrome-192", type: "image/png", sizes: "192x192", purpose: "monochrome" }, + { src: "/icons/monochrome-512", type: "image/png", sizes: "512x512", purpose: "monochrome" }, ], shortcuts: [ { diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index 1db258cf4..14ce32fcf 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; const SERVICE_WORKER_URL = "/sw.js"; const INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-install-dismissed-at"; +const IOS_INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-ios-install-dismissed-at"; const INSTALL_DISMISSAL_MS = 30 * 24 * 60 * 60 * 1000; const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; const PWA_CACHE_PREFIX = "clinical-kb-pwa-"; @@ -42,12 +43,12 @@ function isStandaloneDisplay() { ); } -function wasInstallRecentlyDismissed() { +function wasInstallRecentlyDismissed(key: string = INSTALL_DISMISSAL_KEY) { try { - const dismissedAt = Number(window.localStorage.getItem(INSTALL_DISMISSAL_KEY)); + const dismissedAt = Number(window.localStorage.getItem(key)); if (!Number.isFinite(dismissedAt) || dismissedAt <= 0) return false; if (Date.now() - dismissedAt < INSTALL_DISMISSAL_MS) return true; - window.localStorage.removeItem(INSTALL_DISMISSAL_KEY); + window.localStorage.removeItem(key); } catch { // Storage can be unavailable in private/restricted contexts. Installation // remains a progressive enhancement, so a storage failure is non-fatal. @@ -55,15 +56,22 @@ function wasInstallRecentlyDismissed() { return false; } -function rememberInstallDismissal() { +function rememberInstallDismissal(key: string = INSTALL_DISMISSAL_KEY) { try { - window.localStorage.setItem(INSTALL_DISMISSAL_KEY, String(Date.now())); + window.localStorage.setItem(key, String(Date.now())); } catch { // See wasInstallRecentlyDismissed: the prompt can still be dismissed for // this render even when persistence is unavailable. } } +function isIosBrowser() { + const { userAgent, platform, maxTouchPoints } = window.navigator; + if (/iPad|iPhone|iPod/.test(userAgent)) return true; + // iPadOS 13+ reports a macOS user agent but exposes multi-touch. + return platform === "MacIntel" && (maxTouchPoints ?? 0) > 1; +} + async function teardownLocalPwa() { try { // Exact-match the owned worker URL: a suffix check would also catch an @@ -110,6 +118,7 @@ export function PwaLifecycle() { const isOnline = useSyncExternalStore(subscribeConnectivity, getConnectivitySnapshot, getServerConnectivitySnapshot); const [connectionRestored, setConnectionRestored] = useState(false); const [installPrompt, setInstallPrompt] = useState(null); + const [showIosHint, setShowIosHint] = useState(false); const [waitingWorker, setWaitingWorker] = useState(null); const [activatedUpdateReady, setActivatedUpdateReady] = useState(false); const registrationRef = useRef(null); @@ -175,6 +184,24 @@ export function PwaLifecycle() { }; }, []); + useEffect(() => { + // iOS/iPadOS never fires beforeinstallprompt, so surface a one-time manual + // Add to Home Screen hint instead — never in standalone mode, and never + // again within the dismissal window. Deferred a tick so the client-only + // eligibility check cannot diverge from the server-rendered markup. + const timer = window.setTimeout(() => { + if (!isIosBrowser() || isStandaloneDisplay()) return; + if (wasInstallRecentlyDismissed(IOS_INSTALL_DISMISSAL_KEY)) return; + setShowIosHint(true); + }, 0); + return () => window.clearTimeout(timer); + }, []); + + const dismissIosHint = () => { + rememberInstallDismissal(IOS_INSTALL_DISMISSAL_KEY); + setShowIosHint(false); + }; + useEffect(() => { if (!("serviceWorker" in navigator) || window.isSecureContext === false) return; @@ -326,7 +353,8 @@ export function PwaLifecycle() { const showUpdate = isOnline && (Boolean(waitingWorker) || activatedUpdateReady); const showInstall = isOnline && !showUpdate && Boolean(installPrompt); - if (isOnline && !connectionRestored && !showInstall && !showUpdate) return null; + const showIosInstallHint = isOnline && !showUpdate && !showInstall && showIosHint; + if (isOnline && !connectionRestored && !showInstall && !showUpdate && !showIosInstallHint) return null; return (
@@ -369,6 +397,22 @@ export function PwaLifecycle() { ) : null} + {showIosInstallHint ? ( +
+

+ Install Clinical KB +

+

+ In Safari, tap Share, then Add to Home Screen. Private clinical features still require a connection. +

+
+ +
+
+ ) : null} + {showInstall ? (

diff --git a/src/lib/brand-image.tsx b/src/lib/brand-image.tsx index 803ffb35c..cc47d5f78 100644 --- a/src/lib/brand-image.tsx +++ b/src/lib/brand-image.tsx @@ -2,16 +2,21 @@ // routes (apple-icon, PWA maskable/any icons, opengraph-image). All derive from // the single geometry source in ./brand-mark so raster app icons never drift // from the in-app mark or the favicon. -import { BRAND_LIGHT, brandMarkSvg } from "@/lib/brand-mark"; +import { BRAND_LIGHT, brandMarkSvg, type BrandColors } from "@/lib/brand-mark"; /** Teal field used behind full-bleed (apple / maskable) icons. */ export const BRAND_ICON_FIELD = BRAND_LIGHT.tile; -/** data: URI of the flat brand-mark SVG (light palette) for inside - * ImageResponse. Satori rasterises the SVG server-side, so no browser CSP or - * network is involved. `#` in colours is percent-encoded by encodeURIComponent. */ -export function brandMarkDataUri(): string { - return `data:image/svg+xml,${encodeURIComponent(brandMarkSvg(BRAND_LIGHT))}`; +/** Single-colour palette for manifest `monochrome` icons: platforms read only + * the alpha channel and recolour the silhouette themselves, so the tile and + * ink collapse into one white glyph on a transparent field. */ +export const BRAND_MONOCHROME: BrandColors = { tile: "#ffffff", ink: "#ffffff" }; + +/** data: URI of the flat brand-mark SVG for inside ImageResponse. Satori + * rasterises the SVG server-side, so no browser CSP or network is involved. + * `#` in colours is percent-encoded by encodeURIComponent. */ +export function brandMarkDataUri(colors: BrandColors = BRAND_LIGHT): string { + return `data:image/svg+xml,${encodeURIComponent(brandMarkSvg(colors))}`; } /** @@ -25,10 +30,12 @@ export function BrandIconImage({ size, background = "transparent", inset = 1, + colors = BRAND_LIGHT, }: { size: number; background?: string; inset?: number; + colors?: BrandColors; }) { const px = Math.round(size * inset); return ( @@ -42,7 +49,7 @@ export function BrandIconImage({ background, }} > - +

); } diff --git a/tests/pwa-lifecycle.dom.test.tsx b/tests/pwa-lifecycle.dom.test.tsx index 4214fddc2..f6c395f52 100644 --- a/tests/pwa-lifecycle.dom.test.tsx +++ b/tests/pwa-lifecycle.dom.test.tsx @@ -199,4 +199,32 @@ describe("PwaLifecycle", () => { expect(screen.queryByRole("region", { name: "An update is ready" })).not.toBeInTheDocument(); }); + + it("shows the one-time iOS Add to Home Screen hint and honours its dismissal window", async () => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + }); + try { + const user = userEvent.setup(); + 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); + + await user.click(screen.getByRole("button", { name: "Not now" })); + await waitFor(() => + expect(screen.queryByRole("region", { name: "Install Clinical KB" })).not.toBeInTheDocument(), + ); + expect(Number(window.localStorage.getItem("clinical-kb-pwa-ios-install-dismissed-at"))).toBeGreaterThan(0); + + unmount(); + render(); + expect(screen.queryByRole("region", { name: "Install Clinical KB" })).not.toBeInTheDocument(); + } finally { + delete (navigator as { userAgent?: string }).userAgent; + } + }); }); diff --git a/tests/pwa-manifest.test.ts b/tests/pwa-manifest.test.ts index 186d703e2..7f09474b8 100644 --- a/tests/pwa-manifest.test.ts +++ b/tests/pwa-manifest.test.ts @@ -31,6 +31,7 @@ describe("PWA manifest and public bootstrap resources", () => { for (const size of ["192x192", "512x512"]) { expect(icons).toEqual(expect.arrayContaining([expect.objectContaining({ sizes: size, purpose: "any" })])); expect(icons).toEqual(expect.arrayContaining([expect.objectContaining({ sizes: size, purpose: "maskable" })])); + expect(icons).toEqual(expect.arrayContaining([expect.objectContaining({ sizes: size, purpose: "monochrome" })])); } for (const icon of icons) { @@ -54,6 +55,14 @@ describe("PWA manifest and public bootstrap resources", () => { } }); + it("declares conservative launch and display fallbacks", () => { + // Focus the existing app window on launch instead of spawning duplicates, + // and degrade standalone to minimal-ui — never fullscreen — so browser + // chrome and zoom stay reachable. + expect(appManifest.launch_handler).toEqual({ client_mode: ["navigate-existing", "auto"] }); + expect(appManifest.display_override).toEqual(["standalone", "minimal-ui"]); + }); + it("does not advertise unsupported sensitive-capability handlers", () => { expect(appManifest).not.toHaveProperty("share_target"); expect(appManifest).not.toHaveProperty("file_handlers");