Skip to main content

@happyvertical/smrt-ui

The domain-agnostic Svelte 5 component foundation for s-m-r-t. Components use the shared --smrt-* design tokens, render without a s-m-r-t Provider, and can be used by any package or application without pulling domain dependencies into the UI layer.

pnpm add @happyvertical/smrt-ui
<script lang="ts">
import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
import { Button } from '@happyvertical/smrt-ui/ui';
import { Form, FormGroup, Input, Switch } from '@happyvertical/smrt-ui/forms';
</script>

<ThemeProvider preset="material" colorScheme="system">
<Form formId="profile">
<FormGroup label="Display name"><Input name="displayName" /></FormGroup>
<Switch name="updates" label="Product updates" />
<Button type="submit">Save</Button>
</Form>
</ThemeProvider>

Foundation catalog

AreaComponents
FieldsForm, Field/FormGroup, Fieldset, InputGroup, ErrorSummary
Text and structured inputInput, Textarea, Select, Combobox, Listbox, MultiSelect, TagsInput
ChoicesCheckbox, RadioGroup/Radio, Switch, Toggle, ToggleButton, SegmentedControl
Values and filesSlider, RangeSlider, DatePicker, TimePicker, FilePicker
Actions and displayButton, Dropdown/Menu, Badge, Chip, Avatar, Card, Skeleton, Tooltip, Tree
Disclosure and overlaysPopover, Disclosure, Accordion/AccordionItem, Modal, Drawer/Sheet, ConfirmDialog
FeedbackAlert, ToastViewport, Progress, Meter, Spinner, LoadingOverlay
CollectionsCollectionToolbar, CollectionList/ContentList, DataTable, Pagination
Layout and navigationContainer, Grid, Header, Footer, PageHeader, EmptyState, Tabs, FilterChips

Use the focused subpaths (/forms, /ui, /feedback, /data, /data-surface, /layout, /themes) to keep imports explicit. The Svelte-free /data-surface entry exposes the registry contracts and shared protocol limits for server adapters. The package root remains a compatibility barrel.

Currency display

CurrencyDisplay accepts ISO 4217 codes as a public string prop so persisted Commerce currency fields can be passed directly. Codes are trimmed and uppercased before Intl.NumberFormat formatting; the default remains CAD. Malformed or unsupported codes render an accessible inline error instead of throwing and interrupting a surrounding collection render. With the default historical unit="cents" setting, amounts are interpreted as the selected currency's ISO minor units (for example, 0 digits for JPY and 3 for BHD). Minor-unit amounts must be finite safe integers; fractional or unsafe numeric values render an accessible inline error instead of being rounded. unit="dollars" means the value is already in major units. ISO fund, metal, test, and no-currency codes whose minor unit is N.A. require unit="dollars"; the default minor-unit mode renders an accessible inline error for those codes. Major-unit values for these codes use a stable two-digit display policy across server and browser runtimes. CAD and USD retain their symbol display; all other codes render their ISO code so SSR output does not depend on runtime-specific symbol data.

<script lang="ts">
import { CurrencyDisplay } from '@happyvertical/smrt-ui';
let invoiceCurrency: string = 'eur';
</script>

<CurrencyDisplay amount={12345} currency={invoiceCurrency} />

Component standard

Foundation components follow one contract:

  • Native HTML semantics first, with labelled controls, keyboard interaction, focus-visible states, disabled/read-only handling, and reduced-motion rules.
  • Svelte 5 bindable state plus explicit change callbacks for controlled use.
  • SSR-safe IDs from $props.id() and stable name-based interaction identity.
  • Styling only through semantic --smrt-* tokens; no component owns a theme.
  • Loading, empty, invalid, indeterminate, and disabled states are visible and announced where applicable.
  • Provider-free implementation and no domain imports.
  • Focused interaction tests and axe checks for composed controls.

Application-specific editors, maps, charts, media workbenches, and domain records remain composites built from this foundation rather than generic base components.

Agent-addressable forms

Form can expose its controls to a chat, voice, tutorial, or test adapter without coupling controls to a transport:

<script lang="ts">
import {
Form,
FormGroup,
Input,
createControlInteractionRegistry,
} from '@happyvertical/smrt-ui/forms';

const registry = createControlInteractionRegistry();

async function proposeName() {
await registry.execute(
{
action: 'stage',
identity: { formId: 'profile', controlId: 'displayName' },
value: 'Ada Lovelace',
},
{ source: 'agent' },
);
}
</script>

<Form formId="profile" interactionRegistry={registry}>
<FormGroup label="Display name">
<Input name="displayName" />
</FormGroup>
</Form>

Controls publish serializable metadata, constraints, options, sensitivity, and capabilities. Adapters can focus, reveal, highlight, explain, validate, and stage reviewable proposals. Agents cannot apply, discard, clear, or undo; those value-changing actions require a trusted local gesture handled by the framework review surface. Secret/read-only controls reject agent mutations. Staging remains separate so proposals never change user state before review. Custom local review controls can call executeLocalControlCommand or executeLocalControlBatch synchronously from their DOM handlers; the registry requires the event to still be actively dispatching, snapshots the complete command, and consumes the gesture before authorizing a value-changing command. Retaining an event for later use is rejected even when it remains trusted. Serialized or programmatic source: 'user', confirmed: true input is never confirmation. Sensitive and secret values, validation details, failures, and events remain redacted from every public surface. The shared review surface marks its complete edited Apply value with reviewedValueIsCanonical: true. That marker carries no authority: the registry honors it only for a current staged entry after validating the exact command under an actively dispatching local gesture. Controls with non-idempotent proposal preparation can implement prepareReviewedValue(value) to validate or canonicalize the complete displayed value without re-applying proposal-relative behavior. Controls without that hook route marked edits through their ordinary prepareValue, so a generic marker never bypasses custom normalization or rejection. An unchanged value exactly equal to the stored staged canonical value uses that trusted stored value directly. Registries expose optional refresh(formId) notification for hosts whose live metadata or runtime-state getters change without a registration event; it updates subscribers without discarding an internal staged proposal. executeBatch is an additive optional registry method; Forms fall back to ordered execute calls for older injected registries. Factory-created registries retain the framework's private, one-shot gesture proof, while an older custom registry remains responsible for its pre-existing execution policy and accepts review actions only from a trusted browser event. Custom controls whose clear operation is intentionally idempotent should return true from clear() to affirm that the unchanged cleared value was accepted. Async custom setters and clear handlers are rolled back when they reject. A control that permits direct edits while an async mutation is pending can expose getUserEditSnapshot() and update its revision and value only for direct user edits so rollback restores newer human input even if the handler mutates again before rejecting. A fallible async setter should also expose restoreValue() as an infallible state restoration path that does not repeat the external workflow. Async policy, validation, setter, clear, and restoration hooks receive an optional final ControlExtensionContext. Hooks that need to issue another control command should use extension.execute(); it rejects a mutation of the same control immediately, while commands from independent callers remain in the normal ordered queue regardless of how long the hook takes. Existing hooks that omit the additional argument remain compatible. Setters retain their exact legacy setValue(value) invocation; a setter that needs this context implements the additive setValueWithContext(value, extension) hook instead. A hook must not await a same-control mutation through a captured registry reference: that call is indistinguishable from an independent caller in browser runtimes and, like any hook that never settles, can hold the ordered queue indefinitely.

DataTable controller

DataTable can share one headless DataTableController between rendered controls and a programmatic adapter. Search, declarative filters, ordered multi-column sorting, pagination, columns, selection, and expansion all become plain-data commands; a header click and controller.dispatch() take the same transition path.

<script lang="ts">
import {
createDataTableController,
DataTable,
type DataTableColumn,
} from '@happyvertical/smrt-ui/data';

const controller = createDataTableController({
columnIds: ['name', 'status'],
initialState: {
pageSize: 25,
sorting: [{ columnId: 'name', direction: 'asc' }],
},
});

controller.dispatch({
type: 'setFilters',
filters: [{ columnId: 'status', operator: 'equals', value: 'active' }],
});
</script>

<DataTable {controller} data={rows} {columns} rowKey="id" sortable selectable />

controller.snapshot() returns the canonical JSON-safe version-3 { version, modes, state } envelope. hydrateDataTableSnapshot() accepts versions 1, 2, and 3 and normalizes them to version 3. The envelope contains no rows, callbacks, snippets, storage handles, tenant/principal data, query objects, or authority. URL and saved-view adapters remain application-owned: persist the snapshot (normally excluding selection and expansion IDs), validate it with hydrateDataTableSnapshot, and feed the state into a new controller or replaceState. smrt-ui does not read or write the URL, browser storage, or a database.

Controlled and migration use

Pass state plus onStateChange for controlled state. A controlled controller emits a candidate and waits for the host to call replaceState; an uncontrolled controller owns the state initialized by initialState.

The existing Svelte bindables remain supported during migration:

Existing propController state
bind:sortfirst entry of ordered sorting (single-sort compatibility)
bind:page, pageSizepage, pageSize
bind:selected, bind:expandedlegacy explicit selectedRowIds, canonical selection and expandedRowIds
visibleColumnIdscolumnVisibility intersected with static column.hidden
manualSorting, manualPaginationsorting/pagination entries in modes
filterFnlocal-only legacy predicate; never serialized

An explicit controller takes precedence over state and legacy bindables. Without one, the component creates an internal controller and maps the legacy props. Multi-column sorting and persisted layouts use the controller state; the legacy SortState remains intentionally single-column.

Public surface and supported combinations

DataTable supports the following contracts. These are intentionally composed through the controller rather than through a separate report or remote-table component.

NeedPublic APIImportant constraint
Stable row interactionrowKey, selectable, expanded, onRowClick, agentAddressablerowKey is mandatory whenever a row has durable or remote identity.
Declarative view statecontroller, state, initialState, onStateChangeA supplied controller wins over controlled state and legacy bindables.
Local or remote transformationsmodes, manualSorting, manualPagination, filterFn, totalRowsA manual stage never runs locally; never mix a local transform with an already transformed remote result.
Query lifecycleloading, refreshing, stale, partialResults, error, onRetryThe caller owns request cancellation and revision checks; the table only presents the supplied result state.
Report layoutcolumn headerPath, resizable, role, responsive; structuralRows; controller widths/pinningGroup structure follows final visible leaf columns. Structural rows are never selectable or virtualized.
Narrow screensvisibleColumnIds and responsive column metadataThe table preserves its semantic columns behind a named, keyboard-scrollable horizontal overflow region; it does not silently collapse content.
Continuous browsingvirtualizationRequires rowKey and a fixed-height body. Expanded rows deliberately use the normal semantic body.

The interactive workbench's Data Table entry contains a release conformance fixture for each row in this table: local interaction, manual query lifecycle, responsive overflow, report layout, and virtualization.

Row identity and selection

rowKey is required for selectable, expandable, manual/server, and agentAddressable tables. Its values must be unique non-empty strings or finite numbers. This fails closed before a renderer can reuse the wrong row after a sort, refresh, or server-page change. The historical source-index fallback exists only for local presentational tables with no durable row state.

The controller stores a selection union alongside the deprecated selectedRowIds shorthand:

ScopeStored valueLifecycle
pageIDs from the current rendered pageCleared when page, page size, search, filters, or sorting changes.
explicitExplicit stable IDs across pagesPersists across page and query navigation until changed by the caller.
allMatchingqueryFingerprint, queryRevision, and expectedCount onlyNever stores loaded IDs; query-shape changes clear it.

The built-in header checkbox explicitly means Select all rows on this page. For query-wide selection, dispatch selectAllMatching with the caller-owned query fingerprint, revision, and expected count. A destructive domain action must call assertDataTableSelectionCurrent(selection, currentQuery) immediately before applying it; a mismatched fingerprint or revision throws rather than acting on stale results.

index passed to row callbacks, cells, expansion snippets, and rowClass is the zero-based display index on the currently rendered page. The source index is the zero-based position in the supplied data array and is used only by the non-durable fallback. It must never be saved, sent to an agent, or used as a remote identity.

Transformation ownership and page rules

modes makes each stage explicit. A manual stage renders caller-supplied results and bypasses that local stage, so rows are never double-filtered, double-sorted, or double-paged.

FilteringSortingPaginationRenderer behavior
locallocallocalfilter → ordered multi-sort → slice
manuallocallocalsort and slice supplied rows
localmanuallocalfilter and slice supplied rows
locallocalmanualfilter and sort supplied page; never slice it
manualmanualmanualrender supplied rows unchanged

Every combination follows the same rule per column in the table: each local stage runs once and each manual stage runs zero times. For manual pagination, totalRows supplies the total; when it is unknown the component does not guess the last page or render misleading pagination controls. A supplied totalRows must be a non-negative integer and is rejected unless pagination mode is manual.

Changing search, filters, sorting, or page size resets the page to 1 only when the value changes. Data or total changes clamp an out-of-range page but do not otherwise reset it; empty known totals normalize to page 1. Column layout, selection, and expansion never change the page.

Manual query, retry, and race contract

When any stage is manual, the host owns the request and result lifecycle. On each query-shape change, derive a stable queryFingerprint from every server-owned input (search, filters, sort rules, page, and page size) and a monotonically increasing queryRevision; start the request, retain the currently displayed rows with refreshing/stale as appropriate, and only commit a response when both values still match. A late response is discarded by the host, not merged by DataTable.

const queryFingerprint = JSON.stringify({ search, filters, sorting, page, pageSize });
const query = { queryFingerprint, queryRevision: String(revision) };
const result = await loadRows(query);

if (query.queryRevision === String(revision) && query.queryFingerprint === currentQueryFingerprint()) {
rows = result.rows;
totalRows = result.totalRows;
}

Set error without clearing a usable page, and make onRetry create a new revision. For query-wide actions, dispatch selectAllMatching with the same fingerprint/revision and call assertDataTableSelectionCurrent directly before the destructive request. This gives ContentList, reporting, admin, and agent surfaces the same stale-result and selection guardrail.

Saved layout and report guidance

Use headerPath on every leaf that belongs to a grouped heading; matching IDs at a given depth form a column group after visibility and restored column order are applied. Keep report totals in structuralRows or footer, not in the data array. Persist controller.snapshot() only after removing tenant-specific selection and expansion IDs, then hydrate it before creating the next controller. The version-3 snapshot includes columnOrder, columnVisibility, columnWidths, and columnPinning, so a report can safely restore layout without persisting row data or query authority.

Scale boundaries and virtualization

DATA_TABLE_SCALE_THRESHOLDS publishes the measured operating boundaries used by the reproducible DataTable benchmark:

WorkBoundaryUse after the boundary
Ordinary local rendering250 rows / 5,000 cellsPage or virtualize the body.
Local filtering and sorting1,000 rows / 20,000 cellsMove the transform to the caller or server.
Manual/server paging100 supplied rows per pageKeep totalRows server-owned and bounded.

Run pnpm --filter @happyvertical/smrt-ui bench:data-table to measure the 250-row local render, 1,000-row client-transform, and 100-row manual-paging fixtures. The fixture data has deterministic rowKey values so a browser or renderer comparison does not depend on array-arrival identity.

virtualization is opt-in and requires rowKey. It virtualizes only a fixed-height data body; table headers (including grouped headers) and the footer summary remain normal semantic table sections and do not count toward the window. The virtual scroll region keeps captions and headers sticky, is keyboard-scrollable, and reports the full row count plus each rendered row's logical row index. Supplying expandedContent makes data-row height variable, so the component deliberately falls back to the full semantic body and does not emit virtual scroll callbacks. Use controlled scrollTop/ onScrollTopChange for scroll restoration, and pair focusedRowId with onFocusedRowIdChange to restore DOM focus to a stable row after a data refresh. A measured footer extends the virtual scroll range, so keyboard End and a controlled scroll position can still reveal the summary. Selection and expansion continue to be controller state keyed by rowKey, never by a rendered window index. With manual pagination, totalRows and the current page set that full row count and each rendered row's global index.

Mounted data-surface registry

createDataSurfaceRegistry() is the transport-neutral sibling of the form interaction registry. A mounted table, list, or report supplies serializable discovery metadata, a revisioned view snapshot, and a small handler for its declared visible controls. The registry rejects duplicate identities, validates JSON-safe data, requires an expectedRevision, records monotonic event sequences, serializes commands per mounted identity, and returns a cached acknowledgement when the same commandId is replayed. The replay cache retains only the 100 most recently used command IDs per mounted surface.

Visible-command and preview/apply-action envelopes are capped at 100,000 UTF-8 bytes (DATA_SURFACE_MAX_REQUEST_BYTES). JSON values reject prototype keys and have fixed nesting and container-size bounds, so every browser-facing request remains safe to normalize before host policy evaluates it.

import { createDataSurfaceRegistry } from '@happyvertical/smrt-ui/data';

const registry = createDataSurfaceRegistry();
let revision = 0;
let search = '';

registry.register({
descriptor: {
version: 1,
identity: { surfaceId: 'content-library', kind: 'table' },
schemaVersion: 1,
label: 'Content library',
rowKey: 'id',
columns: [
{ id: 'id', label: 'ID', capabilities: ['read', 'project'] },
{ id: 'title', label: 'Title', capabilities: ['read', 'search'] },
],
query: { modes: ['rows', 'count'], projectableColumnIds: ['id', 'title'] },
controls: [{ id: 'set-search', label: 'Search' }],
actions: [],
limits: { maxQueryRows: 100, maxQueryBytes: 100_000, maxSelectionSize: 100 },
},
getSnapshot: () => ({ revision, state: { search } }),
execute: (command) => {
if (command.controlId === 'set-search') {
search = String((command.payload as { search?: string }).search ?? '');
revision += 1;
}
},
});

inspect() and command results are deterministic { version, descriptor, revision, state, selection } envelopes; neither includes a timestamp, rows, functions, authority fields, tenant/principal data, SQL, or a transport handle. The registry rejects those boundary keys from both default and redacted snapshot state. An optional registration redact() hook can remove sensitive view state before it leaves the mounted host, but cannot alter the identity or revision.

The registry validates bounded projection/count/facet query envelopes (including the UTF-8 byte length of their normalized JSON form) and preview/apply action envelopes, but it does not execute either. Canonical query semantics belong to the query protocol, browser command acknowledgement belongs to a transport adapter, and authentication, tenancy, confirmation-token verification, and durable actions remain server-side. URL state and saved views also remain application-owned persistence adapters.

DataTable and CollectionToolbar integration

Registration is opt-in. Pass dataSurface with an explicit descriptor and a registry; existing DataTable and CollectionToolbar consumers do not register or change behavior. Registration follows reactive dataSurface and controller prop replacement, so registry commands never retain a prior mounted instance. A DataTable descriptor must only name effective, visible columns, except for its stable rowKey, which may remain non-rendered. Mounted tables always require that rowKey to be an explicit string field; the index fallback and functional key callbacks are never addressable across pages or refreshes.

<script lang="ts">
import { DataTable, createDataSurfaceRegistry } from '@happyvertical/smrt-ui/data';

const registry = createDataSurfaceRegistry();
const dataSurface = {
registry,
descriptor: {
// descriptor omitted: give this mounted instance a stable identity,
// policy-visible columns, controls, query limits, and action descriptors
},
};
</script>

<DataTable {dataSurface} data={rows} {columns} rowKey="id" />

Declared controller controls include search, filters, multi-sort, page/page size, column layout, selection, expansion, reset, focus/reveal/highlight, and optional refresh/retry callbacks. The component maps controller controls to the same DataTableController.dispatch() path used by buttons and checkboxes. A controlled table supplies applyControlledState(candidate, command); the registry acknowledges only after that callback settles the candidate state.

CollectionToolbar accepts the same opt-in registration and an optional controller. Its set-search control shares that table controller; set-view remains toolbar-local. Descriptors may advertise row/bulk action contracts, but smrt-ui does not execute durable actions—the later authenticated action adapter owns preview, confirmation, authorization, and persistence.

Toolbar snapshots also advance their revision when the host updates exposed uncontrolled search or view props, so a command based on an earlier view is rejected as stale instead of overwriting host state.

DataSurface columns may also carry domain-neutral policy metadata (fieldName, visibility, order, role, responsivePriority, readable, and per-column operator allowlists). Domain packages such as @happyvertical/smrt-fields apply their effective policy above this package; smrt-ui validates and serializes the metadata without owning field authorization or policy rules.

Themes

@happyvertical/smrt-ui/themes is the canonical theme API and includes the Material, Glass, Studio, s-m-r-t, and HappyVertical ("Day Shift") presets. The old /theme path forwards to the same provider and context for compatibility.

Day Shift is the HappyVertical brand identity: a calm instrument panel with an enamel ground, faceplate panels on hairline bezels, and a single amber accent that also serves as the focus ring. Both its light and dark schemes are hand-authored, and every text pairing clears WCAG AA.

<script>
import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
import '@happyvertical/smrt-ui/themes/styles/all.css';
import '@happyvertical/smrt-ui/themes/styles/fonts.css';
</script>

<ThemeProvider preset="smrt" colorScheme="dark">
{@render children()}
</ThemeProvider>

Run the shared playground to inspect the full catalog under every preset and light/dark scheme.

Development

pnpm check
pnpm test
pnpm build
pnpm verify:pack