# ADR-0017: Size budgets: measure before capping Status: Accepted Date: 2026-09-18 Deciders: Wesley Cormier ## Context This package targets televisions and set-top boxes. There, bytes are parse time on a slow CPU over a slow link, so the size of each subpath is a product constraint, not a vanity metric. The package is split into subpaths (`@standarx/nav`, `/gamepad`, `/spatial`, `/focus-ring`, `/debug`, plus framework adapters), which makes "how big is the package" ambiguous: a core-only consumer pays nothing for the gamepad engine. Budgets are enforced here by `scripts/size-budget.ts`, and the script hit the ambiguity first: each line is measured with the layers it shares with the core marked `external`, because otherwise every subpath line would be charged for the core again and a 3 kB line would mean nothing (`scripts/size-budget.ts:5-12`). Those externals are named file by file and never globbed, because `*` does not cross a path separator and a glob is how a budget line stops measuring without ever going red (`scripts/size-budget.ts:67-79`). The measurements below are inherited from the predecessor implementation ([ADR-0002](0002-license-and-copyright.md)) and not re-derived here: min+gzip at Bun's default gzip level, each line measured with the layers it shares marked external. | Line | Measured | Cap | |---|---|---| | input system (intents + engage) | 1.93 kB | 2.00 kB | | gamepad engine | 2.35 kB | 3.00 kB | | spatial engine | 2.81 kB | 3.00 kB | | modality tracker | 0.74 kB | 1.00 kB | Not one of those four names is a line here: this repository measures `core`, `gamepad engine`, `spatial engine`, `focus ring`, `debug`, `auto mount`, `react adapter`, `vue adapter`, `svelte adapter`, `angular adapter`, `keyboard` and one line per keyboard layout (`scripts/size-budget.ts:80-186`), and `src/input-system.ts` is charged to the core line rather than costed on its own (`scripts/size-budget.ts:81-86`). The table is context for the shape of the problem, not a set of ceilings. One further figure circulates and must be labelled: the spatial engine bundled with **nothing** external was reported at 3 303 B, above 3 kB — inherited from the predecessor implementation ([ADR-0002](0002-license-and-copyright.md)) and not re-derived here. It does not contradict the 2.81 kB line — it is the other question, asked of the same module, and both questions are worth asking. A whole-package line used to ask it of every runtime entry at once; it was removed on 2026-09-23 because no consumer downloads every entry and the sum went red when the package gained one rather than when anything grew — the amendment at the foot of this record. Three further figures of 2026-08-27 (2.48 kB gamepad, 2.89 kB spatial, 1.34 kB focus ring) are inherited from the predecessor implementation ([ADR-0002](0002-license-and-copyright.md)) and not re-derived here; they are historical and only ever quoted with that date attached. The budget script lives here: `scripts/size-budget.ts`, wired as `bun run check:size` (`package.json`) and run in CI (`.github/workflows/ci.yml:57-58`). It declares fourteen lines (`scripts/size-budget.ts:80-186`), every one of them capped from a measurement taken in this repository — the dated amendments below are that record. ## Decision 1. One **marginal** line per opt-in subpath — gamepad engine, spatial engine, focus ring, debug — measured with its siblings external: what a consumer who already imports the core pays to add it. A second kind of line, one whole-package sum, was part of this decision until 2026-09-23; the amendment at the foot of this record is why it is gone and what replaced it. 2. A cap is written **only after the first measurement in this repository**. An inherited number is context, never a ceiling: the bundler, the target and the file layout all change in the move. 3. Caps are the measured value rounded **up to the next quarter kB**. That is headroom for noise, not for growth, and the script says so where it fails: write the next 0.25 kB above the measurement and record it in an amendment here (`scripts/size-budget.ts:393-397`). 4. A cap is raised only by amending this ADR, in its own commit. Never in the pull request that exceeded it. The pull request that exceeds a cap either gets smaller or gets an amendment first. 5. **A line without a cap fails the run.** `cap: null` means "measured, not yet ceilinged": the line is bundled and reported like any other and the run exits non-zero printing the number, so the cap can be written from it (`scripts/size-budget.ts:14-16`, `scripts/size-budget.ts:393-397`). A default would be a guess the file ratifies by being green. 6. The script is **JavaScript only**. This package ships no stylesheet, so nothing in the script compiles or measures CSS: it imports `node:fs` and `node:path` and nothing else (`scripts/size-budget.ts:18-19`), and it measures built JavaScript out of `dist/`. 7. Measurements are min+gzip at Bun's default gzip level. That reads a little heavier than `gzip -9`. One level is cited, never a mix (`scripts/size-budget.ts:11-12`). 8. Every size in a committed document carries its command and date ([CONTRIBUTING](../../CONTRIBUTING.md)) and says whether it was measured here or is inherited. ## Consequences - No cap can be written before a build exists, so rule 5 keeps `check:size` red until the first measurement is recorded — and red earlier still, with a message telling the reader to run `bun run build`, while `dist/` is missing (`scripts/size-budget.ts:382-385`). The sequence is red, measure, cap, green, and the amendments below are where each cap was written from its measurement. - Bundling every subpath alone and then all of them together is more work per run and a longer report. Accepted: the single-number version lets a shared module quietly get expensive for everyone while every individual line stays green. - The 1.93 kB of 2.00 kB on the input system — inherited from the predecessor implementation ([ADR-0002](0002-license-and-copyright.md)) and not re-derived here — left almost no room. If the same shape reappears here, the first measurement gives a cap of 2.00 or 2.25 kB and the next feature on that line needs an amendment. That is the mechanism working, not failing. - Rule 4 makes some pull requests two commits instead of one. That is the price of a cap that means anything. - The focus ring is a special case the budget alone does not capture: it ships no stylesheet at all, so a small bundled size does not mean a complete feature. Its defaults and the six custom properties that override them are [ADR-0020](0020-focus-ring-defaults.md). - Budgets constrain the engine described in [ADR-0016](0016-scoring-constants-provenance.md); a change to the scoring rule is both a fixture question and a size question. ## Amendment, 2026-09-19: the first measurement, and three defects it exposed `bun run build && bun run check:size`, run 2026-09-19 on the extraction branch, bun 1.4.0, tsdown 0.23.0, target `browser`, minified and gzipped at Bun's default level. Caps by rule 3, the measurement rounded up to the next quarter kB. | Line | min | min+gzip | cap | what the line leaves out | |---|---|---|---|---| | core | 7.64 kB | 3.08 kB | 3.25 kB | nothing — it is the baseline | | gamepad engine | 5.37 kB | 2.48 kB | 2.50 kB | `dom/event.js`, `intent-bus.js` | | spatial engine | 7.23 kB | 3.03 kB | 3.25 kB | `dom/event.js`, `dom/query.js`, `tabbable.js` | | focus ring | 3.03 kB | 1.44 kB | 1.50 kB | `dom/event.js`, `dom/query.js`, `modality.js` | | debug | 0.75 kB | 0.50 kB | 0.75 kB | `spatial/spatial.js`, `spatial/geometry.js` | | whole package | 22.31 kB | 8.64 kB | 8.75 kB | nothing; the debug entry is not in it | The core line carries `tabbable.js` and `dom/query.js` because the root entry re-exports six tabbable symbols. That is a deliberate cost: it is what makes `isFocusable` available to a consumer without pulling an engine. `dom/raf.js` and `dom/platform.js` are charged to the spatial engine and `dom/platform.js` again to the focus ring, because no root export reaches either — a marginal cost is what each subpath adds, and both subpaths do add them. Three defects in the script were found by running it for the first time, each of which would have made a line green while measuring nothing: 1. Four lines named `../types.js` external. `src/types.ts` is types only, so nothing is emitted for it and the name matched no file. The existence guard caught it; without that guard the external would simply have been ignored. 2. Every line was bundled as a bare entry. Against a package declaring `sideEffects: false`, nothing keeps an entry's exports alive: the core measured **0.24 kB**, and its bundle was a list of export names whose declarations had all been dropped. Every line now goes through the namespace-into-a-sink module that previously only the whole-package line used. 3. Bun's `external` option does not match a relative specifier written out in full. `external: ["../dom/event.js"]` was accepted, matched nothing, and each subpath line silently measured the core along with itself. Externals now go through an `onResolve` plugin comparing resolved absolute paths. The corrected figures are the ones tabled above; before the fix the same build reported spatial 3.41 kB, focus ring 1.93 kB and debug 1.55 kB. Rule 3's quarter-kB rounding leaves the gamepad and whole-package lines at 99 % of their caps. That is the rule working as written — headroom for noise, not for growth — and the next commit that grows either one needs an amendment here first. **The react adapter line, added the same day:** 2.35 kB min, **1.13 kB min+gzip**, capped at 1.25 kB. It leaves out `input-system.js` and `modality.js`, which the core already pays for, and `react` and `react/jsx-runtime`, which are optional peers the consumer supplies. `internal/env.js` and `internal/equality.js` are charged here rather than to the core: they exist only for the adapter, and the core inlines its single assertion and has no `utils` module at all. That line exposed a fourth script defect, of the same family as the three above: the external mechanism handled relative paths only, so `react` was bundled into the measurement and the line first read **9.77 kB min+gzip, 29.04 kB minified** — a consumer's own copy of React reported as this package's cost. A bare specifier is now matched by name, and it has no file to check against because it is not a file of this package. The whole-package line does not include the react entry, and that is deliberate: its contract is "every runtime entry bundled with nothing external", and react is external by definition. ## Amendment, 2026-09-20: the pre-merge review, and four caps rewritten `bun run build && bun run check:size`, run 2026-09-20 on the extraction branch, bun 1.4.0, tsdown 0.23.0, same target and gzip level as the amendment above. Caps by rule 3, the measurement rounded up to the next quarter kB. | Line | min | min+gzip | cap | was | |---|---|---|---|---| | core | 7.76 kB | 3.13 kB | 3.25 kB | 3.25 kB, unchanged | | gamepad engine | 5.37 kB | 2.48 kB | 2.50 kB | 2.50 kB, unchanged | | spatial engine | 7.28 kB | 3.04 kB | 3.25 kB | 3.25 kB, unchanged | | focus ring | 3.22 kB | 1.51 kB | **1.75 kB** | 1.50 kB | | debug | 0.62 kB | 0.40 kB | **0.50 kB** | 0.75 kB | | react adapter | 2.87 kB | 1.30 kB | **1.50 kB** | 1.25 kB | | whole package | 22.67 kB | 8.77 kB | **9.00 kB** | 8.75 kB | Three of the four moves are raises, and they are what rule 4 exists to make deliberate. The fourth is a cut. - **Focus ring, 1.44 → 1.51 kB.** The overlay now carries its own `z-index`, as the custom property `--snav-focus-ring-z-index` defaulting to 1700 (`src/focus-ring/focus-ring.ts:52`), and fades in and out under its own WAAPI animation, `ring.animate` (`src/focus-ring/focus-ring.ts:150`), rather than a CSS `transition: opacity` — this package ships no stylesheet to hold either. Both were defects found in review, not features: without the first the ring paints behind any dialog, and without the second it cuts in and out. Both are covered: the ring paints at 1700 and stacks above a `z-index:1300` dialog (`src/focus-ring/focus-ring.browser.test.ts:135`, `src/focus-ring/focus-ring.browser.test.ts:167-168`), and it leaves a running animation behind rather than vanishing (`src/focus-ring/focus-ring.browser.test.ts:180-181`). - **React adapter, 1.13 → 1.30 kB.** `NavDocumentProvider` no longer lets an inline `doc` getter's identity reach the provider's effect, and `keymap` is now compared entry-wise the way `plugins` already was. The shared `recordEquals` is charged here for the same reason `arrayEquals` is. - **Whole package, 8.64 → 8.77 kB.** The sum of the focus-ring growth and the unwinding a throwing plugin teardown now does in `input-system.js`. The react entry is still not on this line. - **Debug, capped at 0.75 kB, measured 0.40 kB.** A cut, not a raise. The cap was written from a 0.50 kB measurement taken before `explainMove` was refactored to ask `findBestCandidate` for its winner; the line shrank and the cap did not follow, leaving 87 % slack that rule 3 does not allow. The table in the amendment above still reads 0.50 kB, which is what the line measured on the day that amendment is dated — it stands as the record of 2026-09-19, and this row is the record of what the same command prints now. Rule 4 says a cap is raised by an amendment in its own commit and never in the pull request that exceeded it. Read literally that forbids this amendment, since the caps above were themselves first written three commits earlier in this same pull request. The rule is aimed at a later feature PR quietly buying itself room; the extraction PR that both writes the first caps and then corrects them against a review is the case the rule was not written for. The amendment is still its own commit, which is the part that carries the intent. The quarter-kB rounding now leaves only the gamepad engine at 99 %. It has not moved since the first measurement, and the next commit that grows it still needs an amendment here first. ## Amendment, 2026-09-20: a fifth script defect, in the debug line's externals No cap moved. What moved is what the debug line measures, which is the same family of defect as the four above: `tabbable.js` was not in its `external` list, so the line charged the debug entry for a copy of it that no consumer downloads. It went unnoticed because `explainMove` imported nothing from the core — the omission cost nothing until something did. Adding `scanNativeSelects`, which calls the engine's `isFocusable` ([ADR-0021](0021-native-select-on-television.md)), took the line from 0.40 kB to **0.77 kB min+gzip against a 0.50 kB cap, 153 %** — a failure that was almost entirely the measurement rather than the code. The spatial line already declares `../tabbable.js` external, and for exactly the reason that applies here: the core exports it, so a subpath measured next to the core must not be charged for it. Nothing reaches `/debug` without the core — the entry re-exports the engine's own scoring types. With the external added the line reads **0.78 kB min, 0.49 kB min+gzip, 97 % of its unchanged cap** (`bun run build && bun run check:size`, 2026-09-20). That 97 % is the rule working as rule 3 intends — headroom for noise, not for growth. The next diagnostic added to the debug entry needs a cap amendment here before it lands, and unlike this one it will be a real 0.25 kB of bytes. The general lesson, now the fifth time: an `external` list is a claim about what the consumer already has, and it is only tested by a line that actually imports something. Four lines still have entries no current import exercises. ## Amendment, 2026-09-20: four lines for the keyboard, and the shape of a layout line `bun run build && bun run check:size`, same toolchain as the two amendments above. Four lines added for the on-screen keyboard of [ADR-0022](0022-virtual-keyboard.md), each written the way rule 5 asks: added with `cap: null`, the run went red printing the number, and the cap is the next quarter kB above it. | Line | min | min+gzip | cap | what the line leaves out | |---|---|---|---|---| | keyboard | 3.38 kB | 1.55 kB | 1.75 kB | `dom/event.js`, `dom/query.js`, `keymap.js`, `tabbable.js` | | keyboard layout qwerty | 0.87 kB | 0.45 kB | 0.50 kB | nothing — a layout imports nothing | | keyboard layout azerty | 0.88 kB | 0.49 kB | 0.50 kB | nothing | | keyboard layout alphabetic | 0.53 kB | 0.36 kB | 0.50 kB | nothing | **A note on rule 4, so this does not read as a dodge.** The keyboard was first measured at 1.42 kB and capped at 1.50. Two defects found by its own tests — a controlled React field rejecting a keystroke, and a caret left at position 0 on open — added 0.13 kB and took it to 1.55, over that cap. The cap moved to 1.75 rather than the module getting smaller. Rule 4 is not bent by that: it forbids raising a cap that is *in the repository*, and this one had been written minutes earlier in the same uncommitted change, from a measurement of a module that was not finished. What lands here is a first cap under rule 2, taken from the finished module. A cap that had ever been committed would have meant the amendment first, in its own commit, as rule 4 says. **What a layout line is for.** A layout imports nothing at all — its only import is the type, and a type erases — so its `external` list is empty and its number is the data, whole. That makes the line a test of decision 3 of ADR-0022 rather than a budget: a layout is supposed to be literal data, and a layout line that stops being tiny is behaviour that leaked into one. The azerty line at 0.49 of 0.50 is the tightest in the file, and it is tight for an honest reason — it carries an accented layer qwerty does not. The next character added to it needs an amendment here. ## Amendment, 2026-09-20: the keyboard cap goes to 2.25 kB, for a plugin that had no paint `bun run build && bun run check:size`, same toolchain as the amendments above. | Line | min | min+gzip | old cap | new cap | |---|---|---|---|---| | keyboard | 4.47 kB | 2.01 kB | 1.75 kB | 2.25 kB | **Rule 4, properly this time.** The 1.75 kB cap above was committed, so unlike the note in the previous amendment this is a cap *in the repository* being raised. That is what rule 4 reserves an amendment in its own commit for, and this is that commit: the code it pays for lands in the next one. **What the 0.46 kB buys.** The keyboard shipped with no style at all. A plugin that appends an unstyled `
` to `document.body` inherits the page's block layout, so it drew itself the full width of the viewport — measured at 1280 by 304 on a 1280 by 800 window, 38% of the screen, over whatever was under it — and it was anchored to nothing, which is the opposite of the `