Docs Architecture
Runtime tiers
Every primitive declares one of four tiers, each with a testable budget for what it may cost at runtime, and documents it in its contract and on its page. The budget is the constraint; the word lightweight on its own is not one (ADR 0003).
On this page
The four tiers
| Tier | Name | Budget (ADR 0003) | Meaning |
|---|---|---|---|
| 0 | Native | No runtime JavaScript, no instance listeners, no instance state objects | The browser owns the behavior; the package owns styles, a contract and validation. |
| 1 | Delegated micro-behavior | One shared listener per required event type; no per-root listener; state in the DOM | Small shared behavior, registered once per page; no eager controller per instance. |
| 2 | Lazy scoped controller | WeakMap state created on first interaction; AbortController-scoped listeners; cleanup | Complex behavior gets per-instance state only when the instance is used. |
| 3 | Application-controlled | Svelte state owned by the application; never required by a default primitive | The application intentionally owns the state: a selected tab in the URL, a sort in a query. |
The tier of a primitive is decided by two questions, asked in order. A primitive that answers "no" twice needs a scoped controller; the combobox is the one that does today.
Tier 3 is not a primitive tier. It names the case where the application intentionally owns the state, which is how Svelte Lean Table works: its state is one plain object the application can bind, serialize and restore.
<script lang="ts">
import { DataTable, type TableState } from '@svelte-lean/table';
// Tier 3: the application owns the state and stores it wherever it wants.
let state = $state<Partial<TableState>>({ sorting: [{ id: 'revenue', desc: true }] });
</script>
<DataTable {data} {columns} getRowId={(row) => row.id} bind:state />Declaring a tier
The tier is a field of the contract constant every primitive exports, next to its base, parts,
options, routed events and emitted events. The contract in prose (contract.md)
repeats it in its first line and justifies it.
// packages/primitives/src/tabs/contract.ts: the static description of the protocol
export const tabsContract = {
name: 'tabs',
tier: 1,
base: 'ARIA tabs markup (role=tablist, tab, tabpanel)',
parts: ['list', 'trigger', 'panel'],
options: {
value: {},
activation: { values: ['automatic', 'manual'], default: 'automatic' },
orientation: { values: ['horizontal', 'vertical'], default: 'horizontal' },
loop: { values: ['true', 'false'], default: 'true' }
},
events: ['click', 'keydown'],
emits: ['slean:change']
} as const satisfies BehaviorContract;The root entry of the package exposes the same metadata for every primitive and registers nothing. The sidebar chips, the runtime block on each primitive page and the classification below read it from there; no tier on this site is typed by hand.
import { BEHAVIORS } from '@svelte-lean/primitives';
BEHAVIORS.dialog.tier; // 0
BEHAVIORS.dialog.module; // null: nothing to register
BEHAVIORS.tabs.tier; // 1
BEHAVIORS.tabs.events; // ['click', 'keydown']
BEHAVIORS.tabs.module; // '@svelte-lean/primitives/tabs/register'Classification
| Primitive | Tier | Native base | Shared listeners | Registration module | Behavior JS (brotli) |
|---|---|---|---|---|---|
| accordion | 0 | <details name> group | none | none | 0 B |
| alert | 0 | <div> + live region roles (status, alert) | none | none | 0 B |
| alert-dialog | 0 | <dialog role="alertdialog" closedby="none"> + command/commandfor | none | none | 0 B |
| autocomplete | 0 | <input list> + <datalist> | none | none | 0 B |
| avatar | 0 | <img> with initials behind it | none | none | 0 B |
| badge | 0 | <span> | none | none | 0 B |
| breadcrumb | 0 | <nav> + <ol> + aria-current="page" | none | none | 0 B |
| button | 0 | <button type="button"> | none | none | 0 B |
| button-group | 0 | buttons in role="group" | none | none | 0 B |
| card | 0 | <article> | none | none | 0 B |
| carousel | 0 | scroll-snap, ::scroll-button(), ::scroll-marker | none | none | 0 B |
| checkbox | 0 | <input type="checkbox"> | none | none | 0 B |
| color-field | 0 | <input type="color"> | none | none | 0 B |
| date-field | 0 | <input type="date|time|datetime-local|month|week"> | none | none | 0 B |
| descriptions | 0 | <dl> with <div> groups of <dt> and <dd> | none | none | 0 B |
| dialog | 0 | <dialog> + command/commandfor | none | none | 0 B |
| disclosure | 0 | <details> + <summary> | none | none | 0 B |
| drawer | 0 | <dialog closedby="any"> + command/commandfor | none | none | 0 B |
| empty | 0 | a heading, a paragraph and an optional action | none | none | 0 B |
| field | 0 | <label for>, a control, aria-describedby | none | none | 0 B |
| file-field | 0 | <input type="file"> | none | none | 0 B |
| input | 0 | <input type="text|email|password|search|url|tel">, <textarea> | none | none | 0 B |
| kbd | 0 | <kbd> | none | none | 0 B |
| meter | 0 | <meter min max low high optimum value> | none | none | 0 B |
| otp-field | 0 | <input inputmode="numeric" autocomplete="one-time-code" maxlength> | none | none | 0 B |
| pagination | 0 | <nav> + links + aria-current="page" | none | none | 0 B |
| popover | 0 | [popover] + popovertarget or command/commandfor | none | none | 0 B |
| progress | 0 | <progress value max> | none | none | 0 B |
| radio-group | 0 | <fieldset> + <input type="radio" name="…"> | none | none | 0 B |
| rating | 0 | <fieldset> of <input type="radio"> sharing a name | none | none | 0 B |
| scroll-area | 0 | overflow: auto + tabindex="0" in role="region" | none | none | 0 B |
| segmented | 0 | <fieldset> of <input type="radio"> sharing a name | none | none | 0 B |
| separator | 0 | <hr>, role="separator" | none | none | 0 B |
| skeleton | 0 | aria-hidden placeholders in an aria-busy region | none | none | 0 B |
| slider | 0 | <input type="range"> | none | none | 0 B |
| spinner | 0 | role="status" with a text label | none | none | 0 B |
| steps | 0 | <ol> + aria-current="step" | none | none | 0 B |
| switch | 0 | <input type="checkbox" role="switch"> | none | none | 0 B |
| tag | 0 | <span> with an optional remove <button> | none | none | 0 B |
| timeline | 0 | <ol> of <li> events with <time datetime> | none | none | 0 B |
| calendar | 1 | role=grid of day buttons in a <table> | click, keydown, pointerover, pointerout | @svelte-lean/primitives/calendar/register | 3925 B |
| context-menu | 1 | contextmenu event + [popover] ARIA menu | contextmenu | @svelte-lean/primitives/context-menu/register | 1409 B |
| file-drop | 1 | <input type="file"> stretched over a drop area | dragenter, dragleave, drop, change | @svelte-lean/primitives/file-drop/register | 1107 B |
| hover-card | 1 | popover="hint" next to its trigger | pointerover, pointerout, focusin, focusout, keydown | @svelte-lean/primitives/hover-card/register | 1353 B |
| listbox | 1 | ARIA listbox markup (role=listbox, option) | click, keydown | @svelte-lean/primitives/listbox/register | 1838 B |
| menu | 1 | [popover] + ARIA menu (role=menu, menuitem) | click, keydown, toggle | @svelte-lean/primitives/menu/register | 1932 B |
| menubar | 1 | role="menubar" of popovertarget menu buttons | keydown, focusin, pointerover | @svelte-lean/primitives/menubar/register | 1457 B |
| number-field | 1 | <input type="number">, stepUp()/stepDown() | click | @svelte-lean/primitives/number-field/register | 1082 B |
| range-slider | 1 | two <input type="range"> | input | @svelte-lean/primitives/range-slider/register | 1291 B |
| tabs | 1 | ARIA tabs markup (role=tablist, tab, tabpanel) | click, keydown | @svelte-lean/primitives/tabs/register | 1521 B |
| toggle | 1 | <button type="button" aria-pressed> | click | @svelte-lean/primitives/toggle/register | 888 B |
| toggle-group | 1 | <button aria-pressed> items in role="group" | click, keydown | @svelte-lean/primitives/toggle-group/register | 1569 B |
| toolbar | 1 | role="toolbar" | keydown, focusin | @svelte-lean/primitives/toolbar/register | 1494 B |
| tooltip | 1 | popover="hint" + role="tooltip" + aria-describedby | pointerover, pointerout, focusin, focusout, keydown | @svelte-lean/primitives/tooltip/register | 1297 B |
| tree | 1 | role="tree", treeitem, group, aria-expanded | click, keydown | @svelte-lean/primitives/tree/register | 1919 B |
| combobox | 2 | <input role="combobox"> + [popover] + ARIA listbox (role=listbox, option) | click, keydown, input, pointerdown, pointerover, focusout, toggle | @svelte-lean/primitives/combobox/register | 2564 B |
| date-picker | 2 | <input type="date"> + role=combobox text input + [popover] + calendar | click, keydown, input, change, pointerdown, focusout, toggle | @svelte-lean/primitives/date-picker/register | 4912 B |
| select | 2 | <select> + role=combobox trigger + [popover] + ARIA listbox (role=listbox, option) | click, keydown, pointerdown, pointerover, focusout, change, toggle | @svelte-lean/primitives/select/register | 3362 B |
| splitter | 2 | role="separator" (WAI-ARIA window splitter) + CSS grid | keydown, pointerdown | @svelte-lean/primitives/splitter/register | 1683 B |
| toast | 2 | popover="manual" region + aria-live list | click, pointerover, pointerout, focusin, focusout | @svelte-lean/primitives/toast/register | 1396 B |
| Svelte Lean Table | 3 | <table> | none | none (a Svelte component) | measured per entry point |
The Tier 0 value is the Svelte Lean chunk of the native-only production fixture; the Tier 1 and Tier 2 values are the register module built with Vite in production mode, including the shared kernel (method). The tier of every primitive was decided before its contract, with the reason, in docs/catalog.md; the Primitives overview lists each one with its measured cost.
Targets and measurements
ADR 0003 states initial targets and says they are published only after build output proves them. They are shown here as targets, next to the measured value, the budget the size script enforces in CI, and a status computed from the two.
| Subject | Target (ADR 0003) | Enforced budget | Measured | Status |
|---|---|---|---|---|
| Tabs registration including the shared kernel | Tabs < 1 kB brotli | 1700 B brotli | 1521 B brotli | above target |
| Basic menu registration including the shared kernel | basic Menu ~1–2 kB | 2100 B brotli | 1932 B brotli | within target |
| Every behavior that exists today, on one page, with one kernel | entire common behavior set < 5 kB | — | 2351 B brotli | within target |
Where the measured value is above the ADR's initial target, the table says so. The number that
is enforced is the budget in the size file, set ten to fifteen percent above the measured value
so a regression fails CI; the ADR's figure is the intent the package is measured against, not a
claim. Every value here is read from packages/primitives/artifacts/size.json and fixtures/results.json.
Where state lives
For every state value the same four questions decide where it is stored, in this order.
- Does the browser already own it (
checked,disabled,open, focus, validity, popover open)? Then the browser state is the state. - Is there a meaningful DOM or ARIA representation (
aria-selected,tabindex,hidden)? Then the DOM is the source of truth. This is Tier 1. - Is it ephemeral implementation state (a typeahead buffer, a pointer origin, an open session)?
Then it is lazy JavaScript state in a
WeakMap, created on first use. The menu and the listbox keep one typeahead buffer for the whole page; the combobox keeps one entry per root that was used, holding the value it last filtered by and the open session'sAbortController, and deletes it when focus leaves the root. - Is it application state? Then Svelte and the application own it. This is Tier 3.
Example
A Tier 1 behavior on this page. The tabs below, the source tabs of the frame and the install
blocks elsewhere on the site are served by the same registration: one click and one keydown listener on the document.
<div data-slean="tabs" data-slean-value="account">
<div role="tablist" aria-label="Settings"
data-slean-part="list">
<button type="button" id="tab-account" role="tab"
aria-controls="panel-account" aria-selected="true"
tabindex="0" data-slean-part="trigger"
data-slean-value="account">Account</button>
<button type="button" id="tab-security" role="tab"
aria-controls="panel-security" aria-selected="false"
tabindex="-1" data-slean-part="trigger"
data-slean-value="security">Security</button>
</div>
<div id="panel-account" role="tabpanel"
aria-labelledby="tab-account" data-slean-part="panel"
data-slean-value="account">…</div>
<div id="panel-security" role="tabpanel"
aria-labelledby="tab-security" data-slean-part="panel"
data-slean-value="security" hidden>…</div>
</div><script lang="ts">
// With @svelte-lean/vite this import is injected for the
// static data-slean="tabs" marker. Without the plugin,
// write it yourself once; both register the same behavior.
import '@svelte-lean/primitives/tabs/register';
import '@svelte-lean/styles/tabs.css';
</script>
<div data-slean="tabs" data-slean-value="account">
<div role="tablist" aria-label="Settings"
data-slean-part="list">
<button type="button" id="tab-account" role="tab"
aria-controls="panel-account" aria-selected="true"
tabindex="0" data-slean-part="trigger"
data-slean-value="account">Account</button>
<button type="button" id="tab-security" role="tab"
aria-controls="panel-security" aria-selected="false"
tabindex="-1" data-slean-part="trigger"
data-slean-value="security">Security</button>
</div>
<div id="panel-account" role="tabpanel"
aria-labelledby="tab-account" data-slean-part="panel"
data-slean-value="account">…</div>
<div id="panel-security" role="tabpanel"
aria-labelledby="tab-security" data-slean-part="panel"
data-slean-value="security" hidden>…</div>
</div>A Tier 2 behavior on the same page. The combobox below shares the document listeners with every
other behavior; what is specific to it exists only while it is used: a WeakMap entry for this root after the first key or click, and one document pointerdown listener, scoped by an AbortController, while the popup is
open. Close it and the listener is gone; move focus away and the entry is gone. In a development
build the panel under it counts these controllers.
- Ankara
- Berlin
- Lisbon
No city matches
<label for="city-input">City</label>
<div id="city" data-slean="combobox">
<input
id="city-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="city-list"
aria-autocomplete="list"
autocomplete="off"
placeholder="Select a city"
data-slean-part="input"
/>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<button
type="button"
tabindex="-1"
aria-label="Show cities"
aria-expanded="false"
data-slean-part="toggle"
></button>
<div id="city-popup" popover="manual" data-slean-part="popup">
<ul id="city-list" role="listbox" aria-label="Cities">
<li id="city-ankara" role="option" aria-selected="false" data-slean-value="ankara">Ankara</li>
<li id="city-berlin" role="option" aria-selected="false" data-slean-value="berlin">Berlin</li>
<li
id="city-cairo"
role="option"
aria-selected="false"
aria-disabled="true"
data-slean-value="cairo"
>
Cairo
</li>
<li id="city-lisbon" role="option" aria-selected="false" data-slean-value="lisbon">Lisbon</li>
<li id="city-oslo" role="option" aria-selected="false" data-slean-value="oslo">Oslo</li>
</ul>
<p data-slean-part="empty" hidden>No city matches</p>
</div>
</div><script lang="ts">
// With @svelte-lean/vite this import is injected for the static data-slean="combobox" marker.
// Without the plugin, write it yourself once; both paths register the same behavior.
import '@svelte-lean/primitives/combobox/register';
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/combobox.css';
</script>
<label for="city-input">City</label>
<div id="city" data-slean="combobox">
<input
id="city-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="city-list"
aria-autocomplete="list"
autocomplete="off"
placeholder="Select a city"
data-slean-part="input"
/>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<button
type="button"
tabindex="-1"
aria-label="Show cities"
aria-expanded="false"
data-slean-part="toggle"
></button>
<div id="city-popup" popover="manual" data-slean-part="popup">
<ul id="city-list" role="listbox" aria-label="Cities">
<li id="city-ankara" role="option" aria-selected="false" data-slean-value="ankara">Ankara</li>
<li id="city-berlin" role="option" aria-selected="false" data-slean-value="berlin">Berlin</li>
<li
id="city-cairo"
role="option"
aria-selected="false"
aria-disabled="true"
data-slean-value="cairo"
>
Cairo
</li>
<li id="city-lisbon" role="option" aria-selected="false" data-slean-value="lisbon">Lisbon</li>
<li id="city-oslo" role="option" aria-selected="false" data-slean-value="oslo">Oslo</li>
</ul>
<p data-slean-part="empty" hidden>No city matches</p>
</div>
</div>The panel below reads this page after it loads: the primitives in the DOM, the behaviors
registered on the document runtime, the shared listeners counted by the runtime and,
independently, by a script that wrapped addEventListener before any application
code ran, and the lazy controllers in a development build. The proof page repeats the reading with 1, 100 and 1000 roots.
- Runtime
- Read from the page after it loads; requires JavaScript.
Source
- ADR 0003, Runtime tiers
- ADR 0004, Shared event router
- tabs.test.ts: 1000 roots keep one listener per event type and no per-root state
- listeners.spec.ts: the same count in a real browser, two independent accounts
- combobox.test.ts and lazy.spec.ts: no controller for untouched roots, one per root that is used, released on close and focus loss (shipping invariant D)
- size.mjs: the script that writes the measured values and enforces the budgets