Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
9 changes: 8 additions & 1 deletion docs/pwa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
20 changes: 14 additions & 6 deletions src/app/icons/[variant]/route.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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(<BrandIconImage size={conf.size} background={conf.background} inset={conf.inset} />, {
width: conf.size,
height: conf.size,
});
return new ImageResponse(
<BrandIconImage
size={conf.size}
background={conf.background}
inset={conf.inset}
colors={"colors" in conf ? conf.colors : undefined}
/>,
{ width: conf.size, height: conf.size },
);
}
8 changes: 8 additions & 0 deletions src/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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: [
{
Expand Down
56 changes: 50 additions & 6 deletions src/components/pwa-lifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-";
Expand Down Expand Up @@ -42,28 +43,35 @@ 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.
}
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
Expand Down Expand Up @@ -110,6 +118,7 @@ export function PwaLifecycle() {
const isOnline = useSyncExternalStore(subscribeConnectivity, getConnectivitySnapshot, getServerConnectivitySnapshot);
const [connectionRestored, setConnectionRestored] = useState(false);
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showIosHint, setShowIosHint] = useState(false);
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
const [activatedUpdateReady, setActivatedUpdateReady] = useState(false);
const registrationRef = useRef<ServiceWorkerRegistration | null>(null);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 (
<div className="pwa-notice-stack">
Expand Down Expand Up @@ -369,6 +397,22 @@ export function PwaLifecycle() {
</section>
) : null}

{showIosInstallHint ? (
<section className={cardClassName} role="region" aria-labelledby="pwa-ios-install-title" aria-live="polite">
<p id="pwa-ios-install-title" className="text-sm font-semibold">
Install Clinical KB
</p>
<p className="mt-1 text-sm leading-6 text-[color:var(--text-muted)]">
In Safari, tap Share, then Add to Home Screen. Private clinical features still require a connection.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" className={secondaryButtonClassName} onClick={dismissIosHint}>
Not now
</button>
</div>
</section>
) : null}

{showInstall ? (
<section className={cardClassName} role="region" aria-labelledby="pwa-install-title" aria-live="polite">
<p id="pwa-install-title" className="text-sm font-semibold">
Expand Down
21 changes: 14 additions & 7 deletions src/lib/brand-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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 <img> 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))}`;
}

/**
Expand All @@ -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 (
Expand All @@ -42,7 +49,7 @@ export function BrandIconImage({
background,
}}
>
<img src={brandMarkDataUri()} width={px} height={px} alt="" />
<img src={brandMarkDataUri(colors)} width={px} height={px} alt="" />
</div>
);
}
28 changes: 28 additions & 0 deletions tests/pwa-lifecycle.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PwaLifecycle />);

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(<PwaLifecycle />);
expect(screen.queryByRole("region", { name: "Install Clinical KB" })).not.toBeInTheDocument();
} finally {
delete (navigator as { userAgent?: string }).userAgent;
}
});
});
9 changes: 9 additions & 0 deletions tests/pwa-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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");
Expand Down