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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ jobs:
- name: Test the lockfix classifier
run: node --test ../.github/scripts/lockfix.test.mjs

# Same shape as the two gates above: pure Node, self-tested before it is
# trusted to gate a real build later in this job (#825).
- name: Test the precache-ceiling guard
run: node --test scripts/verify-sw.test.mjs

- name: Install dependencies
run: npm ci

Expand Down
10 changes: 8 additions & 2 deletions docs/designs/822-mui-revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,13 @@ Only one CSS deletion actually landed with #832: `.dialog .confirm-body` / `.dia

### D9. The precache ceiling (#825). Owner review

Ceiling **1,800 KiB** of precache, enforced in CI by extending `scripts/verify-sw.mjs` (it already extracts the manifest at L150-161; it sums the listed files' sizes from `dist/` and fails above the ceiling). Derivation: MUI plus the realistic kit measured 1632.68 KiB; D7.2 adds 118.9 KiB; that is 1751.6 KiB, and 1,800 leaves 2.7% for the small controls. The alternative is a ceiling below the unoptimised kit measurement (1,600 KiB) on the argument that this epic deletes as it adds and a ceiling above the measurement is not a ceiling. Its cost is stated plainly: #825 installs the gate at order 3, before any deletion slice, and no deletion estimate exists yet (the hand-built controls are TSX, which the precache counts as part of the JS chunk, and nobody has measured what #826 and #827 remove), so a 1,600 ceiling can go red at #828 and stay red until enough hand-built code is gone; it also forces the `opsz` fallback in D7.2. #825 measures the JS delta of deleting `NamedEntityPicker` and `Dialog` on a throwaway branch before the number is chosen, so the choice is decidable. Either way: above the ceiling a slice must retire hand-built code to land, and a slice that adds more than 10 KiB of precache without deleting code names the reason in its PR body. The check is a `pull_request` check in the `web` job, so a documentation-only PR skips it (#782), which is correct: such a PR changes no bundle input. Script-time ceiling per #674: re-measure with the record's method when #826 lands, since `Autocomplete` is the component the record named as needing its own number.
~~Ceiling **1,800 KiB** of precache, enforced in CI by extending `scripts/verify-sw.mjs`… Derivation: MUI plus the realistic kit measured 1632.68 KiB; D7.2 adds 118.9 KiB…~~ **Superseded twice, in order.** First, the owner's decision on #825 (2026-09-16) dropped the gate entirely: "measure and report, no gate… a floor picked before enough slices have been measured is a guess, and a wrong guard reads as safety. Revisit a ceiling once the control slices (#826, #827, #828) and #835 have landed and the number has a trend." `verify-sw.mjs` was extended to print the total with no threshold.

**Amended by #825 (this slice): the gate is back, with a measured number, not the projected one above.** #826 and #827 have landed; #828 and #835 have not, so the owner's own stated precondition ("once… have landed") is not fully met. This slice's task explicitly asked for CI enforcement of both ceilings now rather than waiting on #828/#835, so it proceeds on that instruction and states the resulting gap plainly here rather than silently re-deciding it: the ceiling below is derived to already absorb both pending changes, so it does not need day-one revisiting once they land.

Ceiling **1,900 KiB**, enforced in `scripts/verify-sw.mjs` (it sums the precache manifest's listed files' real sizes from `dist/` and fails above the ceiling). Derivation, measured rather than projected: actual precache at `75388d2` (2026-09-22, after #826 and #827) is 1,806.38 KiB. Pending known costs: #828 retires the isolated `NumberField` (~-18 KiB projected in the 2026-09-16 "Numbers for the ceiling choice" issue comment — that figure covers NumberField alone; §1's fact base above already found there is no hand-rolled tooltip to also credit); #835 adopts Inter's `opsz` axis (+118.9 KiB, measured directly in D7.2 above). Net projected after both land is **~1,907 KiB — over this 1,900 ceiling by about 7 KiB, not absorbed by it.** That gap is not an oversight: D7.2 already prices it in and names the fallback for exactly this case, one display-size cut for `h1`/`h2` only, worth 24.1 KiB, comfortably more than the 7 KiB shortfall. So #835 is expected to land using that fallback, or #828 to find savings beyond the isolated-chunk estimate, or #825's own two landed slices to have left more room than this projection assumes; whichever happens, `verify-sw.mjs` will say so at build time rather than this doc guessing further. Above the ceiling a slice must retire hand-built code, or find equivalent savings elsewhere, to land; a slice that adds more than 10 KiB of precache without deleting code names the reason in its PR body as a separate documentation convention — the one the 2026-09-16 decision kept regardless of gate/no-gate — but that convention does not itself pass the check. The check runs inside the existing `web` job's `verify:sw` step, so a documentation-only PR still skips it (#782), unchanged.

**Script-duration ceiling: also re-enforced by #825, as a wide regression tripwire rather than a tight budget**, for a reason the precache ceiling does not share: byte counts are environment-independent, but a CI runner's CPU is shared and variable run to run, so a tight number here would be exactly the "wrong guard that reads as safety" the owner's own 2026-09-16 comment warned against for precache. `tools/simulation/ui/specs/dashboard-script-duration.spec.ts` measures Dashboard's script execution time via CDP `Performance.getMetrics`, median of 5 runs against the real seeded stack, and fails only past a 600 ms tripwire sized generously above ordinary noise — printed every run, gating only a multi-fold regression (the record's own named unmeasured cases, `Autocomplete` and a data grid).

### D10. Coverage: the constraint nobody filed

Expand Down Expand Up @@ -420,7 +426,7 @@ Product calls this doc makes that the owner reviews before or at the slice that
| ~~Form dialogs `fullScreen` below 900px~~ **Decided 2026-09-17: centred, sized to content, 16px side margin (#892 mockups)** | D2 pair 2, D3.3, #827/#832 | full screen with a pinned footer, or a bottom sheet, both mocked up on #892 and declined |
| Daily-entry footer buttons stay side by side at every width, exempt from D3.4's stacking rule | D3.4, #823, #830 | stack the footer full-width with every other action row, reversing F134's side-by-side pair |
| Links take `--brand` / `--stat-accent` (D7.1's original proposal, superseded) | D7.1, #834 | what #834 actually shipped: `--ink`, underlined in a new `--link-rule` (28% ink), per the owner's DIRECTION.md decision (2026-09-16) |
| Ceiling 1,800 KiB | D9, #825 | 1,600 KiB and the `opsz` fallback |
| ~~Ceiling 1,800 KiB~~ **Ceiling 1,900 KiB, re-enforced after a report-only interval; see D9's amendment** | D9, #825 | 1,600 KiB and the `opsz` fallback |

## 8. Simplicity ceiling

Expand Down
83 changes: 83 additions & 0 deletions tools/simulation/ui/specs/dashboard-script-duration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// tools/simulation/ui/specs/dashboard-script-duration.spec.ts — #825.
//
// Measures Dashboard's script execution cost on the real seeded stack. #674's
// decision record found bundle bytes flat under CPU throttling but script
// time rising with MUI component count (+27% for eleven components) — the
// cost a byte budget cannot see. This spec is that second, script-duration
// signal, run continuously instead of by hand in a throwaway branch.
//
// CDP Performance.getMetrics, same domain ax.ts already uses for the
// accessibility tree (see that file for why CDP over a higher-level API).
// Measured around a CLIENT-SIDE route change (a nav Link click), not
// page.goto: the access token lives in a module-level variable (#145,
// fixtures.ts), so a full browser navigation would sign the persona out
// before the second run. Client-side navigation also isolates Dashboard's own
// mount cost from the app shell's one-time bootstrap.
//
// This is a MEASUREMENT with a wide regression tripwire, not a tight budget.
// A CI runner's CPU is shared and variable run to run, so a tight ceiling
// here would be exactly the "wrong guard that reads as safety" AGENTS.md
// warns against for every other guard in this repo. The tripwire is sized to
// catch a genuine multi-fold regression — the record names Autocomplete and a
// data grid as the untested cases — while absorbing ordinary CI noise.

import { test, expect } from "../src/fixtures";
import { owner } from "../src/cast";

const RUNS = 5;

// #674 measured 107ms -> 136ms (bare -> 11 MUI components) at 6x CPU
// throttle on a real device. This spec runs unthrottled on a shared CI
// runner, so its absolute numbers are not comparable to that record, only to
// this spec's own history. Set from the one real measurement taken while
// writing this spec, against an isolated build of the seeded stack
// (median 124.5ms, runs 56/122/124/127/141ms), times roughly 4.8 — CI
// runners vary more than a single local run can show, so this is a
// deliberately wide margin, not a tight budget. Tighten only against a
// measured trend recorded in the PR that does it, per #825.
const SCRIPT_DURATION_CEILING_MS = 600;

test.describe("Dashboard script duration", () => {
test("median script execution time across N Dashboard mounts stays under the tripwire", async ({ page, signIn, nav }) => {
await signIn(owner());

const durationsMs: number[] = [];
for (let i = 0; i < RUNS; i++) {
// Leave Dashboard so the next click is a real mount, not a no-op nav.
await nav.link("nav:flocks").click();
await page.waitForURL("/flocks");

const cdp = await page.context().newCDPSession(page);
await cdp.send("Performance.enable");
const before = await cdp.send("Performance.getMetrics");

await nav.link("nav:dashboard").click();
// The Lay rate card is the last of Dashboard's four panels to settle
// (#916's scoped trend read); its strip replacing the LinearProgress is
// "the last Dashboard panel visible" the runtime baseline record times to.
await expect(page.locator("figure.trend")).toBeVisible();

const after = await cdp.send("Performance.getMetrics");
await cdp.detach();

const scriptBefore = before.metrics.find((m) => m.name === "ScriptDuration")?.value ?? 0;
const scriptAfter = after.metrics.find((m) => m.name === "ScriptDuration")?.value ?? 0;
durationsMs.push((scriptAfter - scriptBefore) * 1000);
}

const sorted = [...durationsMs].sort((a, b) => a - b);
// RUNS is a positive compile-time constant, so `sorted` is never empty.
const median = sorted[Math.floor(sorted.length / 2)] ?? 0;
console.log(
`[dashboard script duration] runs=${sorted.map((d) => Math.round(d)).join(",")}ms medianMs=${median.toFixed(1)}`,
);

expect(
median,
`Dashboard script time regressed: ${median.toFixed(1)}ms median over ${RUNS} runs, ` +
`ceiling ${SCRIPT_DURATION_CEILING_MS}ms. Re-measure with #674's throttled-device method ` +
"before tightening this tripwire. If this is real growth from a new component " +
"(Autocomplete, a data grid), name it in the PR body per #825.",
).toBeLessThan(SCRIPT_DURATION_CEILING_MS);
});
});
46 changes: 45 additions & 1 deletion web/scripts/verify-sw.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
//
// Usage: node scripts/verify-sw.mjs [dist]

import { readFileSync, existsSync, readdirSync } from "node:fs";
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";

const dist = process.argv[2] ?? "dist";
Expand Down Expand Up @@ -161,6 +161,46 @@ check(apiish.length === 0, `API paths found in the precache manifest: ${apiish.j
check(precached.length > 0, "precache manifest is empty — the app shell would not be cached at all");
check(missingJs.length === 0, `emitted JavaScript missing from precache: ${missingJs.join(", ")}`);

// 4. Total precache weight, against an explicit ceiling (#825). #674 measured
// +320 KiB (+24%) for MUI's provider plus a realistic component kit over the
// hand-rolled baseline — real weight on a PWA meant for phones in sheds. Byte
// sizes are the only precache measure that is environment-independent
// (docs/decisions/407's writing-a-guard rule on golden values applies here
// too: a timing-based guard would not be), so this is where enforcement
// belongs; script duration is measured separately, on the real stack, in
// tools/simulation/ui/specs/dashboard-script-duration.spec.ts.
//
// 1,900 KiB, measured against the branch this ceiling was set on (75388d2,
// 2026-09-22): actual precache there is 1,806.38 KiB, after #826 and #827
// retired NamedEntityPicker's and Dialog's hand-built code but before #828
// (retires the isolated `NumberField`, ~-18 KiB projected in the issue's
// 2026-09-16 "Numbers for the ceiling choice" comment — that figure covers
// NumberField alone, not the hand-rolled tooltip positioning #828 also
// retires) and #835 (adopts Inter's `opsz` axis, +118.9 KiB measured in
// docs/designs/822-mui-revamp.md). Net projected after both land is ~1,907
// KiB — OVER this ceiling by ~7 KiB, not absorbed by it. That is not an
// oversight: docs/designs/822-mui-revamp.md's D7.2 already prices this in
// and names the fallback (one display-size cut for `h1`/`h2` only, worth
// 24.1 KiB) for exactly the case where #835 does not fit. A slice that adds
// weight without retiring hand-built code names the reason in its PR body,
// as a convention; it does not change this check's verdict.
const PRECACHE_CEILING_KIB = 1900;
let precacheBytes = 0;
const missingOnDisk = [];
for (const url of precached) {
const assetPath = join(dist, url.replace(/^\.?\//, ""));
if (!existsSync(assetPath)) { missingOnDisk.push(url); continue; }
precacheBytes += statSync(assetPath).size;
}
check(missingOnDisk.length === 0,
`precache manifest names files absent from ${dist}: ${missingOnDisk.join(", ")}`);
const precacheKiB = precacheBytes / 1024;
check(precacheKiB <= PRECACHE_CEILING_KIB,
`precache is ${precacheKiB.toFixed(2)} KiB, over the ${PRECACHE_CEILING_KIB} KiB ceiling ` +
`(#825) by ${(precacheKiB - PRECACHE_CEILING_KIB).toFixed(2)} KiB. Retire hand-built code, ` +
"or find equivalent savings elsewhere, to pass this check. Naming the reason in the PR body " +
"is the separate documentation convention #825 records; it does not make this check pass.");

if (failures.length) {
for (const f of failures) console.error(`::error::[service worker] ${f}`);
console.error(`\n${failures.length} service-worker guarantee(s) broken in ${swPath}.`);
Expand All @@ -172,3 +212,7 @@ console.log(
`no runtime caching strategy, ${precached.length} shell entries precached, ` +
`${emittedJs.length} JavaScript assets verified and no API path among them.`,
);
console.log(
`[precache budget] ${precacheKiB.toFixed(2)} KiB / ${PRECACHE_CEILING_KIB} KiB ceiling ` +
`(${(PRECACHE_CEILING_KIB - precacheKiB).toFixed(2)} KiB headroom).`,
);
69 changes: 69 additions & 0 deletions web/scripts/verify-sw.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// web/scripts/verify-sw.test.mjs — #825.
//
// Behavior-level: runs the real script as a subprocess against a minimal but
// otherwise-valid generated service worker, the same way CI runs it against
// the real one. Proves the precache-ceiling guard added in verify-sw.mjs both
// passes under budget and fails closed over it, with the mutant's own failure
// message readable in the assertion. Not a refactor into pure functions:
// verify-sw.mjs's own header states its whole purpose is checking the REAL
// emitted worker, so a fixture-driven subprocess run is the faithful test.

import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const scriptPath = fileURLToPath(new URL("./verify-sw.mjs", import.meta.url));

// Real Workbox-emitted patterns (copied from an actual `vite build` output),
// so this fixture exercises the same regex logic as the guards above it, not
// a simplified stand-in.
const DENYLIST = "[/^\\/api(?:[/?]|$)/i,/^\\/health(?:[/?]|$)/i]";

/** Builds a minimal dist/ that satisfies every OTHER verify-sw.mjs check, so a test only exercises the precache-size guard. */
function buildFixture(assetByteSize) {
const dist = mkdtempSync(join(tmpdir(), "verify-sw-fixture-"));
mkdirSync(join(dist, "assets"));
writeFileSync(join(dist, "assets", "app.js"), "a".repeat(assetByteSize));
const sw = `precacheAndRoute([{url:"assets/app.js",revision:"deadbeef"}]);` +
`registerRoute(new wb.NavigationRoute(wb.createHandlerBoundToURL("/index.html"),{denylist:${DENYLIST}}));`;
writeFileSync(join(dist, "sw.js"), sw);
return dist;
}

function runVerifySw(dist) {
try {
const stdout = execFileSync("node", [scriptPath, dist], { encoding: "utf8" });
return { status: 0, stdout, stderr: "" };
} catch (err) {
return { status: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
}
}

test("passes and reports headroom when precache is under the ceiling", () => {
const dist = buildFixture(1024); // 1 KiB, far under the ceiling
try {
const { status, stdout } = runVerifySw(dist);
assert.equal(status, 0);
assert.match(stdout, /\[precache budget] 1\.00 KiB \/ \d+ KiB ceiling/);
} finally {
rmSync(dist, { recursive: true, force: true });
}
});

test("fails closed with an actionable message when precache exceeds the ceiling", () => {
// 2,000 KiB is over every ceiling this repo would plausibly set; the test
// asserts the guard fires and names both figures, not a specific number.
const dist = buildFixture(2000 * 1024);
try {
const { status, stderr } = runVerifySw(dist);
assert.equal(status, 1);
assert.match(stderr, /precache is 2000\.00 KiB, over the \d+ KiB ceiling \(#825\)/);
assert.match(stderr, /does not make this check pass/);
} finally {
rmSync(dist, { recursive: true, force: true });
}
});
Loading