# Runtime tiers

> The four runtime tiers with their budgets, how a primitive declares one, the classification of every primitive, and the ADR 0003 targets next to the measured values.

Page: https://svelte-lean.vercel.app/docs/architecture/runtime-tiers · Markdown: https://svelte-lean.vercel.app/docs/architecture/runtime-tiers.md · Index: https://svelte-lean.vercel.app/llms.txt

Every primitive declares one of four tiers, each with a testable runtime budget (ADR 0003).

## 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. |
| 1 | Delegated micro-behavior | One shared listener per required event type; no per-root listener; state in the DOM | Registered once per page; no controller per instance. |
| 2 | Lazy scoped controller | WeakMap state created on first interaction; AbortController-scoped listeners; cleanup | Per-instance state only once the instance is used. |
| 3 | Application-controlled | Svelte state owned by the application; never required by a default primitive | The application owns the state, e.g. a sort in the URL. |

Two questions, in order, decide the tier; two "no" answers mean a scoped controller.

Native before JavaScript. Every primitive declares its tier in its contract.

Tier 3 is not a primitive tier: it is how Svelte Lean Table works, one plain state object the application can bind, serialize and restore.

`members.svelte`

```svelte
<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 each primitive's contract constant; `contract.md` states and justifies it in its first line.

`contract.ts`

```ts
// 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', 'native'],
	options: {
		value: {},
		activation: { values: ['automatic', 'manual'], default: 'automatic' },
		orientation: { values: ['horizontal', 'vertical'], default: 'horizontal' },
		loop: { values: ['true', 'false'], default: 'true' },
		render: { values: ['app'] }
	},
	events: ['click', 'keydown'],
	emits: ['slean:change']
} as const satisfies BehaviorContract;
```

The root entry exposes the metadata and registers nothing; every tier on this site is read from it.

```ts
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](https://svelte-lean.vercel.app/primitives/accordion.md) | 0 | `<details> + <summary>, exclusive groups through name` | none | none | 0 B |
| [alert](https://svelte-lean.vercel.app/primitives/alert.md) | 0 | `<div> + live region roles (status, alert)` | none | none | 0 B |
| [avatar](https://svelte-lean.vercel.app/primitives/avatar.md) | 0 | `<img> with initials behind it` | none | none | 0 B |
| [badge](https://svelte-lean.vercel.app/primitives/badge.md) | 0 | `<span> with an optional remove <button>` | none | none | 0 B |
| [breadcrumb](https://svelte-lean.vercel.app/primitives/breadcrumb.md) | 0 | `<nav> + <ol> + aria-current="page"` | none | none | 0 B |
| [button](https://svelte-lean.vercel.app/primitives/button.md) | 0 | `<button type="button">` | none | none | 0 B |
| [button-group](https://svelte-lean.vercel.app/primitives/button-group.md) | 0 | `buttons in role="group"` | none | none | 0 B |
| [card](https://svelte-lean.vercel.app/primitives/card.md) | 0 | `<article>` | none | none | 0 B |
| [carousel](https://svelte-lean.vercel.app/primitives/carousel.md) | 0 | `scroll-snap, ::scroll-button(), ::scroll-marker` | none | none | 0 B |
| [checkbox](https://svelte-lean.vercel.app/primitives/checkbox.md) | 0 | `<input type="checkbox">, role="switch"` | none | none | 0 B |
| [descriptions](https://svelte-lean.vercel.app/primitives/descriptions.md) | 0 | `<dl> with <div> groups of <dt> and <dd>` | none | none | 0 B |
| [dialog](https://svelte-lean.vercel.app/primitives/dialog.md) | 0 | `<dialog> + command/commandfor` | none | none | 0 B |
| [empty](https://svelte-lean.vercel.app/primitives/empty.md) | 0 | `a heading, a paragraph and an optional action` | none | none | 0 B |
| [field](https://svelte-lean.vercel.app/primitives/field.md) | 0 | `<label for>, a control, aria-describedby; constraint validation` | none | none | 0 B |
| [kbd](https://svelte-lean.vercel.app/primitives/kbd.md) | 0 | `<kbd>` | none | none | 0 B |
| [meter](https://svelte-lean.vercel.app/primitives/meter.md) | 0 | `<meter min max low high optimum value>` | none | none | 0 B |
| [nav](https://svelte-lean.vercel.app/primitives/nav.md) | 0 | `<nav> + <ul> of links + <details name> groups + aria-current="page"` | none | none | 0 B |
| [pagination](https://svelte-lean.vercel.app/primitives/pagination.md) | 0 | `<nav> + links + aria-current="page"` | none | none | 0 B |
| [popover](https://svelte-lean.vercel.app/primitives/popover.md) | 0 | `[popover] + popovertarget or command/commandfor` | none | none | 0 B |
| [progress](https://svelte-lean.vercel.app/primitives/progress.md) | 0 | `<progress value max>` | none | none | 0 B |
| [radio-group](https://svelte-lean.vercel.app/primitives/radio-group.md) | 0 | `<fieldset> + <input type="radio" name="…">` | none | none | 0 B |
| [rating](https://svelte-lean.vercel.app/primitives/rating.md) | 0 | `<fieldset> of <input type="radio"> sharing a name` | none | none | 0 B |
| [scroll-area](https://svelte-lean.vercel.app/primitives/scroll-area.md) | 0 | `overflow: auto + tabindex="0" in role="region"` | none | none | 0 B |
| [separator](https://svelte-lean.vercel.app/primitives/separator.md) | 0 | `<hr>, role="separator"` | none | none | 0 B |
| [skeleton](https://svelte-lean.vercel.app/primitives/skeleton.md) | 0 | `aria-hidden placeholders in an aria-busy region` | none | none | 0 B |
| [steps](https://svelte-lean.vercel.app/primitives/steps.md) | 0 | `<ol> + aria-current="step"` | none | none | 0 B |
| [timeline](https://svelte-lean.vercel.app/primitives/timeline.md) | 0 | `<ol> of <li> events with <time datetime>` | none | none | 0 B |
| [calendar](https://svelte-lean.vercel.app/primitives/calendar.md) | 1 | `role=grid of day buttons in a <table>` | click, keydown, change, reset, pointerover, pointerout | @svelte-lean/primitives/calendar/register | 4594 B |
| [checkbox-group](https://svelte-lean.vercel.app/primitives/checkbox-group.md) | 1 | `<fieldset> + <input type="checkbox">, indeterminate` | change, focusin, reset | @svelte-lean/primitives/checkbox-group/register | 1456 B |
| [copy](https://svelte-lean.vercel.app/primitives/copy.md) | 1 | `navigator.clipboard.writeText + <button>` | click | @svelte-lean/primitives/copy/register | 1291 B |
| [file-drop](https://svelte-lean.vercel.app/primitives/file-drop.md) | 1 | `<input type="file"> stretched over a drop area; DataTransfer for the file list` | dragenter, dragleave, drop, paste, input, change, click, reset | @svelte-lean/primitives/file-drop/register | 2983 B |
| [findable](https://svelte-lean.vercel.app/primitives/findable.md) | 1 | `hidden="until-found" + beforematch` | beforematch | @svelte-lean/primitives/findable/register | 1887 B |
| [hint](https://svelte-lean.vercel.app/primitives/hint.md) | 1 | `role="tooltip" + popover="manual" + aria-describedby` | pointerover, pointerout, pointerdown, focusin, focusout, keydown | @svelte-lean/primitives/hint/register | 2687 B |
| [hover-card](https://svelte-lean.vercel.app/primitives/hover-card.md) | 1 | `popover="hint" named by interestfor` | pointerover, pointerout, focusin, focusout, keydown | @svelte-lean/primitives/hover-card/register | 1986 B |
| [input](https://svelte-lean.vercel.app/primitives/input.md) | 1 | `<input> of any type, <textarea>; stepUp()/stepDown(), type="password"↔"text"` | click, pointerdown, pointerover, focusin | @svelte-lean/primitives/input/register | 1808 B |
| [listbox](https://svelte-lean.vercel.app/primitives/listbox.md) | 1 | `ARIA listbox markup (role=listbox, option)` | click, keydown, change, reset | @svelte-lean/primitives/listbox/register | 2357 B |
| [menu](https://svelte-lean.vercel.app/primitives/menu.md) | 1 | `[popover] + ARIA menu (role=menu, menuitem)` | click, keydown, beforetoggle, toggle | @svelte-lean/primitives/menu/register | 2773 B |
| [menubar](https://svelte-lean.vercel.app/primitives/menubar.md) | 1 | `role="menubar" of popovertarget menu buttons` | keydown, focusin, pointerover | @svelte-lean/primitives/menubar/register | 2064 B |
| [slider](https://svelte-lean.vercel.app/primitives/slider.md) | 1 | `<input type="range">, one or two in a group` | input, reset | @svelte-lean/primitives/slider/register | 1549 B |
| [tabs](https://svelte-lean.vercel.app/primitives/tabs.md) | 1 | `ARIA tabs markup (role=tablist, tab, tabpanel)` | click, keydown | @svelte-lean/primitives/tabs/register | 1839 B |
| [toggle](https://svelte-lean.vercel.app/primitives/toggle.md) | 1 | `<button type="button" aria-pressed>` | click | @svelte-lean/primitives/toggle-group/register | not measured |
| [toggle-group](https://svelte-lean.vercel.app/primitives/toggle-group.md) | 1 | `<button aria-pressed> items in role="group"` | click, keydown | @svelte-lean/primitives/toggle-group/register | 1932 B |
| [toolbar](https://svelte-lean.vercel.app/primitives/toolbar.md) | 1 | `role="toolbar"` | keydown, focusin | @svelte-lean/primitives/toolbar/register | 1706 B |
| [tree](https://svelte-lean.vercel.app/primitives/tree.md) | 1 | `role="tree", treeitem, group, aria-expanded` | click, keydown | @svelte-lean/primitives/tree/register | 2140 B |
| [autocomplete](https://svelte-lean.vercel.app/primitives/autocomplete.md) | 2 | `<input list> + <datalist>; owned: role=combobox input + [popover] + ARIA listbox` | click, keydown, input, pointerdown, pointerover, focusin, focusout, toggle | @svelte-lean/primitives/autocomplete/register | 3962 B |
| [date-picker](https://svelte-lean.vercel.app/primitives/date-picker.md) | 2 | `<input type="date"> + role=combobox text input + [popover] + calendar` | click, keydown, input, change, pointerdown, pointerover, focusin, focusout, toggle, reset | @svelte-lean/primitives/date-picker/register | 9475 B |
| [select](https://svelte-lean.vercel.app/primitives/select.md) | 2 | `<select> + role=combobox trigger + [popover] + ARIA listbox (role=listbox, option)` | click, keydown, input, pointerdown, pointerover, focusout, change, toggle, reset, animationstart | @svelte-lean/primitives/select/register | 5022 B |
| [sortable](https://svelte-lean.vercel.app/primitives/sortable.md) | 2 | `<ul>/<ol> + <button> handles; document order is the value` | pointerdown, keydown, click | @svelte-lean/primitives/sortable/register | 3833 B |
| [splitter](https://svelte-lean.vercel.app/primitives/splitter.md) | 2 | `role="separator" (WAI-ARIA window splitter) + CSS grid` | keydown, pointerdown | @svelte-lean/primitives/splitter/register | 1908 B |
| [toast](https://svelte-lean.vercel.app/primitives/toast.md) | 2 | `popover="manual" region + aria-live list` | click, pointerover, pointerout, focusin, focusout | @svelte-lean/primitives/toast/register | 1616 B |
| [Svelte Lean Table](https://svelte-lean.vercel.app/table.md) | 3 | `<table>` | none | none (a Svelte component) | [measured per entry point](https://svelte-lean.vercel.app/table.md) |

- Tier 0: the Svelte Lean chunk of the native-only production fixture.
- Tier 1 and 2: the register module built with Vite in production mode, shared kernel included ([method](https://svelte-lean.vercel.app/performance/methodology.md)).
- Each tier was decided, with the reason, in docs/catalog.md before the contract.

## Targets and measurements

ADR 0003 initial targets next to the CI budget and the measured value; the status is computed.

| Subject | Target (ADR 0003) | Enforced budget | Measured | Status |
| --- | --- | --- | --- | --- |
| Tabs registration including the shared kernel | Tabs \< 1 kB brotli | 1900 B brotli | 1839 B brotli | above target |
| Basic menu registration including the shared kernel | basic Menu ~1–2 kB | 2800 B brotli | 2773 B brotli | above target |
| Every behavior that exists today, on one page, with one kernel | entire common behavior set \< 5 kB | — | 4600 B brotli | within target |

- The enforced number is the budget in the size file, ten to fifteen percent above the measured value, so a regression fails CI. The ADR figure is an intent, not a claim.
- Values come from `packages/primitives/artifacts/size.json` and `fixtures/results.json`.

## Where state lives

First match wins:

1. Browser-owned (`checked`, `disabled`, `open`, focus, validity, popover open): the browser state.
2. DOM or ARIA representation (`aria-selected`, `tabindex`, `hidden`): the DOM (Tier 1).
3. Ephemeral (typeahead buffer, pointer origin, open session): a lazy `WeakMap` entry. Menu and listbox share one typeahead buffer per page; the select and the autocomplete keep one entry per used root and delete it when focus leaves.
4. Application state: Svelte and the application (Tier 3).

## Example

Tier 1: these tabs and the frame's source tabs share one `click` and one `keydown` listener on the document.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static
	// data-slean="tabs" marker. Without the plugin, write it yourself once:
	// import '@svelte-lean/primitives/tabs/register';
	import '@svelte-lean/styles/tabs.css';
	import { tabsParts } from '@svelte-lean/primitives/tabs';

	// Roles, ids, aria-controls, aria-labelledby, selection and the tab stop.
	const t = tabsParts('settings');
</script>

<div id="settings" data-slean="tabs" data-slean-value="account">
	<div {...t.list} aria-label="Settings">
		<button {...t.tab('account', 'account')}>Account</button>
		<button {...t.tab('security', 'account')}>Security</button>
	</div>
	<div {...t.panel('account', 'account')} tabindex="0">Account settings.</div>
	<div {...t.panel('security', 'account')} tabindex="0">Security settings.</div>
</div>
```

Arrow keys, Home and End move between triggers; state is written to aria-selected, tabindex and hidden.

Tier 2: the autocomplete gets a `WeakMap` entry on first use and a scoped `pointerdown` listener while open; both go when it closes and focus leaves.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static
	// data-slean="autocomplete" marker.
	import '@svelte-lean/styles/control.css';
	import '@svelte-lean/styles/autocomplete.css';
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';

	const p = autocompleteParts('city');
</script>

<label {...p.label}>City</label>
<div id="city" data-slean="autocomplete">
	<input {...p.input} name="city" placeholder="Select a city" />
	<datalist {...p.datalist}>
		<option value="Ankara"></option>
		<option value="Berlin"></option>
		<option value="Cairo" disabled></option>
		<option value="Lisbon"></option>
		<option value="Oslo"></option>
	</datalist>
</div>
```

Focus, type or press ArrowDown: the controller is created then, the popup is created from the datalist, and both are released when focus leaves.

The live reading of this page; the [proof page](https://svelte-lean.vercel.app/proof.md) repeats it 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
- autocomplete.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
