From c9bc9f0958cdba133810003c3ede13882d0f5840 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 17:28:26 +0000 Subject: [PATCH 1/8] Install browser system packages from the cache when the Ubuntu mirror is down Since about 14:30Z on 11 Nov every browser job fails because the runner cannot resolve azure.archive.ubuntu.com, and Playwright's install-deps always runs apt-get update first. The seed step already copies the main-saved packages for this exact runner image into apt's archive folder, so the setup now installs those as local files with downloads switched off, installs the browsers, and passes only if each one launches. If any part fails it falls back to the online path as before. The shared-mode job also restores WebKit's cached packages and uses the same script for both engines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmrqgwa3hCAtaMf9JT --- .github/workflows/ward-flow.yml | 17 ++++-- scripts/ward-ci-public/install-chromium.mjs | 59 ++++++++++++++++++++- tests/ward-browser-install.test.ts | 48 +++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ward-flow.yml b/.github/workflows/ward-flow.yml index e788f42626..ec29641f4d 100644 --- a/.github/workflows/ward-flow.yml +++ b/.github/workflows/ward-flow.yml @@ -374,18 +374,27 @@ jobs: restore-keys: | ward-release-apt-${{ runner.os }}-chromium-${{ steps.shared-version.outputs.version }}- ward-release-apt-${{ runner.os }}-${{ steps.shared-version.outputs.version }}- + # WebKit's own packages, so the offline install covers both engines when the mirror is down. + - name: Restore release WebKit system packages + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cache/ward-release-apt-webkit + key: ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}-${{ steps.shared-version.outputs.image }} + restore-keys: | + ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}- - name: Seed release system packages if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} - run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" + run: | + node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" + node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt-webkit" - name: Install Chromium and WebKit for the shared-mode journeys id: shared-chromium timeout-minutes: 10 if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} env: PLAYWRIGHT_CACHE_HIT: ${{ steps.shared-cache.outputs.cache-hit }} - run: | - node scripts/ward-ci-public/install-chromium.mjs - node node_modules/playwright/cli.js install --with-deps webkit + run: node scripts/ward-ci-public/install-chromium.mjs chromium webkit - name: Run the shared-mode browser journey timeout-minutes: 20 if: ${{ !cancelled() && steps.shared-chromium.outcome == 'success' }} diff --git a/scripts/ward-ci-public/install-chromium.mjs b/scripts/ward-ci-public/install-chromium.mjs index cdaa7da064..e88a3d9af5 100644 --- a/scripts/ward-ci-public/install-chromium.mjs +++ b/scripts/ward-ci-public/install-chromium.mjs @@ -1,6 +1,8 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; +import { APT_ARCHIVES } from "./apt-archive-cache.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); const cli = resolve(root, "node_modules/playwright/cli.js"); @@ -21,7 +23,10 @@ export function waitForPackageManager(deadline, now = Date.now) { /** * Six-minute shared budget, two attempts per operation; the workflow adds an eight-minute outer bound. - * @param {{ browsers?: string[], cacheHit?: boolean, execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options + * When the seed step has left cached system packages in apt's archive folder, they are installed + * first with no network at all, then each browser must launch; only if that fails does the setup + * fall back to Playwright's own apt-get, which needs the runner's Ubuntu mirror. + * @param {{ browsers?: string[], cacheHit?: boolean, cachedDebs?: string[], execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options */ export function installBrowsers({ browsers = ["chromium"], @@ -30,6 +35,7 @@ export function installBrowsers({ now = Date.now, log = console.log, waitForIdle = execute === runBounded ? (deadline) => waitForPackageManager(deadline, now) : () => {}, + cachedDebs = execute === runBounded ? archiveDebs() : [], } = {}) { if ( !browsers.length || @@ -39,6 +45,7 @@ export function installBrowsers({ throw new Error("Invalid browser selection; name unique supported engines"); const label = browsers.length === 1 && browsers[0] === "chromium" ? "Chromium" : "Playwright browser"; const deadline = now() + 360_000; + if (cachedDebs.length && installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) === 0) return 0; const operations = cacheHit ? [ ["install-deps", ...browsers], @@ -67,6 +74,54 @@ export function installBrowsers({ return 0; } +/** The cached .deb files the seed step copied in; none when the cache missed. */ +export function archiveDebs(dir = APT_ARCHIVES) { + return existsSync(dir) + ? readdirSync(dir) + .filter((name) => name.endsWith(".deb")) + .sort() + .map((name) => join(dir, name)) + : []; +} + +/** A fixed script; the Playwright path and the engine names arrive as arguments, never as code. */ +const LAUNCH_PROBE = `const [playwright, ...names] = process.argv.slice(1); +const pw = require(playwright); +(async () => { + for (const name of names) { + const browser = await pw[name].launch(); + await browser.close(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +});`; + +/** + * The runner's Ubuntu mirror can be unreachable for hours (11 Nov 2026: "Temporary failure + * resolving 'azure.archive.ubuntu.com'"), and Playwright's install-deps always runs apt-get update + * first. Install the cached packages as local files with downloads switched off, fetch the browsers + * alone, and pass only if every browser launches. Any failure hands back to the online path. + */ +function installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) { + log(`${label} setup: installing ${cachedDebs.length} cached system packages without the network.`); + const steps = [ + ["sudo", ["apt-get", "install", "-y", "--no-download", "--no-install-recommends", ...cachedDebs], 120_000], + [process.execPath, [cli, "install", ...browsers], 120_000], + [process.execPath, ["-e", LAUNCH_PROBE, resolve(root, "node_modules/playwright"), ...browsers], 60_000], + ]; + for (const [command, args, limit] of steps) { + const remaining = deadline - now(); + const result = remaining > 0 ? execute(command, args, Math.min(limit, remaining)) : undefined; + if (!result || result.error || result.status !== 0) { + log(`${label} setup: the offline install did not finish; trying the runner's apt mirror.`); + return 1; + } + } + log(`${label} setup: installed from cached system packages; every browser launches.`); + return 0; +} + // Existing consumers retain their default Chromium-only command and retry budget. /** Compatibility entry point for callers requesting Chromium only. @param {Parameters[0]} [options] */ export function installChromium(options) { diff --git a/tests/ward-browser-install.test.ts b/tests/ward-browser-install.test.ts index 7d5bbcb3b9..58159bff75 100644 --- a/tests/ward-browser-install.test.ts +++ b/tests/ward-browser-install.test.ts @@ -34,6 +34,54 @@ describe("bounded release browser setup", () => { ]); }); + it("installs cached system packages without the network and stops once every browser launches", () => { + const commands: string[][] = []; + expect( + installChromium({ + browsers: ["chromium", "webkit"], + cacheHit: true, + cachedDebs: ["/var/cache/apt/archives/a.deb", "/var/cache/apt/archives/b.deb"], + log: () => {}, + execute(command, args) { + commands.push([command, ...args]); + return { status: 0 }; + }, + }), + ).toBe(0); + expect(commands).toHaveLength(3); + expect(commands[0]).toEqual([ + "sudo", + "apt-get", + "install", + "-y", + "--no-download", + "--no-install-recommends", + "/var/cache/apt/archives/a.deb", + "/var/cache/apt/archives/b.deb", + ]); + expect(commands[1].slice(2)).toEqual(["install", "chromium", "webkit"]); + expect(commands[2][1]).toBe("-e"); + expect(commands[2].slice(-2)).toEqual(["chromium", "webkit"]); + expect(commands.flat()).not.toContain("install-deps"); + expect(commands.flat()).not.toContain("--with-deps"); + }); + + it("falls back to the apt mirror when a browser does not launch after the offline install", () => { + const commands: string[][] = []; + expect( + installChromium({ + cachedDebs: ["/var/cache/apt/archives/a.deb"], + log: () => {}, + execute(command, args) { + commands.push([command, ...args]); + return { status: args[0] === "-e" ? 1 : 0 }; + }, + }), + ).toBe(0); + expect(commands).toHaveLength(4); + expect(commands[3].slice(2)).toEqual(["install", "--with-deps", "chromium"]); + }); + it("refuses an unsupported or empty engine selection without invoking a command", () => { const execute = () => { throw new Error("must not start an installation"); From a5dd0ff6bd545858d1bf6a444ccf58b43ceabd96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 17:31:11 +0000 Subject: [PATCH 2/8] Restore WebKit's cached packages from the path the save step used The cache path is part of the cache version, so a different restore path never finds the release gate's WebKit cache. Seed Chromium's packages, then restore WebKit's into the same folder and seed again. (Codex review) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmrqgwa3hCAtaMf9JT --- .github/workflows/ward-flow.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ward-flow.yml b/.github/workflows/ward-flow.yml index ec29641f4d..c7ce0b48c1 100644 --- a/.github/workflows/ward-flow.yml +++ b/.github/workflows/ward-flow.yml @@ -374,20 +374,22 @@ jobs: restore-keys: | ward-release-apt-${{ runner.os }}-chromium-${{ steps.shared-version.outputs.version }}- ward-release-apt-${{ runner.os }}-${{ steps.shared-version.outputs.version }}- - # WebKit's own packages, so the offline install covers both engines when the mirror is down. + - name: Seed release system packages + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} + run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" + # WebKit's own packages too, so the offline install covers both engines when the mirror is down. + # Same path as the save step, because the path is part of the cache version. - name: Restore release WebKit system packages if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: - path: ~/.cache/ward-release-apt-webkit + path: ~/.cache/ward-release-apt key: ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}-${{ steps.shared-version.outputs.image }} restore-keys: | ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}- - - name: Seed release system packages + - name: Seed release WebKit system packages if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} - run: | - node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" - node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt-webkit" + run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" - name: Install Chromium and WebKit for the shared-mode journeys id: shared-chromium timeout-minutes: 10 From 48550c2991335c5fe71c2fa407f79396fb539b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:22:59 +0000 Subject: [PATCH 3/8] Draw Home's since line and Beds that fit in Home's first frame Both cards were fetched when the browser went idle, so they arrived about a second after Home drew and pushed My list and Later today down: 0.134 layout shift on a desktop, over the 0.1 line, in all three release gate runs. They are now fetched as soon as Home's code runs and drawn straight away once loaded (home-early-card.tsx), so they normally land in Home's first frame. They stay off the first load: Home measures 584.3 KB of its 586 KB limit. Local production build: desktop 0.000 in 10 of 10 loads (was up to 0.134), phone about 0.014. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK (cherry picked from commit 8cb48515d3bbeaf7c652012b6a2239848a61ad47) --- .../coordinator/home-early-card.tsx | 39 +++++++++++++++++++ .../coordinator/home-list-lazy.tsx | 6 ++- .../coordinator/home-since-banner-lazy.tsx | 13 ++----- 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/components/ward-management/coordinator/home-early-card.tsx diff --git a/src/components/ward-management/coordinator/home-early-card.tsx b/src/components/ward-management/coordinator/home-early-card.tsx new file mode 100644 index 0000000000..4ea9e5afd6 --- /dev/null +++ b/src/components/ward-management/coordinator/home-early-card.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState, type ComponentType } from "react"; + +/** + * A small Home card kept off the first load whose code is fetched as soon as Home's own code runs, + * not when the browser is idle (11 Nov 2026). Home draws on the client after its data is ready, by + * which time the card's code has normally arrived, so the card draws in Home's first frame instead + * of arriving a second later and pushing the cards under it down (0.134 layout shift on a desktop, + * over the 0.1 line). If the code is still on its way the card draws as soon as it lands, as + * `next/dynamic` would. Errors are ignored: the card simply stays away. + */ +export function earlyHomeCard

(load: () => Promise>): ComponentType

{ + let loaded: ComponentType

| undefined; + const pending = + typeof window === "undefined" + ? undefined + : load().then( + (component) => { + loaded = component; + }, + () => undefined, + ); + function EarlyHomeCard(props: P) { + const [Card, setCard] = useState | undefined>(() => loaded); + useEffect(() => { + if (Card) return; + let live = true; + void pending?.then(() => { + if (live && loaded) setCard(() => loaded); + }); + return () => { + live = false; + }; + }, [Card]); + return Card ? : null; + } + return EarlyHomeCard; +} diff --git a/src/components/ward-management/coordinator/home-list-lazy.tsx b/src/components/ward-management/coordinator/home-list-lazy.tsx index b1ada57cba..85da3ad4c6 100644 --- a/src/components/ward-management/coordinator/home-list-lazy.tsx +++ b/src/components/ward-management/coordinator/home-list-lazy.tsx @@ -3,6 +3,7 @@ import dynamic from "next/dynamic"; import { preloadWhenIdle } from "../preload-when-idle"; +import { earlyHomeCard } from "./home-early-card"; // A1K-01 / DR-03 (26 Oct 2026): the server draws My list's rows before their code arrives, so their // styles load with Home. Without them the rows paint as plain text, then grow into cards and push // Home down (0.16 on a phone). My list itself now loads with the page (below). @@ -27,10 +28,11 @@ export { MyListCard } from "./my-list-card"; export const HomePlacementDrawer = dynamic(() => loadPlacementDrawer().then((m) => m.HomePlacementDrawer), { loading: () => null, }); -export const BedsGapCard = dynamic(() => loadBedsGapCard().then((m) => m.BedsGapCard), { loading: () => null }); +// Fetched as soon as Home's code runs and drawn in Home's first frame, so it never pushes Later +// today down as it arrives (home-early-card.tsx). +export const BedsGapCard = earlyHomeCard(() => loadBedsGapCard().then((m) => m.BedsGapCard)); preloadWhenIdle(loadPlacementDrawer); -preloadWhenIdle(loadBedsGapCard); // H-0977: Home's in-place "Release bed" sheet, fetched on first use so it stays off the first load. export const HomeReleaseSheet = dynamic(() => import("./home-release-sheet").then((m) => m.HomeReleaseSheet), { diff --git a/src/components/ward-management/coordinator/home-since-banner-lazy.tsx b/src/components/ward-management/coordinator/home-since-banner-lazy.tsx index 83e52f2a40..16aaa60e50 100644 --- a/src/components/ward-management/coordinator/home-since-banner-lazy.tsx +++ b/src/components/ward-management/coordinator/home-since-banner-lazy.tsx @@ -1,18 +1,13 @@ "use client"; -import dynamic from "next/dynamic"; - -import { preloadWhenIdle } from "../preload-when-idle"; +import { earlyHomeCard } from "./home-early-card"; /** * Phone budget: the "since you were last here" line brings the last-look comparison and its sheet * with it. It renders nothing until something is new, so it stays off Home's first load and is - * fetched when the browser is idle, as Later today is (see home-activity-lazy.tsx). + * fetched as soon as Home's code runs, so it draws in Home's first frame and never pushes My list + * down as it arrives (home-early-card.tsx). */ const loadSinceBanner = () => import("./home-since-banner"); -export const HomeSinceBanner = dynamic(() => loadSinceBanner().then((m) => m.HomeSinceBanner), { - loading: () => null, -}); - -preloadWhenIdle(loadSinceBanner); +export const HomeSinceBanner = earlyHomeCard(() => loadSinceBanner().then((m) => m.HomeSinceBanner)); From 6ee387b41ec383f7df094114677cefce6ba61114 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:38:59 +0000 Subject: [PATCH 4/8] Hold Home's columns until its two early cards arrive Replaces home-early-card.tsx with the reviewed load-early.tsx and useHomeCardsSettled (patch from the Home layout shift review): - Home's columns stay hidden, keeping their space, until the since line and Beds that fit have loaded, or 1.5 s, so a late card on a slow device no longer lands on rows already shown. - A failed fetch is tried again the next time the card mounts. - A phone, which never shows Beds that fit, still fetches it only when idle, so Data Saver and 2G keep PERF-06. Local production build: desktop 0.000 in 10 of 10 loads, phone at most 0.018; Home 584.8 of 586 KB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK (cherry picked from commit 9e7516f8f71763dbc0c3a0ceaf7464104517e2d3) --- .../coordinator/coordinator-screen.tsx | 4 ++ .../coordinator/home-cards-settled.ts | 43 +++++++++++++++++ .../coordinator/home-early-card.tsx | 39 --------------- .../coordinator/home-list-lazy.tsx | 22 +++++++-- .../coordinator/home-since-banner-lazy.tsx | 14 +++--- .../coordinator/home.module.css | 6 +++ src/components/ward-management/load-early.tsx | 48 +++++++++++++++++++ 7 files changed, 126 insertions(+), 50 deletions(-) create mode 100644 src/components/ward-management/coordinator/home-cards-settled.ts delete mode 100644 src/components/ward-management/coordinator/home-early-card.tsx create mode 100644 src/components/ward-management/load-early.tsx diff --git a/src/components/ward-management/coordinator/coordinator-screen.tsx b/src/components/ward-management/coordinator/coordinator-screen.tsx index 9267d06092..6dcc0fa971 100644 --- a/src/components/ward-management/coordinator/coordinator-screen.tsx +++ b/src/components/ward-management/coordinator/coordinator-screen.tsx @@ -74,6 +74,7 @@ import cards from "./home-cards.module.css"; import styles from "./home.module.css"; import { HomeEdPressure } from "./home-ed-pressure"; import { PlannedList } from "./home-activity-lazy"; +import { useHomeCardsSettled } from "./home-cards-settled"; import { useHomeLayout } from "./home-layout"; import { BedsGapCard, HomePlacementDrawer, HomeReleaseSheet, MyListCard } from "./home-list-lazy"; import list from "./home-list.module.css"; @@ -592,6 +593,8 @@ export function CoordinatorScreen() { // Plan item 2i: the hero's Act now is main's service-scoped Act now figure. const heroActNow = actNowCount; const chosenAlert = selectedAlertId ? alertEntries.find((entry) => entry.item.id === selectedAlertId) : undefined; + // Layout shift on Home (11 Nov 2026): the columns appear once the cards above My list and Later today have arrived. + const cardsSettled = useHomeCardsSettled(!isPhone && !chosenAlert); const releaseAlert = releaseAlertId ? alertEntries.find((entry) => entry.item.id === releaseAlertId) : undefined; const waitEntries = alertEntries.filter((entry) => entry.group === "wait"); // Home flow mock-up (Q340): a waiting alert is a mark on its patient's Flow row. @@ -974,6 +977,7 @@ export function CoordinatorScreen() { data-testid="ward-coordinator-region-grid" data-layout={layout} data-shortlist-open={hasPanelSubject} + data-cards-pending={cardsSettled ? undefined : "true"} ref={queueFocusRef} >

diff --git a/src/components/ward-management/coordinator/home-cards-settled.ts b/src/components/ward-management/coordinator/home-cards-settled.ts new file mode 100644 index 0000000000..e45f150460 --- /dev/null +++ b/src/components/ward-management/coordinator/home-cards-settled.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; + +import { bedsGapCard } from "./home-list-lazy"; +import { sinceBanner } from "./home-since-banner-lazy"; + +/** Home never stays hidden longer than this for a card that is slow or fails to load. */ +export const HOME_CARDS_WAIT_MS = 1500; + +/** + * Layout shift on Home (11 Nov 2026): Home draws on the client, and the two cards it shows at rest + * from split code (the "since you were last here" line, and Beds that fit off a phone) landed a + * moment after My list and Later today and pushed them down (0.106 on a desktop, 0.064 on a phone). + * Home's columns now stay hidden, still taking their space, until those cards have arrived, then + * appear whole. Both are fetched as soon as Home's code runs, so the wait is normally nothing. + * After `HOME_CARDS_WAIT_MS` Home shows anyway and the late card drops in, as before. + */ +export function useHomeCardsSettled(needBeds: boolean): boolean { + const subscribe = useCallback( + (listener: () => void) => { + const stopBanner = sinceBanner.subscribe(listener); + const stopBeds = needBeds ? bedsGapCard.subscribe(listener) : () => undefined; + return () => { + stopBanner(); + stopBeds(); + }; + }, + [needBeds], + ); + const loaded = useSyncExternalStore( + subscribe, + () => sinceBanner.ready() && (!needBeds || bedsGapCard.ready()), + () => false, + ); + const [waitedOut, setWaitedOut] = useState(false); + useEffect(() => { + if (loaded || waitedOut) return; + const timer = window.setTimeout(() => setWaitedOut(true), HOME_CARDS_WAIT_MS); + return () => window.clearTimeout(timer); + }, [loaded, waitedOut]); + return loaded || waitedOut; +} diff --git a/src/components/ward-management/coordinator/home-early-card.tsx b/src/components/ward-management/coordinator/home-early-card.tsx deleted file mode 100644 index 4ea9e5afd6..0000000000 --- a/src/components/ward-management/coordinator/home-early-card.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { useEffect, useState, type ComponentType } from "react"; - -/** - * A small Home card kept off the first load whose code is fetched as soon as Home's own code runs, - * not when the browser is idle (11 Nov 2026). Home draws on the client after its data is ready, by - * which time the card's code has normally arrived, so the card draws in Home's first frame instead - * of arriving a second later and pushing the cards under it down (0.134 layout shift on a desktop, - * over the 0.1 line). If the code is still on its way the card draws as soon as it lands, as - * `next/dynamic` would. Errors are ignored: the card simply stays away. - */ -export function earlyHomeCard

(load: () => Promise>): ComponentType

{ - let loaded: ComponentType

| undefined; - const pending = - typeof window === "undefined" - ? undefined - : load().then( - (component) => { - loaded = component; - }, - () => undefined, - ); - function EarlyHomeCard(props: P) { - const [Card, setCard] = useState | undefined>(() => loaded); - useEffect(() => { - if (Card) return; - let live = true; - void pending?.then(() => { - if (live && loaded) setCard(() => loaded); - }); - return () => { - live = false; - }; - }, [Card]); - return Card ? : null; - } - return EarlyHomeCard; -} diff --git a/src/components/ward-management/coordinator/home-list-lazy.tsx b/src/components/ward-management/coordinator/home-list-lazy.tsx index 85da3ad4c6..9918d2efb8 100644 --- a/src/components/ward-management/coordinator/home-list-lazy.tsx +++ b/src/components/ward-management/coordinator/home-list-lazy.tsx @@ -2,8 +2,10 @@ import dynamic from "next/dynamic"; +import { WF_PHONE_QUERY } from "@/components/wf"; + +import { loadEarly } from "../load-early"; import { preloadWhenIdle } from "../preload-when-idle"; -import { earlyHomeCard } from "./home-early-card"; // A1K-01 / DR-03 (26 Oct 2026): the server draws My list's rows before their code arrives, so their // styles load with Home. Without them the rows paint as plain text, then grow into cards and push // Home down (0.16 on a phone). My list itself now loads with the page (below). @@ -16,7 +18,6 @@ import "@/components/ward-management/alerts/alerts.module.css"; * idle, as the shortlist panels are (see shortlist-panel-lazy.tsx). */ const loadPlacementDrawer = () => import("./home-placement-drawer"); -const loadBedsGapCard = () => import("./beds-gap-card"); /* * A1K-01 (26 Oct 2026): My list is the first thing on Home, so it now loads with the page. As a lazy @@ -28,11 +29,22 @@ export { MyListCard } from "./my-list-card"; export const HomePlacementDrawer = dynamic(() => loadPlacementDrawer().then((m) => m.HomePlacementDrawer), { loading: () => null, }); -// Fetched as soon as Home's code runs and drawn in Home's first frame, so it never pushes Later -// today down as it arrives (home-early-card.tsx). -export const BedsGapCard = earlyHomeCard(() => loadBedsGapCard().then((m) => m.BedsGapCard)); + +/* + * Beds that fit sits above Later today at rest on a wider screen. Arriving late, it pushed Later + * today down about 390 px (layout shift on Home, 11 Nov 2026), so off a phone it is fetched as soon + * as Home's code runs and Home holds its columns until it arrives (`useHomeCardsSettled`). A phone + * never shows it, so a phone still fetches it only when idle. + */ +export const bedsGapCard = loadEarly(() => import("./beds-gap-card").then((m) => m.BedsGapCard)); +export const BedsGapCard = bedsGapCard.Card; preloadWhenIdle(loadPlacementDrawer); +const wide = + typeof window !== "undefined" && + (typeof window.matchMedia !== "function" || !window.matchMedia(WF_PHONE_QUERY).matches); +if (wide) bedsGapCard.start(); +else preloadWhenIdle(async () => bedsGapCard.start()); // H-0977: Home's in-place "Release bed" sheet, fetched on first use so it stays off the first load. export const HomeReleaseSheet = dynamic(() => import("./home-release-sheet").then((m) => m.HomeReleaseSheet), { diff --git a/src/components/ward-management/coordinator/home-since-banner-lazy.tsx b/src/components/ward-management/coordinator/home-since-banner-lazy.tsx index 16aaa60e50..8eb312ed6e 100644 --- a/src/components/ward-management/coordinator/home-since-banner-lazy.tsx +++ b/src/components/ward-management/coordinator/home-since-banner-lazy.tsx @@ -1,13 +1,15 @@ "use client"; -import { earlyHomeCard } from "./home-early-card"; +import { loadEarly } from "../load-early"; /** * Phone budget: the "since you were last here" line brings the last-look comparison and its sheet - * with it. It renders nothing until something is new, so it stays off Home's first load and is - * fetched as soon as Home's code runs, so it draws in Home's first frame and never pushes My list - * down as it arrives (home-early-card.tsx). + * with it, so it stays off Home's first load. It is on screen at rest (before any review it counts + * from the start of the shift), so it is fetched as soon as Home's code runs and Home holds its + * columns until it arrives (`useHomeCardsSettled`): arriving late, it pushed My list down (layout + * shift on Home, 11 Nov 2026). */ -const loadSinceBanner = () => import("./home-since-banner"); +export const sinceBanner = loadEarly(() => import("./home-since-banner").then((m) => m.HomeSinceBanner)); +export const HomeSinceBanner = sinceBanner.Card; -export const HomeSinceBanner = earlyHomeCard(() => loadSinceBanner().then((m) => m.HomeSinceBanner)); +sinceBanner.start(); diff --git a/src/components/ward-management/coordinator/home.module.css b/src/components/ward-management/coordinator/home.module.css index ad593e57c9..5fe1a33507 100644 --- a/src/components/ward-management/coordinator/home.module.css +++ b/src/components/ward-management/coordinator/home.module.css @@ -316,6 +316,12 @@ min-width: 0; } +/* Layout shift on Home (11 Nov 2026): held hidden, still taking its space, until the cards at the + top of each column have arrived (useHomeCardsSettled), so nothing lands on rows already shown. */ +.grid[data-cards-pending] { + visibility: hidden; +} + @media (min-width: 62.5625rem) { /* Three equal columns at one height, a little under a window tall (the shell's rail height less room for the hero), each scrolling inside itself, so the page itself barely scrolls. Tablet diff --git a/src/components/ward-management/load-early.tsx b/src/components/ward-management/load-early.tsx new file mode 100644 index 0000000000..5f246ef88e --- /dev/null +++ b/src/components/ward-management/load-early.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { type ComponentType, useSyncExternalStore } from "react"; + +/** + * A code-split card that Home shows at rest (the "since you were last here" line, Beds that fit). + * It stays out of the first load like any lazy card, but `start` fetches it as soon as Home's code + * runs instead of when the browser is idle, and Home holds its columns until it has arrived + * (`useHomeCardsSettled`), so the card never lands on top of rows already on screen (layout shift + * on Home, 11 Nov 2026). Once loaded it draws in the same render, unlike `next/dynamic`, which + * always waits one pass even when the code is already there. A failed fetch is tried again the next + * time the card mounts. Does nothing on the server. + */ +export function loadEarly

(loader: () => Promise>) { + let loaded: ComponentType

| null = null; + let pending: Promise | null = null; + const listeners = new Set<() => void>(); + + const start = () => { + if (loaded || pending || typeof window === "undefined") return; + pending = loader().then( + (component) => { + loaded = component; + for (const listener of listeners) listener(); + }, + () => { + pending = null; + }, + ); + }; + const subscribe = (listener: () => void) => { + listeners.add(listener); + start(); + return () => { + listeners.delete(listener); + }; + }; + const ready = () => loaded !== null; + const current = () => loaded; + const none = () => null; + + function EarlyCard(props: P) { + const Card = useSyncExternalStore(subscribe, current, none); + return Card ? : null; + } + + return { Card: EarlyCard, start, subscribe, ready }; +} From 09fd9dfa8bb8ac444d8cfe230503ba6c14520566 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:45:44 +0000 Subject: [PATCH 5/8] Keep the Home cards wait constant private (knip) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK (cherry picked from commit e6ad3e70cb846e3aab1dbd6a54b0e23876755544) --- .../ward-management/coordinator/home-cards-settled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ward-management/coordinator/home-cards-settled.ts b/src/components/ward-management/coordinator/home-cards-settled.ts index e45f150460..597f38f5d0 100644 --- a/src/components/ward-management/coordinator/home-cards-settled.ts +++ b/src/components/ward-management/coordinator/home-cards-settled.ts @@ -6,7 +6,7 @@ import { bedsGapCard } from "./home-list-lazy"; import { sinceBanner } from "./home-since-banner-lazy"; /** Home never stays hidden longer than this for a card that is slow or fails to load. */ -export const HOME_CARDS_WAIT_MS = 1500; +const HOME_CARDS_WAIT_MS = 1500; /** * Layout shift on Home (11 Nov 2026): Home draws on the client, and the two cards it shows at rest From 3007f56bc5d19b793d93cd594cbecf08faabab20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 14:25:40 +0000 Subject: [PATCH 6/8] Name the Home cards wait as a loading pause in the invented-figures check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK (cherry picked from commit 66426eb3f9dbb5ab19c2096ffb059f49819085b3) --- tests/ward-no-invented-screen-figures.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ward-no-invented-screen-figures.test.ts b/tests/ward-no-invented-screen-figures.test.ts index cea5ec2351..87575e2d96 100644 --- a/tests/ward-no-invented-screen-figures.test.ts +++ b/tests/ward-no-invented-screen-figures.test.ts @@ -174,6 +174,12 @@ const CONSTANT_EXCLUSIONS: ReadonlyArray<{ id: string; why: string; file?: RegEx file: /^referrals\/use-referral-match-state\.tsx$/, name: /^ACCEPT_SECOND_TAP_GUARD_MS$/, }, + { + id: "home-cards-wait", + why: "How long Home holds its columns for its two early cards before showing anyway (item 13, 11 Nov 2026), so the page does not shift; a loading pause, never shown, not a limit.", + file: /^coordinator\/home-cards-settled\.ts$/, + name: /^HOME_CARDS_WAIT_MS$/, + }, { id: "recovery-verifier-lifetime", why: "How long this browser keeps the one-use PKCE secret for a password recovery link it asked for (SEC-2, Josh, 6 Nov 2026); a sign-in security timer, never shown, not a clinical limit.", From 06c8bb4b7725aef566398132a5fcedace2ae5939 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:40:45 +0000 Subject: [PATCH 7/8] Give the transfer journey three minutes, and skip cached packages that would downgrade The phone "Transfer with transport" journey crosses three desks with ten accessibility checks. On hosted WebKit it went from 1.3 min at 10:31Z on 11 Nov to 2.1 min by 18:00Z with no single step hanging, so it ran out of the shared two-minute budget on every PR. It alone now gets three minutes. Also takes #1630's withoutDowngrades filter (e3ed4c5e2) for the offline browser setup, so the two branches carry the same script. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmrqgwa3hCAtaMf9JT --- scripts/ward-ci-public/install-chromium.mjs | 40 ++++++++++++++++++++- tests/release-gate-journeys.spec.ts | 4 +++ tests/ward-browser-install.test.ts | 22 +++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/ward-ci-public/install-chromium.mjs b/scripts/ward-ci-public/install-chromium.mjs index e88a3d9af5..61efdca645 100644 --- a/scripts/ward-ci-public/install-chromium.mjs +++ b/scripts/ward-ci-public/install-chromium.mjs @@ -35,7 +35,7 @@ export function installBrowsers({ now = Date.now, log = console.log, waitForIdle = execute === runBounded ? (deadline) => waitForPackageManager(deadline, now) : () => {}, - cachedDebs = execute === runBounded ? archiveDebs() : [], + cachedDebs = execute === runBounded ? withoutDowngrades(archiveDebs()) : [], } = {}) { if ( !browsers.length || @@ -84,6 +84,44 @@ export function archiveDebs(dir = APT_ARCHIVES) { : []; } +/** Each installed package's version, keyed by "name arch", read once from dpkg. */ +function installedVersions() { + const result = spawnSync("dpkg-query", ["-W", "-f", "${Package} ${Architecture} ${Version}\\n"], { + encoding: "utf8", + }); + const versions = new Map(); + for (const line of result.status === 0 ? result.stdout.split("\n") : []) { + const [name, arch, version] = line.split(" "); + if (version) versions.set(`${name} ${arch}`, version); + } + return versions; +} + +/** True when dpkg orders version `a` before version `b`. */ +function olderThan(a, b) { + return spawnSync("dpkg", ["--compare-versions", a, "lt", b]).status === 0; +} + +/** + * A cache saved on an older runner image can hold a package older than the one installed. Naming + * that file asks apt for a downgrade, which `-y` refuses, and the whole offline install fails. Leave + * such files out; archive names are `name_version_arch.deb`, with an epoch's colon written `%3a`. + */ +export function withoutDowngrades(debs, installed = installedVersions(), isOlder = olderThan) { + return debs.filter((path) => { + const parts = path + .split("/") + .pop() + .replace(/\.deb$/, "") + .split("_"); + if (parts.length !== 3) return true; + const [name, rawVersion, arch] = parts; + const current = installed.get(`${name} ${arch}`); + const version = decodeURIComponent(rawVersion); + return !current || current === version || !isOlder(version, current); + }); +} + /** A fixed script; the Playwright path and the engine names arrive as arguments, never as code. */ const LAUNCH_PROBE = `const [playwright, ...names] = process.argv.slice(1); const pw = require(playwright); diff --git a/tests/release-gate-journeys.spec.ts b/tests/release-gate-journeys.spec.ts index 62a729de11..9d35c2e74e 100644 --- a/tests/release-gate-journeys.spec.ts +++ b/tests/release-gate-journeys.spec.ts @@ -236,6 +236,10 @@ test("Morning bed meeting: a ward confirms its numbers and the coordinator's cap test("Transfer with transport: the ED books transport, the officer delivers, and the ward confirms arrival", async ({ page, }, testInfo) => { + // Three desks, ten accessibility checks and three desk switches: the longest journey here. Phone + // WebKit on a hosted runner took 1.3 min on 11 Nov at 10:31Z and 2.1 min by 18:00Z with no step + // hanging (each click 1.5 to 3 s, each check about 2 s), so it alone gets three minutes. + test.setTimeout(180_000); // WF-004 is seeded pulled into its accepted ward with a linked stay waiting for the patient, and // no transport booked yet. A hand-authored transport job has no stay, so arrival could only move // the ward's count; this one proves the patient's own stay becomes an occupied bed. diff --git a/tests/ward-browser-install.test.ts b/tests/ward-browser-install.test.ts index 58159bff75..d4ec9fc0ea 100644 --- a/tests/ward-browser-install.test.ts +++ b/tests/ward-browser-install.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { installChromium } from "../scripts/ward-ci-public/install-chromium.mjs"; +import { installChromium, withoutDowngrades } from "../scripts/ward-ci-public/install-chromium.mjs"; describe("bounded release browser setup", () => { it("installs both required engines on a cold runner", () => { @@ -82,6 +82,26 @@ describe("bounded release browser setup", () => { expect(commands[3].slice(2)).toEqual(["install", "--with-deps", "chromium"]); }); + it("leaves out a cached package older than the installed one, so apt is never asked to downgrade", () => { + const dir = "/var/cache/apt/archives"; + const installed = new Map([ + ["libnss3 amd64", "2:3.98-1build1"], + ["fonts-liberation all", "1:1.07.4-11"], + ]); + const older = (a: string, b: string) => a < b; + expect( + withoutDowngrades( + [ + `${dir}/libnss3_2%3a3.90-1_amd64.deb`, + `${dir}/fonts-liberation_1%3a1.07.4-11_all.deb`, + `${dir}/libwoff1_1.0.2-2build1_amd64.deb`, + ], + installed, + older, + ), + ).toEqual([`${dir}/fonts-liberation_1%3a1.07.4-11_all.deb`, `${dir}/libwoff1_1.0.2-2build1_amd64.deb`]); + }); + it("refuses an unsupported or empty engine selection without invoking a command", () => { const execute = () => { throw new Error("must not start an installation"); From 81ff35cc7f2f23771a74eb6cc4dd94ee93de28ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:43:54 +0000 Subject: [PATCH 8/8] Name dpkg and dpkg-query as system binaries for knip Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmrqgwa3hCAtaMf9JT --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 76b004981b..139a2f86a9 100644 --- a/knip.json +++ b/knip.json @@ -21,5 +21,5 @@ } }, "ignoreDependencies": ["@axe-core/playwright", "playwright-core", "rimraf", "tsx"], - "ignoreBinaries": ["pgrep", "openssl", "jq"] + "ignoreBinaries": ["pgrep", "openssl", "jq", "dpkg", "dpkg-query"] }