sveltelean
Versionv0.2.0 GitHub

The four tiers

TierNameBudget (ADR 0003)Meaning
0NativeNo runtime JavaScript, no instance listeners, no instance state objectsThe browser owns the behavior; the package owns styles, a contract and validation.
1Delegated micro-behaviorOne shared listener per required event type; no per-root listener; state in the DOMSmall shared behavior, registered once per page; no eager controller per instance.
2Lazy scoped controllerWeakMap state created on first interaction; AbortController-scoped listeners; cleanupComplex behavior gets per-instance state only when the instance is used.
3Application-controlledSvelte state owned by the application; never required by a default primitiveThe 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.

How a primitive is assigned its runtime tierCan the browser do it? Yes: native, Tier 0, no Svelte Lean JavaScript. No: is one shared listener per event type enough? Yes: shared behavior, Tier 1. No: lazy scoped controller, Tier 2, with state created on first interaction.Can the browser do it?yesnoNative · Tier 0no Svelte Lean JavaScriptIs one shared listener perevent type enough?yesnoShared behavior · Tier 1one listener per event typeTier 2lazy scopedcontroller How a primitive is assigned its runtime tierCan the browser do it? Yes: native, Tier 0, no Svelte Lean JavaScript. No: is one shared listener per event type enough? Yes: shared behavior, Tier 1. No: lazy scoped controller, Tier 2, with state created on first interaction.Can the browser do it?yesnoNative · Tier 0no Svelte Lean JavaScriptIs one shared listener perevent type enough?yesnoShared behavior · Tier 1one listener per event typeLazy scoped controller · Tier 2state created on first interaction
Native before JavaScript. Every primitive declares its tier in its contract.

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.

members.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 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.

contract.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'],
	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.

TypeScript
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

PrimitiveTierNative baseShared listenersRegistration moduleBehavior JS (brotli)
accordion0<details name> groupnonenone0 B
alert0<div> + live region roles (status, alert)nonenone0 B
alert-dialog0<dialog role="alertdialog" closedby="none"> + command/commandfornonenone0 B
autocomplete0<input list> + <datalist>nonenone0 B
avatar0<img> with initials behind itnonenone0 B
badge0<span>nonenone0 B
breadcrumb0<nav> + <ol> + aria-current="page"nonenone0 B
button0<button type="button">nonenone0 B
button-group0buttons in role="group"nonenone0 B
card0<article>nonenone0 B
carousel0scroll-snap, ::scroll-button(), ::scroll-markernonenone0 B
checkbox0<input type="checkbox">nonenone0 B
color-field0<input type="color">nonenone0 B
date-field0<input type="date|time|datetime-local|month|week">nonenone0 B
descriptions0<dl> with <div> groups of <dt> and <dd>nonenone0 B
dialog0<dialog> + command/commandfornonenone0 B
disclosure0<details> + <summary>nonenone0 B
drawer0<dialog closedby="any"> + command/commandfornonenone0 B
empty0a heading, a paragraph and an optional actionnonenone0 B
field0<label for>, a control, aria-describedbynonenone0 B
file-field0<input type="file">nonenone0 B
input0<input type="text|email|password|search|url|tel">, <textarea>nonenone0 B
kbd0<kbd>nonenone0 B
meter0<meter min max low high optimum value>nonenone0 B
otp-field0<input inputmode="numeric" autocomplete="one-time-code" maxlength>nonenone0 B
pagination0<nav> + links + aria-current="page"nonenone0 B
popover0[popover] + popovertarget or command/commandfornonenone0 B
progress0<progress value max>nonenone0 B
radio-group0<fieldset> + <input type="radio" name="…">nonenone0 B
rating0<fieldset> of <input type="radio"> sharing a namenonenone0 B
scroll-area0overflow: auto + tabindex="0" in role="region"nonenone0 B
segmented0<fieldset> of <input type="radio"> sharing a namenonenone0 B
separator0<hr>, role="separator"nonenone0 B
skeleton0aria-hidden placeholders in an aria-busy regionnonenone0 B
slider0<input type="range">nonenone0 B
spinner0role="status" with a text labelnonenone0 B
steps0<ol> + aria-current="step"nonenone0 B
switch0<input type="checkbox" role="switch">nonenone0 B
tag0<span> with an optional remove <button>nonenone0 B
timeline0<ol> of <li> events with <time datetime>nonenone0 B
calendar1role=grid of day buttons in a <table>click, keydown, pointerover, pointerout@svelte-lean/primitives/calendar/register3925 B
context-menu1contextmenu event + [popover] ARIA menucontextmenu@svelte-lean/primitives/context-menu/register1409 B
file-drop1<input type="file"> stretched over a drop areadragenter, dragleave, drop, change@svelte-lean/primitives/file-drop/register1107 B
hover-card1popover="hint" next to its triggerpointerover, pointerout, focusin, focusout, keydown@svelte-lean/primitives/hover-card/register1353 B
listbox1ARIA listbox markup (role=listbox, option)click, keydown@svelte-lean/primitives/listbox/register1838 B
menu1[popover] + ARIA menu (role=menu, menuitem)click, keydown, toggle@svelte-lean/primitives/menu/register1932 B
menubar1role="menubar" of popovertarget menu buttonskeydown, focusin, pointerover@svelte-lean/primitives/menubar/register1457 B
number-field1<input type="number">, stepUp()/stepDown()click@svelte-lean/primitives/number-field/register1082 B
range-slider1two <input type="range">input@svelte-lean/primitives/range-slider/register1291 B
tabs1ARIA tabs markup (role=tablist, tab, tabpanel)click, keydown@svelte-lean/primitives/tabs/register1521 B
toggle1<button type="button" aria-pressed>click@svelte-lean/primitives/toggle/register888 B
toggle-group1<button aria-pressed> items in role="group"click, keydown@svelte-lean/primitives/toggle-group/register1569 B
toolbar1role="toolbar"keydown, focusin@svelte-lean/primitives/toolbar/register1494 B
tooltip1popover="hint" + role="tooltip" + aria-describedbypointerover, pointerout, focusin, focusout, keydown@svelte-lean/primitives/tooltip/register1297 B
tree1role="tree", treeitem, group, aria-expandedclick, keydown@svelte-lean/primitives/tree/register1919 B
combobox2<input role="combobox"> + [popover] + ARIA listbox (role=listbox, option)click, keydown, input, pointerdown, pointerover, focusout, toggle@svelte-lean/primitives/combobox/register2564 B
date-picker2<input type="date"> + role=combobox text input + [popover] + calendarclick, keydown, input, change, pointerdown, focusout, toggle@svelte-lean/primitives/date-picker/register4912 B
select2<select> + role=combobox trigger + [popover] + ARIA listbox (role=listbox, option)click, keydown, pointerdown, pointerover, focusout, change, toggle@svelte-lean/primitives/select/register3362 B
splitter2role="separator" (WAI-ARIA window splitter) + CSS gridkeydown, pointerdown@svelte-lean/primitives/splitter/register1683 B
toast2popover="manual" region + aria-live listclick, pointerover, pointerout, focusin, focusout@svelte-lean/primitives/toast/register1396 B
Svelte Lean Table3<table>nonenone (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.

SubjectTarget (ADR 0003)Enforced budgetMeasuredStatus
Tabs registration including the shared kernelTabs < 1 kB brotli1700 B brotli1521 B brotliabove target
Basic menu registration including the shared kernelbasic Menu ~1–2 kB2100 B brotli1932 B brotliwithin target
Every behavior that exists today, on one page, with one kernelentire common behavior set < 5 kB—2351 B brotliwithin 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.

  1. Does the browser already own it (checked, disabled, open, focus, validity, popover open)? Then the browser state is the state.
  2. Is there a meaningful DOM or ARIA representation (aria-selected, tabindex, hidden)? Then the DOM is the source of truth. This is Tier 1.
  3. 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's AbortController, and deletes it when focus leaves the root.
  4. 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.

Tabs · Tier 1
Account settings.
<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>
Arrow keys, Home and End move between triggers; the selected state is written to aria-selected, tabindex and hidden. Nothing was created for this root until you interacted with it.

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.

Combobox · Tier 2
  • Ankara
  • Berlin
  • Lisbon
<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>
Click the field, type or press ArrowDown to open; the controller for this root is created at that moment and released when focus leaves. Before that, the root costs nothing beyond its markup.

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