sveltelean
Versionv0.2.0 GitHub

Three questions

  1. What is installed in node_modules? One package per product.
  2. What source is copied into the application? Nothing by default; stylesheets when the application chooses to own them.
  3. What JavaScript and CSS is shipped to production? The registration modules of the behaviors the markup uses, the stylesheets that were imported, and nothing else.

An application can install @svelte-lean/primitives, use seven of its primitives, and ship no Svelte Lean behavior JavaScript at all: the native-only production fixture emits a Svelte Lean chunk of 0 B.

Consumption modes

Mode A: native, copy only

Plain platform markup that needs no package at all. The best implementation of a native primitive is zero runtime modules, zero registrations, zero components and zero imports, and the documentation shows that path first rather than hiding it behind a wrapper.

settings.svelte
<button type="button" commandfor="settings" command="show-modal">Settings</button>

<dialog id="settings" data-slean="dialog" aria-labelledby="settings-title">
	<header data-slean-part="header">
		<h2 id="settings-title" data-slean-part="title">Settings</h2>
	</header>
	<div data-slean-part="body">…</div>
	<footer data-slean-part="footer">
		<button type="button" commandfor="settings" command="close">Close</button>
	</footer>
</dialog>

Mode B: package-managed behavior

For a behavior the browser lacks, the markup carries a static marker and @svelte-lean/vite injects only the matching registration module. A manual import is the documented and tested escape hatch and behaves identically.

vite.config.ts
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteLean } from '@svelte-lean/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [svelteLean(), sveltekit()]
});
+layout.svelte
// src/routes/+layout.svelte (or any module that runs once on the client)
import '@svelte-lean/primitives/tabs/register';

Mode C: owned styles

The stylesheets are plain files the application may copy and edit. The behavior engine is never copied: it is accessibility-sensitive, and a focus bug fixed upstream must reach every project through npm. The CSS is yours to own; the behavior stays a maintained dependency. A slean add command for the copy is future work.

app.css
/* Mode C without any package CSS: target the protocol and the platform state directly */
[data-slean='tabs'] [role='tab'][aria-selected='true'] {
	border-bottom: 2px solid currentColor;
}

[data-slean='dialog']::backdrop {
	background: rgb(0 0 0 / 0.4);
}

Package granularity

One product package, never one npm package per primitive. Behaviors are subpath exports: the plain entry holds types, the contract and pure helpers; the register entry is the one side effect. The root entry exports metadata and contracts and never registers a behavior.

TypeScript
import type { TabsChangeDetail } from '@svelte-lean/primitives/tabs'; // types, pure helpers
import '@svelte-lean/primitives/tabs/register'; // the side effect: registers tabs once
import { BEHAVIORS } from '@svelte-lean/primitives'; // metadata and contracts; registers nothing

The exports map of @svelte-lean/primitives, read from its package.json at build time: ., ./button, ./dialog, ./popover, ./disclosure, ./checkbox, ./switch, ./radio-group, ./tabs, ./tabs/register, ./menu, ./menu/register, ./listbox, ./listbox/register, ./combobox, ./combobox/register, ./date-field, ./calendar, ./calendar/register, ./date-picker, ./date-picker/register, ./input, ./field, ./select, ./select/register, ./segmented, ./rating, ./slider, ./otp-field, ./file-field, ./color-field, ./autocomplete, ./alert-dialog, ./drawer, ./accordion, ./card, ./separator, ./avatar, ./badge, ./tag, ./kbd, ./carousel, ./alert, ./progress, ./meter, ./spinner, ./skeleton, ./breadcrumb, ./pagination, ./steps, ./toggle, ./toggle/register, ./toggle-group, ./toggle-group/register, ./toolbar, ./toolbar/register, ./range-slider, ./range-slider/register, ./number-field, ./number-field/register, ./tree, ./tree/register, ./tooltip, ./tooltip/register, ./toast, ./toast/register, ./button-group, ./scroll-area, ./descriptions, ./timeline, ./empty, ./splitter, ./splitter/register, ./context-menu, ./context-menu/register, ./file-drop, ./file-drop/register, ./hover-card, ./hover-card/register, ./menubar, ./menubar/register, ./svelte-elements.

The table follows the same rule with larger units: the root entry is <DataTable /> and createTable(); grouping, pivot, editing, clipboard, export, server data sources and persisted state are entry points that never load unless imported. Its exports: ., ./core, ./grouping, ./pivot, ./export, ./editing, ./state, ./server, ./clipboard, ./style.css.

TypeScript
import { DataTable, createTable, createRowModel } from '@svelte-lean/table';
import { groupBy, summarize } from '@svelte-lean/table/grouping';
import { pivot } from '@svelte-lean/table/pivot';
import { createHistory, applyEdit } from '@svelte-lean/table/editing';
import { copyRange, pasteRange } from '@svelte-lean/table/clipboard';
import { toCsv, downloadText } from '@svelte-lean/table/export';
import { createDataSource } from '@svelte-lean/table/server';
import { serializeState, restoreState } from '@svelte-lean/table/state';
import '@svelte-lean/table/style.css';

sideEffects lists only the register modules and the CSS, so a bundler may drop everything else that is unused and must keep a registration that was imported.

package.json
// packages/primitives/package.json: only the register modules are side effects
"sideEffects": ["./dist/*/register.js"]

// packages/table/package.json
"sideEffects": ["**/*.css"]

Styles distribution

Granular entry points are primary: @svelte-lean/styles/tabs.css and one file per primitive, with the tokens in a separate file that any theme can override. The monolithic @svelte-lean/styles entry exists for convenience only. The package ships CSS and no JavaScript; every export is a CSS file, which its tests assert.

Shipping invariants

Five release-blocking invariants, each with the place it is tested against real production output. One has nothing to test yet and says so.

InvariantWhere it is testedStatus in this build
A. Native-only pages ship no behavior runtimefixtures/native-only; packages/primitives/scripts/size.mjs (native-only entry)31 assertions pass
B. Unused behaviors are absent from production outputfixtures/tabs-only, tabs-and-menu, manual-registration, table-basictabs-only: 43 assertions pass; tabs-and-menu: 43 assertions pass; manual-registration: 42 assertions pass; table-basic: 46 assertions pass
C. Instance count does not multiply shared listenersapps/playground/tests/primitives/listeners.spec.ts (1 vs 1000 roots); packages/primitives/tests/tabs.test.tsexact equality, release gate
D. Tier 2 state is lazypackages/primitives/tests/combobox.test.ts ("lazy controller": untouched roots hold no state, 1000 roots, session count); apps/playground/tests/primitives/lazy.spec.ts (100 roots in Chrome)exact assertion, release gate
E. Compatibility code is opt-in or target-selectedNo test yet: no compatibility module exists. The first one adds a fixture proving the native path excludes it.nothing to test
FixtureConsumesSvelte Lean chunk (brotli)
native-onlybutton, dialog, popover markup; the stylesheet; the plugin0 B
tabs-onlytabs markup; the plugin1521 B
manual-registrationtabs markup; the register module imported by hand; no plugin1521 B
tabs-and-menutabs and menu markup; one kernel2351 B
table-basic<DataTable /> and its stylesheet; six optional modules asserted absent16829 B

Each fixture is a Vite application built in production mode in its own process, with every @svelte-lean/* module isolated into one chunk so its bytes are never mixed with Svelte's. The fixtures resolve the workspace packages through node_modules links. Packed tarballs are tested separately with publint, a check of every exports target, a Node import of every subpath and a server render of the table (pack-smoke.mjs); the shipping invariants themselves are asserted on the workspace-linked fixture builds (fixtures/README.md).

Manifest and report

Discovery is acceptable magic because it is inspectable: the source markup is visible, the production build writes a manifest of every behavior it found and the module it injected, the build report prints the same summary, and the manual import exists. The manifest of this site's own build is on Build-time discovery.

vite.config.ts
svelteLean({
	// A behavior of your own, or a replacement module for a built-in one.
	behaviors: {
		carousel: '$lib/behaviors/carousel/register',
		tabs: '@acme/primitives/tabs/register'
	}
});

Escape hatches

Every abstraction has a lower-level path, and each is documented and tested.

AbstractionEscape hatchDocumented on
Automatic behavior discovery by the Vite pluginA manual import '@svelte-lean/primitives/tabs/register'; the same module, the same bytes/docs/installation#manual-registration
The built-in behavior table of the pluginThe behaviors option: your own registration module for a name, or false for no runtime/docs/architecture/build-time-discovery#options
The document runtimecreateRuntime({ root }) for a shadow root or an iframe; register the same definitions on it/docs/architecture/build-time-discovery#the-shared-router
<DataTable />createTable(): the engine it renders, for a renderer of your own; @svelte-lean/table/core for the pure row model/table/api
Uncontrolled table statebind:state or onStateChange: one plain object the application owns, serializes and restores/table/state-persistence
The package stylesheetYour own CSS against the protocol attributes and platform state, or a copied file you own/styling/owned-styles
Native positioning of popoversYour own anchor-name and position-anchor rules; no compatibility positioning module exists/primitives/popover
The declarative tab selectionactivateTab(root, value) and the cancelable slean:change event/docs/architecture/dom-protocol#events
TypeScript
import { createTable } from '@svelte-lean/table';

// Below <DataTable />: the engine it renders, for a renderer of your own.
const table = createTable({
	data: () => rows,
	columns,
	getRowId: (row) => row.id,
	state: () => tableState,
	onStateChange: (next) => (tableState = next)
});

table.toggleSort('revenue');
table.model.rows;

Source