Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
4244fbd
Show a calm Saving… pill first; warn only after 8 s, offline or on fa…
BigSimmo Nov 11, 2026
28e9268
Keep the Midland demo team off the signed-in shared workspace (live a…
BigSimmo Nov 11, 2026
0aaa7f5
Hide the demo Ready to leave checklist on the shared workspace (live …
BigSimmo Nov 11, 2026
1e54240
List a bed's closures and reopenings in its drawer History (live audi…
BigSimmo Nov 11, 2026
1074a05
Add Leave the demo to the account menu, phone menu and Settings (live…
BigSimmo Nov 11, 2026
b40d18c
Drop the retired network screen, fix the ED guide's New menu and '1 o…
BigSimmo Nov 11, 2026
055481f
Tests: shared workspace hides the Midland demo and the demo checklist…
BigSimmo Nov 11, 2026
d318cf1
Live audit decisions D-148 to D-151: bed close reason, archive rule, …
BigSimmo Nov 11, 2026
37668c2
Item 8/6/13 follow-up: keep the warning through a retry after a failu…
BigSimmo Nov 11, 2026
1b56666
Guard: list the save warning pause as screen timing, not an invented …
BigSimmo Nov 11, 2026
b1f24e2
Leave the demo: replace the page with sign-in, so Back does not retur…
BigSimmo Nov 11, 2026
352c00a
D-149: unlink withdrawn referrals on archive so the stored workspace …
BigSimmo Nov 11, 2026
2387696
Trim to fit the phone budgets: share the History toggle and list, reu…
BigSimmo Nov 11, 2026
9256f3b
D-150: keep global search within its line cap
BigSimmo Nov 11, 2026
5a60aa8
Restore the recovery host and bar height hooks dropped by the previou…
BigSimmo Nov 11, 2026
b523143
Merge remote-tracking branch 'origin/main' into fix/live-audit-decisions
BigSimmo Nov 11, 2026
8366206
Live audit hardening: token wait deadline, security.txt, backend head…
BigSimmo Nov 11, 2026
c13674b
Merge remote-tracking branch 'origin/fix/live-audit-hardening-2026111…
BigSimmo Nov 11, 2026
ef34aef
Merge remote-tracking branch 'origin/fix/live-audit-ui-20261111' into…
BigSimmo Nov 11, 2026
aca4d2a
Merge remote-tracking branch 'origin/main' into fix/live-audit-decisions
BigSimmo Nov 11, 2026
e60937e
Keep TV_BOARD_ROLES module-private so knip sees no new unused export …
BigSimmo Nov 11, 2026
1e6d548
Combined live audit PR: Leave the demo ends the pass through the fron…
BigSimmo Nov 11, 2026
94e0264
Keep the toast's reason menu off every first load: load it only when …
BigSimmo Nov 11, 2026
356e9ca
Merge remote-tracking branch 'origin/main' into fix/live-audit-decisions
BigSimmo Nov 11, 2026
d5a8fd8
Leave the demo loads the front door's pass call on use, so the shell …
BigSimmo Nov 11, 2026
bbd3a4c
D-148: say the audit trail keeps both steps; the bed's History shows …
BigSimmo Nov 11, 2026
3b33f41
Merge remote-tracking branch 'origin/fix/live-audit-hardening-2026111…
BigSimmo Nov 11, 2026
e664ef8
Journey review records for the live audit fixes (D-149 archive guard,…
BigSimmo Nov 11, 2026
2373eb9
Phone budget trim: front door holders carry four class names, not the…
BigSimmo Nov 11, 2026
fb3cb9f
Format the journey README note
BigSimmo Nov 11, 2026
e593c6f
Merge remote-tracking branch 'origin/main' into fix/live-audit-decisions
BigSimmo Nov 11, 2026
af2372d
Journey: re-review community-screen citations after merging main; for…
BigSimmo Nov 11, 2026
a2eaac0
Phone budget trim: the front door holders compose their four classes …
BigSimmo Nov 11, 2026
c3817e6
Keep the guards green: Leave the demo lives in the shell, the bed His…
BigSimmo Nov 11, 2026
5a9e2e4
Merge remote-tracking branch 'origin/main' into fix/live-audit-decisions
BigSimmo Nov 11, 2026
cb1d1b9
Phone budget trim: the front door holders compose their four classes …
BigSimmo Nov 11, 2026
0b652ed
Format the journey records
BigSimmo Nov 11, 2026
269b744
Allow the D-149 archive reducer in the D-14 patient-link guard
BigSimmo Nov 11, 2026
ac0fc37
Merge origin/main (51a47453) into fix/live-audit-decisions
claude Nov 11, 2026
fbf4d03
Allow the D-149 archive step to read referral.patientId (D-14 60 to 61)
claude Nov 11, 2026
b226708
Merge Josh's D-14 allowlist commit 269b7443 from origin
claude Nov 11, 2026
dfa5547
Merge origin/main into fix/live-audit-decisions
BigSimmo Nov 11, 2026
09cd8ed
Address review round on #1626 (archive Undo boundary, Leave the demo,…
claude Nov 11, 2026
5066fe5
Merge dfa554768 (the same origin/main merge, made on the remote) into…
claude Nov 11, 2026
0341e5d
Fix the backend Postgres proof for D-150 and re-record the archive re…
claude Nov 11, 2026
03b1189
Bed board: keep "Waiting to save" beside a fit reason while picking
claude Nov 11, 2026
2c4d873
Keep leaveDemo private to leave-demo.ts (knip: new unused export)
claude Nov 11, 2026
7ab9c6e
Fix three unit-gate reds from the live audit fixes
claude Nov 11, 2026
acdde4d
Record D-155: check the live clock offset before clearing it
claude Nov 11, 2026
b54a402
Bring in PR #1631's mirror-free browser setup to unblock CI
claude Nov 11, 2026
557a3cf
Address CodeRabbit review 5863010106 on #1626
claude Nov 11, 2026
5d7fbaf
Toast choices fall back to plain buttons; a confirmed Undo still redo…
claude Nov 11, 2026
fb6eb33
Record D-156: reset the synthetic live world after the clock fix
claude Nov 11, 2026
50f8ff9
Review fixes on #1626: calm Saving… after an old refusal; archive ign…
claude Nov 11, 2026
e3c9771
Merge main (#1631 browser setup, Home layout-shift and transfer budge…
claude Nov 11, 2026
b894cae
Merge main into fix/live-audit-decisions (D-153, governance review hi…
claude Nov 11, 2026
79bd554
Review fixes on #1626: archive drops spent Undo records; shared journ…
claude Nov 11, 2026
257e2f0
Merge main (#1608) into fix/live-audit-decisions
claude Nov 11, 2026
aeefa1b
Keep screen-to-screen imports at 564 after the #1608 merge
claude Nov 11, 2026
67df9ca
Keep the review time when a reason is added to a bed closure
claude Nov 11, 2026
8a05117
Merge main (#1606) into fix/live-audit-decisions
claude Nov 11, 2026
4daa8a3
D-155's gap note: D-154 is claimed by open PR #1632
claude Nov 11, 2026
733ef02
Raise three phone size limits after merging #1606
claude Nov 11, 2026
8c047c0
Merge main (#1632) into fix/live-audit-decisions
claude Nov 11, 2026
0fd2a04
Raise four phone size limits to CI's measurement plus 0.5 KB
claude Nov 12, 2026
ad79675
Trim the first-load so the live-audit pages fit their phone limits
BigSimmo Nov 12, 2026
b52ff76
Withdraw the D-156 live-world reset, and break an import cycle
BigSimmo Nov 12, 2026
99c3bc8
Merge origin/main (0f2ce4d6e, #1623) into fix/live-audit-decisions
BigSimmo Nov 12, 2026
a4cb058
Take main's phone size limits after the #1623 merge; keep only the ED…
claude Nov 12, 2026
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
31 changes: 24 additions & 7 deletions backend/ward-flow/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,23 +401,40 @@ function changedFacts(before: WardFlowState, after: WardFlowState, rawAfter?: Wa
}

export function seedWorld(at: Date): SharedWorld {
const dayZero = demoDayZero(at);
return {
version: 1,
state: serialise(seedWardFlowStateAt(0)),
// D-150: the seed is re-anchored onto the real Perth time, as the public demo's provider does
// (`ward-reanchor.ts`), so a new world's clock and its seeded times agree from the start.
state: serialise(seedWardFlowStateAt(minutesSince(dayZero, at) - NOW_ANCHOR)),
// New worlds share the local demo's Perth calendar. Existing stored origins remain authoritative.
dayZero: demoDayZero(at).toISOString(),
dayZero: dayZero.toISOString(),
startedAt: at.toISOString(),
};
}

export function worldNow(world: SharedWorld, at: Date): number {
return (
NOW_ANCHOR +
Math.max(0, Math.floor((at.getTime() - Date.parse(world.startedAt)) / 60_000)) +
world.state.clockOffsetMinutes
/** Whole minutes from `from` to `at`, never negative. */
function minutesSince(from: Date | string, at: Date): number {
return Math.max(
0,
Math.floor((at.getTime() - (typeof from === "string" ? Date.parse(from) : from.getTime())) / 60_000),
);
}

/**
* The shared world's clock. D-150 (Josh, 11 Nov 2026): the signed-in site shows real Perth time, so
* an instant is the real minutes since the world's day zero (a Perth midnight). Before this the clock
* started at the scripted 10:42 (`NOW_ANCHOR`) whenever the world was made and ran fast or slow by
* that gap all day. The scripted clock stays on the public demo only. `clockOffsetMinutes` is still
* added, though the advance-clock control is hidden in shared mode.
*/
export function worldNow(
world: Pick<SharedWorld, "dayZero"> & { state: Pick<WardFlowState, "clockOffsetMinutes"> },
at: Date,
): number {
return minutesSince(world.dayZero, at) + world.state.clockOffsetMinutes;
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
}

export function validWorld(value: unknown): value is SharedWorld {
if (!value || typeof value !== "object") return false;
const world = value as SharedWorld;
Expand Down
26 changes: 13 additions & 13 deletions backend/ward-flow/postgres.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -211,23 +211,23 @@ export function createWorkspaceStore(
*/
let executiveBasis = null;
/**
* The workspace clock (start time and offset) of one committed row ("revision:updated-at"), so
* The workspace clock (day zero and offset, D-150) of one committed row ("revision:updated-at"), so
* an unchanged poll does not read them out of the workspace JSON each time. Set by full reads
* and by the poll's own fallback read; a row with a different stamp is never answered from it.
* @type {{ stamp: string, startedAt: string, clockOffset: number } | null}
* @type {{ stamp: string, dayZero: string, clockOffset: number } | null}
*/
let pollClock = null;
function rememberPollClock(stamp, startedAt, clockOffset) {
function rememberPollClock(stamp, dayZero, clockOffset) {
if (
typeof stamp === "string" &&
typeof startedAt === "string" &&
Number.isFinite(Date.parse(startedAt)) &&
typeof dayZero === "string" &&
Number.isFinite(Date.parse(dayZero)) &&
Number.isFinite(clockOffset)
)
pollClock = { stamp, startedAt, clockOffset };
pollClock = { stamp, dayZero, clockOffset };
}
const pollNow = (at) =>
engine.worldNow({ startedAt: pollClock?.startedAt, state: { clockOffsetMinutes: pollClock?.clockOffset } }, at);
engine.worldNow({ dayZero: pollClock?.dayZero, state: { clockOffsetMinutes: pollClock?.clockOffset } }, at);
/** The executive desk's snapshot around one report, as a full read and a cached poll send it. */
const executiveReply = (view, context, accessUntil, revision, now, report) => ({
kind: "executive-aggregate",
Expand Down Expand Up @@ -437,7 +437,7 @@ export function createWorkspaceStore(
replyTicket = /** @type {object | undefined} */ (undefined),
) => {
// A committed row's clock, so the next unchanged poll answers from memory (no migration).
rememberPollClock(stamp, row.payload?.startedAt, row.payload?.state?.clockOffsetMinutes);
rememberPollClock(stamp, row.payload?.dayZero, row.payload?.state?.clockOffsetMinutes);
if (view?.access?.role === "executive") return executiveSnapshot(connection, row, at, view, stamp);
const context = accessContext(at, view);
const revision = Number(row.revision);
Expand Down Expand Up @@ -1252,24 +1252,24 @@ export function createWorkspaceStore(
return { notModified: true, dataMode: "prototype", revision: knownRevision, now: pollNow(at) };
}
const { rows } = await pool.query(
"SELECT data_mode, revision, date_trunc('milliseconds', updated_at) AS updated_at, payload->>'startedAt' AS started_at, payload->'state'->'clockOffsetMinutes' AS clock_offset FROM ward_flow.workspaces WHERE id=$1 AND data_mode='prototype'",
"SELECT data_mode, revision, date_trunc('milliseconds', updated_at) AS updated_at, payload->>'dayZero' AS day_zero, payload->'state'->'clockOffsetMinutes' AS clock_offset FROM ward_flow.workspaces WHERE id=$1 AND data_mode='prototype'",
[workspaceId],
);
const row = rows[0];
if (row && row.updated_at instanceof Date)
rememberPollClock(`${Number(row.revision)}:${row.updated_at.getTime()}`, row.started_at, row.clock_offset);
rememberPollClock(`${Number(row.revision)}:${row.updated_at.getTime()}`, row.day_zero, row.clock_offset);
if (
row &&
Number(row.revision) === knownRevision &&
typeof row.started_at === "string" &&
Number.isFinite(Date.parse(row.started_at)) &&
typeof row.day_zero === "string" &&
Number.isFinite(Date.parse(row.day_zero)) &&
Number.isFinite(row.clock_offset)
)
return {
notModified: true,
dataMode: row.data_mode,
revision: knownRevision,
now: engine.worldNow({ startedAt: row.started_at, state: { clockOffsetMinutes: row.clock_offset } }, at),
now: engine.worldNow({ dayZero: row.day_zero, state: { clockOffsetMinutes: row.clock_offset } }, at),
};
return { notModified: false, snapshot: await read(actorId, view) };
},
Expand Down
8 changes: 5 additions & 3 deletions backend/ward-flow/postgres.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,10 @@ test("real PostgreSQL: shared commands, last-bed contention, retry, audit and re
unit.id === unitId
? {
...unit,
empty: { ...unit.empty, value: 6, confirmedAt: 642 },
allocatable: { ...unit.allocatable, value: 6, confirmedAt: 642 },
// Confirmed at the world's own now: under D-150 the shared clock is real Perth time,
// so a fixed 10:42 is hours stale at this test's 18:00 Perth.
empty: { ...unit.empty, value: 6, confirmedAt: seed.now },
allocatable: { ...unit.allocatable, value: 6, confirmedAt: seed.now },
allocatableLocked: 6,
}
: unit,
Expand Down Expand Up @@ -338,7 +340,7 @@ test("real PostgreSQL: shared commands, last-bed contention, retry, audit and re
const restored = await createWorkspaceStore(pool, { workspaceId, engine, clock: () => at }).read(actorB);
assert.equal(restored.revision, 2);
assert.equal(restored.payload.state.movements.find((row) => row.id === winner.movement).stage, "pulled");
assert.equal(restored.payload.state.units.find((row) => row.id === unitId).allocatable.confirmedAt, 642);
assert.equal(restored.payload.state.units.find((row) => row.id === unitId).allocatable.confirmedAt, seed.now);
});
await t.test("audit entries cannot be changed or removed", async () => {
await assert.rejects(
Expand Down
29 changes: 29 additions & 0 deletions backend/ward-flow/security-headers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { request as httpRequest } from "node:http";
import { test } from "node:test";
import { listen } from "./server.mjs";

test("live audit item 17: every backend reply sends no referrer, offers no feature and refuses no-cors embedding", async () => {
const server = listen(async () => Response.json({ ok: true }), { port: 0, host: "127.0.0.1" });
try {
await new Promise((resolve) => server.once("listening", resolve));
const port = /** @type {import("node:net").AddressInfo} */ (server.address()).port;
for (const method of ["GET", "DELETE"]) {
const headers = await new Promise((resolve, reject) => {
const req = httpRequest({ host: "127.0.0.1", port, path: "/healthz", method }, (res) => {
res.resume();
resolve(res.headers);
});
req.on("error", reject);
req.end();
});
assert.equal(headers["referrer-policy"], "no-referrer", method);
assert.equal(headers["permissions-policy"], "camera=(), microphone=(), geolocation=(), payment=()", method);
assert.equal(headers["cross-origin-resource-policy"], "same-origin", method);
// The existing P174 pair is still there.
assert.equal(headers["content-security-policy"], "default-src 'none'; frame-ancestors 'none'", method);
}
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
7 changes: 6 additions & 1 deletion backend/ward-flow/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ const gzipAsync = promisify(gzip);
const BODY_LIMIT = 1_048_576;
/**
* Audit P174: every backend reply is JSON, never a page, so it may not be framed or load anything,
* and browsers keep to HTTPS for a year.
* and browsers keep to HTTPS for a year. Live audit item 17 (hardening only): no referrer leaves a
* reply, no browser feature is offered, and no other site may embed a reply without CORS (the
* site's own CORS reads are unaffected, as CORP applies only to no-cors loads).
*/
export const BACKEND_SECURITY_HEADERS = Object.freeze({
"strict-transport-security": "max-age=31536000; includeSubDomains",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=(), payment=()",
"cross-origin-resource-policy": "same-origin",
});
/** Audit P177: the uptime monitor sends this header to see the database answer on `/healthz`. */
export const UPTIME_TOKEN_HEADER = "x-ward-flow-uptime-token";
Expand Down
8 changes: 7 additions & 1 deletion backend/ward-flow/shared-http.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ const STEP_UP_PATHS = new Set([
const WORKING_AS = "x-ward-working-as";
/** "tv" when the desk is showing the bed meeting TV board, so the access log can say so (H-0731). */
const SCREEN = "x-ward-screen";
/**
* Live audit item 21: the roles whose desk opens the bed meeting TV board (`ROLE_SCREENS` has
* "bed-meeting" for these only; `tests/ward-live-audit-hardening.test.ts` keeps them in step). Any
* other desk's "tv" header is logged as the list it really is.
*/
const TV_BOARD_ROLES = new Set(["coordinator", "bed_manager", "ward"]);
/** Messages the pg pool and the workspace store use when the database, not the code, failed. */
const STORAGE_MESSAGES =
/^(?:Connection terminated|timeout exceeded when trying to connect|Client has encountered a connection error|Cannot use a pool after calling end|Shared schema unavailable|Workspace data mode mismatch|Stored workspace is incompatible)/;
Expand Down Expand Up @@ -486,7 +492,7 @@ export function createSharedHandler({
// unchanged poll sends none, so it is not logged, except on the TV board: a desk that moves
// to the TV keeps showing the records it already holds, so the TV logs its own polls too
// (the store logs at most once a window). A failed log write fails the read.
const tvScreen = request.headers.get(SCREEN) === "tv";
const tvScreen = request.headers.get(SCREEN) === "tv" && TV_BOARD_ROLES.has(view.access.role);
const logListRead = async () => {
if (view.access.role !== "executive" && store.logList) await store.logList(view, tvScreen ? "tv" : "list");
};
Expand Down
29 changes: 29 additions & 0 deletions backend/ward-flow/shared-http.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,35 @@ test("the server logs each full read of a desk's list or TV board, and the TV's
assert.equal((await failing(request())).status, 503);
});

test("live audit item 21: a desk whose role cannot open the TV board is logged as its list", async () => {
const logged = [];
const edView = { ...wardView, access: { role: "ed", scope: { kind: "ed", edIds: ["ed-a"] } } };
const { handler } = setup(other, "prototype", {
viewer: async () => edView,
logList: async (view, surface) => {
logged.push([view.access.role, surface]);
return true;
},
});
const tv = request();
tv.headers.set("x-ward-screen", "tv");
assert.equal((await handler(tv)).status, 200);
const { handler: ward } = setup(other, "prototype", {
viewer: async () => wardView,
logList: async (view, surface) => {
logged.push([view.access.role, surface]);
return true;
},
});
const wardTv = request();
wardTv.headers.set("x-ward-screen", "tv");
assert.equal((await ward(wardTv)).status, 200);
assert.deepEqual(logged, [
["ed", "list"],
["ward", "tv"],
]);
});

test("a bootstrap coordinator past its end date has no role (H-0057)", async () => {
const handler = createHandler({
config: {
Expand Down
4 changes: 2 additions & 2 deletions docs/FACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ green. Quote a fact instead of restating it, and it updates itself when the sour
| `migration-count` | 18 | Numbered database migrations | `backend/ward-flow/migrations/` |
| `ward-route-count` | 61 | `page.tsx` routes under `/ward-flow` | `src/app/ward-flow/` |
| `app-route-count` | 65 | Pages and route handlers: the Ward Flow routes plus sign-in and the `/mockups` digest | `src/app/ward-flow/, src/app/ward-flow-sign-in/, src/app/mockups/` |
| `latest-decision` | D-154 | Newest entry in the decision log | `docs/ward-flow/decisions.md` |
| `next-decision` | D-155 | Number to use for the next decision | `docs/ward-flow/decisions.md` |
| `latest-decision` | D-156 | Newest entry in the decision log | `docs/ward-flow/decisions.md` |
| `next-decision` | D-157 | Number to use for the next decision | `docs/ward-flow/decisions.md` |
| `node-engine` | >=24.22.1 <25 | Supported Node.js range | `package.json` |
| `backend-env-vars` | 33 names, [listed below](#backend-env-vars) | Environment variables the shared backend reads | `backend/ward-flow/*.mjs` |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 11 Nov 2026, Live audit fixes (PR #1626)

Changed on top of `main` at `0b657ad` (after #1606 and #1608 merged). Each raise is under 5 KB, within the per-screen allowance in HOW-WE-WORK section 6:

- Home `/ward-flow`: 587.7 to 588.2
- Movement `/ward-flow/movements/WF-001`: 618.7 to 619.2
- ED site `/ward-flow/ed/arm-ed`: 678.1 to 681.5

Every other screen keeps `main`'s limit.

Measured by hosted CI on head `4daa8a3` (job 130830721425), in KB: Home 587.9, Movement 618.9 and ED site 681.2. Each new limit is the measurement plus 0.3. `main` alone could not be built for comparison, so the whole overage counts against this PR. Before #1606 merged, this PR with `main` measured Home 585.1, Movement 617.2 and ED site 675.0 (job 130823967817), all inside the limits at the time.

Why it changed: this PR adds the calm "Saving…" pill and its delayed warning, "Leave the demo" in the account and phone menus, the "Waiting to save" bed tile, the bed's closure History, the archive card's reason line and the Undo choices on the toast (D-148, D-149, D-151). Most of that is in the shared shell or the bed drawer, which the ED site page also loads. Merging #1606's ED referral flow on top grew the ED site page by a further 6.2 KB in the combined build, against 1.6 KB in #1606's own measurement.

Trim tried: the toast's reason menu (`wf/toast-choices.tsx`) and the front-door holders were already moved out of the first load in this PR. No further trim was found without a measurement of `main` alone. The ED site raise is 3.4 KB over `main`'s limit, so any further growth there leaves 1.6 KB of the allowance.

## After merging `main` at `02817bb` (#1632)

`main` raised six of its own limits. This PR now keeps `main`'s value for every route and adds only its own raise over the old `main` at `0b657ad`, in KB: Home 588.3 to 588.8 (+0.5), Movement 619.0 to 619.5 (+0.5) and ED site 678.1 to 681.5 (+3.4). Every other route takes `main`'s limit unchanged. Hosted CI's measurement will show whether any can come down.

Hosted CI on that merge (job 130851096719) measured, in KB: Referrals 594.5, Ward 625.9, Movement 619.6 and ED site 681.8, and `main` alone could not be built. Builds vary by a few tenths, so these four limits are now the measurement plus 0.5: Referrals 595.0 (+0.7 over `main`), Ward 626.4 (+1.0), Movement 620.1 (+1.1) and ED site 682.3 (+4.2), each within the 5 KB allowance. Home stays at 588.8 (+0.5) and every other route keeps `main`'s limit.

## After the first-load trim (12 Nov 2026)

The toast's fallback buttons and the ward bed drawer left the first load (the drawer was already
closed at first paint; its inner sheet was already lazy). Local production measurement after that
trim, in KB: Home 588.2, Referrals 594.3, Ward 623.6, Movement 619.4 and ED site 681.6. All nine
budgeted routes sit inside the limits already on this branch. Ward is 1.8 KB inside `main`'s 625.4.
The other limits can come down later (Home 588.8, Referrals 595.0, Movement 620.1, ED site 682.3).

## After merging `main` at `0f2ce4d` (#1623)

The merge with #1623 kept this PR's older values and so lowered four of `main`'s limits below what #1623 needed (Home, ED, Movement and Capacity), and hosted CI then failed Home (589.0 against 588.8) and Capacity (666.0 against 665.9). After the trim, hosted CI measured, in KB: Home 589.0, Referrals 593.5, ED 543.1, Ward 622.5, Movement 619.6, Capacity 666.0, ED site 678.1, Bed meeting 599.5 and Statistics 640.6, so every route but ED site fits `main`'s limits. This PR now takes `main`'s limit for every route and keeps one raise: ED site 678.1 to 678.6 (+0.5), because it measured exactly at `main`'s limit and builds vary by a few tenths. The earlier raises (Home, Referrals, Ward, Movement and ED site to 682.3) are no longer needed and are dropped.
Loading
Loading