docs/solutions/conventions/i18n-shell-namespaces-are-byte-budgeted-first-paint-surface.md
While adding billing-state CTA copy for panel gating (PR #5494, issue #4771), the natural home for the new keys looked like the existing premium.* namespace — the panel CTA already used premium.signInToUnlock, premium.upgradeToPro, etc. But tests/i18n-english-shell.test.mjs failed: it statically scans the eager chrome files (src/App.ts, src/app/panel-layout.ts, src/settings-main.ts, src/settings-window.ts, and everything under src/components/) for t('...') calls whose keys fall under SHELL_KEY_PREFIXES (a list that includes premium., shell., header., panels., common., and others — see the constant near the top of the test), and requires every such key to exist byte-identical in src/locales/en.shell.json. And en.shell.json is capped by SHELL_BUDGET_BYTES (50 KB) — at the time of PR #5494 it sat 69 bytes under the cap, so adding even one new mirrored key meant either raising a first-paint performance budget or finding another home.
The shell (en.shell.json) exists so first-paint chrome renders English text before the full locale loads. Its namespaces are therefore a scarce, perf-budgeted surface, not a semantic taxonomy:
components.billingState.* (mirroring the existing non-shell components.checkoutFailureBanner.* precedent) instead of premium.*.SHELL_KEY_PREFIXES in tests/i18n-english-shell.test.mjs before choosing a namespace for any key referenced from an eager chrome file. A prefix hit means: mirrored entry in en.shell.json + budget pressure. components. is only shell-gated for the specific sub-prefixes listed there (components.map., components.panel., components.proBanner., components.settings., components.deckgl.views.); other components.* children are free.SHELL_BUDGET_BYTES. The cap is a first-paint payload budget; raising it is a performance decision, not a namespace convenience.t('...') key must exist in en.json) and locale completeness (every en.json key must exist in all ~25 locale files, en.shell.json exempt). Dynamic keys (t(variable)) bypass the static scan — when using them, add an explicit test asserting each possible key resolves in en.json, as tests/billing-state-wiring.test.mts does for the banner variant keys.Choosing a shell namespace for post-boot copy either reds CI (budget exceeded) or, worse, quietly grows the first-paint payload every visitor downloads — paying real bytes for strings no one can see until seconds after boot. Choosing a non-shell namespace for genuinely first-paint copy is the opposite failure: raw i18n keys flash on screen before the full locale loads. The namespace decision is a rendering-time decision.
Any new t() key referenced from an eager chrome file, and any copy addition to an existing shell namespace (premium., shell., header., panels., common., and the rest of SHELL_KEY_PREFIXES). Ask one question: can this string render before the full locale file loads? If no, keep it out of shell namespaces.
PR #5494's billing CTA keys, referenced from src/components/Panel.ts (an eager chrome file):
// Rejected: trips the shell mirror + 50KB budget (premium. is in SHELL_KEY_PREFIXES)
t('premium.billingRenewalPendingDesc')
// Shipped: components.billingState. is not shell-gated; these CTAs render
// post-entitlement-resolution, well after full-locale load
t('components.billingState.renewalPendingDesc')
components.billingState.* home was chosen.