# 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 `