SniceSnice

Elements

Defining custom elements, choosing a render root, and extending existing elements.

TopicDocumented in
Public input, state, attribute conversionProperties
Connection, readiness, teardown, @watchLifecycle
@query / @queryAllQueries
@styles, host styling, iconsStyling
@render, templates, control flowDeclarative Rendering
Template events, @on, @dispatchEvents

Basic Usage

Creating an Element

import { element, render, html } from 'snice'; @element('my-button') class MyButton extends HTMLElement { @render() renderContent() { return html`<button>Click me</button>`; } }

For convention-based authoring, extend the optional SniceElement base and implement render() directly:

import { SniceElement, css, element, html, state } from 'snice'; @element('my-counter') class MyCounter extends SniceElement { static styles = css`:host { display: inline-block; }`; @state() count = 0; render() { return html`<button @click=${() => this.count++}>${this.count}</button>`; } }

Plain HTMLElement subclasses and decorated render/style methods remain fully supported.

Element Decorator Options

The @element decorator accepts:

Render Roots and Shadow DOM

Elements use an open shadow root by default. Open/closed shadow roots and light DOM share the same differential renderer, lifecycle, event binding, styles, and query decorators.

@element('closed-card', { shadow: 'closed' }) class ClosedCard extends HTMLElement { /* ... */ } @element('light-card', { renderRoot: 'light' }) class LightCard extends HTMLElement { /* ... */ } @element('focus-card', { delegatesFocus: true }) class FocusCard extends HTMLElement { /* ... */ }

shadow: false is shorthand for renderRoot: 'light'. Framework-managed queries continue to work with a closed root. A createRenderRoot() override may return the host element or a ShadowRoot for a custom policy.

See Queries for resolving elements inside the render root.

Native autofocus

Use the platform autofocus attribute or property; no focus controller is needed. Snice applies autofocus after the element is ready and the browser has had a frame to paint, which makes it reliable for late-upgraded custom elements and controls rendered inside open or closed shadow roots.

<!-- A built-in Snice control forwards focus to its native control. --> <snice-input autofocus label="Search"></snice-input> @element('search-panel') class SearchPanel extends HTMLElement { @render() template() { return html`<input autofocus type="search">`; } }

For a decorated host without its own effective focus() implementation, host-level autofocus targets the first native focusable control in its render root. Snice does not add tabindex. As with native autofocus, the first candidate wins; focus deliberately established by application code (including an @ready handler) is preserved. Assigning element.autofocus = true after initialization is also supported. The pass also covers a host that first appears in a LATER render (e.g. inside a conditional branch), not only elements present at initial mount.

Under jsdom the autofocus IDL property is not implemented, so element.autofocus = true is a no-op in tests — Snice's pass reads hasAttribute('autofocus'), which makes the attribute form (?autofocus=${...} in templates) the testable one.

Extending Elements

Elements can extend other elements — including Snice's built-in components. The child inherits the parent's properties, watchers, event handlers, lifecycle hooks, and styles, then adds or overrides its own.

Example: Currency Input

Extend snice-input to create an input that prefixes a currency symbol, restricts to numeric entry, and formats the value on blur:

import { element, property, watch, on, render, styles, html, css } from 'snice'; import 'snice/components/input/snice-input'; import { SniceInput } from 'snice/components/input/snice-input'; @element('currency-input') class CurrencyInput extends SniceInput { @property() currency = 'USD'; // Snice input already has: value, label, placeholder, disabled, // size, variant, error-text, helper-text, clearable, prefix-icon, // suffix-icon, and all associated watchers and events. connectedCallback() { super.connectedCallback(); this.updatePrefix(); } @watch('currency') updatePrefix() { const symbols: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', JPY: '¥' }; this.prefixIcon = symbols[this.currency] || this.currency; } @on('input', 'input') restrictNumeric(e: InputEvent) { const input = e.target as HTMLInputElement; input.value = input.value.replace(/[^\d.]/g, ''); this.value = input.value; } @on('blur', 'input') formatValue() { const num = parseFloat(this.value); if (!isNaN(num)) { this.value = num.toFixed(2); } } @styles() currencyStyles() { return css` /* Parent input styles are inherited — add currency-specific tweaks */ :host { --input-text-align: right; } `; } } <currency-input label="Price" currency="EUR" placeholder="0.00"></currency-input>

The currency-input inherits everything from snice-input — label rendering, variants, sizes, validation, focus/blur events, clearable, keyboard handling — without re-implementing any of it. It adds a currency symbol prefix, numeric restriction, and formatting.

What inherits

FeatureBehavior
@propertyChild gets all parent properties. Child can override defaults or type.
@watchBoth parent and child watchers fire.
@onBoth parent and child handlers fire.
@ready, @reconnect, @disposeAll three fire on parent and child.
@dispatchInherited via prototype.
@renderChild replaces parent's render. If child doesn't declare @render, parent's is used.
@stylesConcatenated — parent styles first, child second (child wins via cascade).
formAssociatedInherited.

Each child class needs its own @element('tag-name') with a unique tag name.

Full Example

Elements handle visual behavior — they render the form and emit events. Business logic (API calls, validation) belongs in controllers:

import { element, property, query, dispatch, render, styles, html, css } from 'snice'; @element('registration-form') class RegistrationForm extends HTMLElement { @property({ type: Boolean }) loading = false; @query('form') form?: HTMLFormElement; @dispatch('register-submit') handleSubmit(event: Event) { event.preventDefault(); return Object.fromEntries(new FormData(this.form!)); } @render() renderContent() { return html` <form @submit=${this.handleSubmit}> <div class="field"> <label>Username</label> <input type="text" name="username" required> </div> <div class="field"> <label>Email</label> <input type="email" name="email" required> </div> <button type="submit" ?disabled=${this.loading}> ${this.loading ? 'Registering...' : 'Register'} </button> </form> `; } @styles() formStyles() { return css` :host { display: block; max-width: 400px; } .field { margin-bottom: 1rem; } label { display: block; margin-bottom: 0.25rem; font-weight: bold; } input { width: 100%; padding: 0.5rem; border: 1px solid var(--snice-color-border, #ddd); border-radius: 4px; } button:disabled { opacity: 0.6; cursor: not-allowed; } `; } }

Properties

Public inputs, internal state, attribute conversion, and reflection. Elements themselves are covered in Elements; template binding channels in Binding Channels.

Basic Properties

Properties automatically sync with DOM attributes and trigger re-renders:

import { element, property, render, html } from 'snice'; @element('user-profile') class UserProfile extends HTMLElement { @property() name = 'Anonymous'; @property({ type: Number }) age = 0; @property({ type: Boolean }) verified = false; @render() renderContent() { return html` <div> <h3>${this.name}</h3> <p>Age: ${this.age}</p> ${this.verified ? html`<span>✓ Verified</span>` : ''} </div> `; } }

Usage:

<user-profile name="John Doe" age="30" verified></user-profile>

Property Options

interface PropertyOptions { type?: String | Number | Boolean | Array | Object | Date | BigInt | SimpleArray; attribute?: string | boolean; // Custom attribute name, or false to disable attribute sync reflect?: boolean; // Property → attribute; default true deep?: boolean; // Observe nested object/array/Map/Set writes converter?: PropertyConverter; // Custom converter hasChanged?: (value, oldValue) => boolean; }

Property Behavior

All properties automatically:

Attribute conversion is intentionally one-way at the HTML boundary. A direct JavaScript assignment is already typed and is stored exactly as assigned:

const rows = [{ id: 1 }]; element.rows = rows; element.rows === rows; // true

This preserves dates, union values, services, objects, and collection identity. type and converter.fromAttribute process strings arriving from attributes; converter.toAttribute serializes reflection.

Use reflect: false for input-only attributes, attribute: false for JavaScript-only public properties, and @state() for internal reactive fields:

@property({ type: Number, reflect: false }) page = 1; @property({ attribute: false }) service!: UserService; @state() open = false; @state({ deep: true }) model = { rows: [] as Row[] };

@state() never observes or writes an attribute. deep: true tracks nested plain objects, arrays, Map, and Set using native Proxy and Reflect; class instances and DOM objects remain intact. Deep observation targets modern evergreen browsers and is not available in Internet Explorer.

Controlled form properties

When rendering a controlled native or Snice form control, bind its JavaScript

property. Use live() when an owner render must restore state even though the

bound value equals the last value Snice committed:

import { html, live, state } from 'snice'; @state() name = 'Ada'; html`<snice-input .value=${live(this.name)}></snice-input>`;

live() compares with the current DOM property, but it does not watch the DOM

or schedule a render. See Live property values

for the modal-reseed example and exact timing.

Note: Initial field values (defaults like name = 'Anonymous') are NOT reflected to attributes. Only changes made via the property setter are reflected. Set attribute: false to disable attribute sync entirely.

@element('reflected-props') class ReflectedProps extends HTMLElement { @property() theme = 'light'; @property({ attribute: 'user-id' }) userId = ''; @render() renderContent() { return html`<div class="${this.theme}">User: ${this.userId}</div>`; } }

Boolean Properties:

@property({ type: Boolean }) enabled = false;

Custom Converters

const dateConverter: PropertyConverter = { fromAttribute(value: string | null): Date | null { return value ? new Date(value) : null; }, toAttribute(value: Date | null): string | null { return value ? value.toISOString() : null; } }; @element('date-display') class DateDisplay extends HTMLElement { @property({ converter: dateConverter }) date: Date | null = null; @render() renderContent() { return html`<time>${this.date?.toLocaleDateString() || 'No date'}</time>`; } }

SimpleArray Type

The SimpleArray type enables safe reflection of arrays containing basic types:

import { element, property, SimpleArray, render, html } from 'snice'; @element('tag-list') class TagList extends HTMLElement { @property({ type: SimpleArray }) tags = ['javascript', 'typescript', 'web']; @render() renderContent() { return html` <ul> ${this.tags.map(tag => html`<li>${tag}</li>`)} </ul> `; } }

Usage:

<tag-list tags="react,vue,angular"></tag-list>

Lifecycle

Connection, readiness, teardown, and reacting to change.

Lifecycle Decorators

@ready() - Called after styles are applied and event handlers are set up. The initial render may still be completing in a microtask — use @query (which re-queries each access) to safely access rendered DOM:

import { element, ready, query, render, html } from 'snice'; @element('auto-resize-textarea') class AutoResizeTextarea extends HTMLElement { @query('textarea') textarea?: HTMLTextAreaElement; @ready() adjustHeight() { if (this.textarea) { this.textarea.style.height = `${this.textarea.scrollHeight}px`; } } @render() renderContent() { return html`<textarea @input=${this.adjustHeight}></textarea>`; } }

@reconnect() - Called every time the element is connected AFTER the first connect. @ready only fires once; @dispose fires on every disconnect. The gap between is "what should run on a reconnect?" — for most components nothing extra is needed because framework-managed handlers (@on, @observe, @respond, @context) are re-established automatically. Use @reconnect only when the component wires its own long-lived global subscription in @ready (e.g. document.addEventListener for outside-click) and tears it down in @dispose:

@element('outside-click-listener') class OutsideClick extends HTMLElement { private handler = () => { /* ... */ }; @ready() init() { document.addEventListener('click', this.handler); } @reconnect() onReconnect() { document.addEventListener('click', this.handler); } @dispose() cleanup() { document.removeEventListener('click', this.handler); } }

@dispose() - Called when element is removed from DOM:

@element('animated-element') class AnimatedElement extends HTMLElement { private rafId?: number; @ready() startAnimation() { const animate = () => { // Update animation frame this.rafId = requestAnimationFrame(animate); }; this.rafId = requestAnimationFrame(animate); } @dispose() stopAnimation() { if (this.rafId) { cancelAnimationFrame(this.rafId); } } @render() renderContent() { return html`<canvas width="300" height="200"></canvas>`; } }

@moved() and @adopted()

Fire when the element is moved between documents (adoptedCallback).

@adopted() is an alias of @moved(); both accept the same debounce/throttle

options as the other lifecycle decorators.

@element('portable-widget') class PortableWidget extends HTMLElement { @moved() reattach() { // Re-resolve anything tied to the previous document (styles, observers) } }

Waiting for elements

Awaiting definition or readiness from outside a component — useful in tests and

in code that hands work to an element it did not create:

import { waitForElementDefined, waitForElementReady, waitForAllCustomElements } from 'snice'; await waitForElementDefined('user-card'); // custom element is registered await waitForElementReady(element); // defined, connected, first render done await waitForAllCustomElements(container); // every custom element in a subtree

Each takes an optional warningTimeout (ms) and warns when an element takes

longer than expected rather than hanging silently. Silence those warnings with

setDisableElementReadyWarnings(true).

Inside a component, prefer await el.ready — see Testing.

ready Promise

Every element has a ready promise that resolves when fully initialized:

const el = document.createElement('my-element') as MyElement; document.body.appendChild(el); await (el as any).ready; // Wait for element to be ready

If an @ready() handler throws or returns a rejected promise, ready rejects

with that failure after the handlers finish. Snice also logs the handler name.

Await ready during tests and application setup so initialization failures

cannot look like successfully rendered, half-initialized elements.

Watch Decorator

Use @watch to react to property changes. Handlers receive three arguments: (oldValue, newValue, propertyName).

@element('reactive-component') class ReactiveComponent extends HTMLElement { @property() userName = ''; @property({ type: Number }) score = 0; @watch('userName') onUserNameChange(oldVal: string, newVal: string, prop: string) { console.log(`${prop} changed from ${oldVal} to ${newVal}`); } @watch('score') onScoreChange(oldVal: number, newVal: number) { if (newVal > 100) { console.log('High score achieved!'); } } // Wildcard watcher — fires on any @property change @watch('*') onAnyChange(oldVal: any, newVal: any, prop: string) { console.log(`${prop}: ${oldVal} → ${newVal}`); } @render() renderContent() { return html` <div> <h1>${this.userName}</h1> <p>Score: ${this.score}</p> </div> `; } }

Initial values. By default a watcher fires once during initialization with the element's starting value — from markup (<reactive-component user-name="Ada">) or the field default — with oldValue undefined, then again on every later change:

@watch('userName') onUserNameChange(oldVal: string | undefined, newVal: string) { // Init: (undefined, 'Ada') // Change: ('Ada', 'Grace') }

Pass { immediate: false } as the last argument for a change-only watcher — one that must not run on mount, such as a watcher that dispatches an event:

@watch('value', { immediate: false }) onValueChange(oldVal: string, newVal: string) { this.dispatchChange(); // only on real changes, never on mount }

The options object always comes after the property names, so it works with multiple watched properties too: @watch('width', 'height', { immediate: false }).

@context() Decorator

Receive router context updates on pages, descendant elements, and attached

controllers. The decorated method is called whenever the router context

changes (navigation, app context update, etc.). A controller's managed

decorators activate after attach(), so context-dependent startup belongs in

this handler:

import { element, context, property, render, html } from 'snice'; import type { Context, Placard } from 'snice'; @element('nav-bar') class NavBar extends HTMLElement { @property({ type: Array }) placards: Placard[] = []; @property() currentRoute = ''; @context() onContextUpdate(ctx: Context) { this.placards = ctx.navigation.placards; this.currentRoute = ctx.navigation.route; } @render() renderContent() { return html` <nav> ${this.placards .filter(p => p.show !== false) .map(p => html` <a href="${p.href || ''}" class="${this.currentRoute === p.name ? 'active' : ''}"> ${p.icon} ${p.title} </a> `)} </nav> `; } }

Context Options:

@context({ debounce: 300 }) // Wait 300ms after last change @context({ throttle: 500 }) // At most once per 500ms @context({ once: true }) // Only called once, then auto-unregisters

Order for a routed page: @context() fires first (before the first

render), then the first render commits, then @ready() runs with the rendered

DOM already queryable. A page can therefore normalize or correct an incoming

route parameter in @context() before the first render commits a value onto a

child element.

The Context object provides:

Queries

Resolve elements inside a render root with @query and @queryAll instead of reaching for querySelector.

Single Element Query

import { element, query, render, html } from 'snice'; @element('form-component') class FormComponent extends HTMLElement { @query('input[type="text"]') textInput?: HTMLInputElement; @query('button[type="submit"]') submitButton?: HTMLButtonElement; @render() renderContent() { return html` <form> <input type="text" placeholder="Enter text"> <button type="submit">Submit</button> </form> `; } getValue(): string { return this.textInput?.value || ''; } }

Multiple Elements Query

@element('todo-list') class TodoList extends HTMLElement { @queryAll('.todo-item') todoItems?: NodeListOf<HTMLElement>; @queryAll('input[type="checkbox"]') checkboxes?: NodeListOf<HTMLInputElement>; @render() renderContent() { return html` <ul> <li class="todo-item"><input type="checkbox"> Task 1</li> <li class="todo-item"><input type="checkbox"> Task 2</li> </ul> `; } getCompletedCount(): number { if (!this.checkboxes) return 0; return Array.from(this.checkboxes).filter(cb => cb.checked).length; } }

Query Options

Control where queries search using light and shadow options:

@element('query-options') class QueryOptions extends HTMLElement { // Query only in shadow DOM (default) @query('.shadow-only') shadowElement?: HTMLElement; // Query only in light DOM (slotted content) @query('.light-only', { light: true, shadow: false }) lightElement?: HTMLElement; // Query in both light and shadow DOM @query('.anywhere', { light: true, shadow: true }) anyElement?: HTMLElement; @render() renderContent() { return html` <div class="shadow-only">Shadow Content</div> <slot></slot> `; } }

Accessing Shadow DOM Elements

Use @query instead of manual shadowRoot.querySelector:

@element('shadow-demo') class ShadowDemo extends HTMLElement { @query('#content') content?: HTMLElement; @render() renderContent() { return html`<div id="content">Hello</div>`; } updateContent(text: string) { if (this.content) { this.content.textContent = text; } } }

Declarative Rendering

Snice templates are tagged template literals that update only the dynamic parts of the DOM. Template structure is parsed once and DOM nodes are retained across updates.

import { SniceElement, css, element, html, property, state } from 'snice'; @element('user-editor') class UserEditor extends SniceElement { static styles = css`:host { display: block; }`; @property() userId = ''; @state({ deep: true }) form = { name: '', roles: [] as string[] }; render() { return html`<h2>${this.form.name || 'New user'}</h2>`; } }

SniceElement is optional. Plain HTMLElement subclasses continue to support @render() and @styles(). The base class adds a conventional render() method, static styles, kebab-case implicit attribute names, and typed invalidate() / renderNow() methods.

Template values

Node expressions accept text, numbers, templates, iterables, DOM nodes, promises, async iterables, and nothing.

html` <h2>${title}</h2> ${items.map(item => html`<span>${item.label}</span>`)} ${visible ? html`<section>Visible</section>` : nothing} `

Dynamic text is escaped. unsafeHTML(value) is the explicit opt-in for trusted raw HTML. Use svg for an SVG fragment that does not include its own outer <svg>` element.

Escaping prevents markup injection; it does not make an untrusted navigation URL safe. Validate URL sinks with isSafeUrl():

import { isSafeUrl } from 'snice'; if (isSafeUrl(candidateUrl)) { window.location.href = candidateUrl; } isSafeUrl(objectUrl, { allowed: ['blob:'] });

By default, relative references and absolute http:, https:, mailto:, and tel: URLs are accepted. Network-path references must resolve to an allowed protocol. Malformed URLs, raw ASCII control characters, and every other explicit scheme are rejected. Passing allowed replaces the absolute-protocol list but does not disable relative references. snice-button applies this policy automatically to its href property.

Authoring diagnostics

Malformed declarative syntax fails when its TemplateResult is prepared for rendering. When the template belongs to a Snice element, the error identifies the owning host by its authoritative registered tag and, when safely available, its JavaScript class, then includes a nearby static-template excerpt where possible:

snice: render failed for <user-editor> (UserEditor): ... Near "<button ${…}>...".

Minified CDN builds commonly strip constructor names, so a tag-only identity such as <user-editor> is normal and intentional. Identity is recorded only after a successful Snice @element, @layout, or Router page registration (or when that exact constructor is already registered). It belongs to that exact constructor and immediate prototype: an undecorated subclass stays generic, while an instance adopted into another document keeps its original registered identity. This context follows nested templates, keyed/iterable templates, promises, and async iterables, including templates rendered into open or closed shadow roots and light DOM. A template prepared without a component render host keeps the generic authoring error and nearby excerpt; Snice does not invent a component, source filename, or callsite it cannot know at runtime. Contextual errors retain the original error as cause, so its stack remains available for debugging.

Bindings

This section is the quick syntax overview. See Binding Channels for the complete value, cleanup, event, spread, sentinel, and form-control semantics.

html`<input title=${label} data-label="prefix ${label}" .value=${value} ?disabled=${disabled} @input=${handler} >`
SyntaxEffect
${value}Node content
name=${value}Attribute
.name=${value}JavaScript property
?name=${value}Boolean attribute presence
@event=${handler}Event listener

Use property bindings for objects, arrays, functions, element state, and native form values. Property bindings preserve the JavaScript value and its identity.

Class and style toggles

Toggle one class or one CSS property without rebuilding a complete string:

html`<article class="card" class:selected=${this.selected} style:color=${this.color} style:--card-accent=${this.color} ></article>`

Falsy class:name values remove the class. nothing, null, or false removes an individual style:name property. classMap() and styleMap() remain available when an object-to-string mapping is more convenient.

Event modifiers and keyboard filters

Event modifiers compose after the event name with |:

html` <form @submit|prevent=${this.save}></form> <button @click|once|stop=${this.runOnce}>Run</button> <div @click|self=${this.selectContainer}>...</div> <input @keydown.ctrl+s|prevent=${this.save}> <input @keyup.~enter=${this.acceptWithAnyModifiers}> `

Supported modifiers are prevent, stop, immediate, once, capture, passive, and self. Long aliases such as preventDefault, stopPropagation, and stopImmediatePropagation are accepted. passive and prevent cannot be combined.

Keyboard filters support dot or colon notation. Exact filters such as @keydown.ctrl+s reject extra modifiers; ~ allows any modifier combination, as in @keydown.~enter.

Handlers can be functions or EventListenerObject values. Template functions run with this bound to the component that owns the render tree.

Named spreads

Prefer direct bindings when the set of keys is known; they are easier to read. Named spreads are for dynamic or forwarded bags from wrappers, plugins, and generated views. They make the target channel explicit and remove stale keys on later renders:

@property({ attribute: false }) forwardedProps = {}; @property({ attribute: false }) accessibility = {}; @property({ attribute: false }) forwardedListeners = {}; html`<input ...props=${this.forwardedProps} ...attrs=${this.accessibility} ...events=${this.forwardedListeners} >`

Event-spread values may be functions, EventListenerObject values, or nullish/false to remove a listener. Empty or duplicate normalized names (for example both click and @click) are rejected. Listeners detach while a conditional branch is parked and reattach when it returns. Removing a host from the document retains the listeners on its retained render tree, matching native DOM behavior. Consumed |once and EventListenerObject.once listeners stay consumed in either lifecycle.

Form controls

Keep both directions visible: bind component state to the native property, then handle the browser event that updates component state.

@state() query = ''; @state() accepted = false; render() { return html` <input .value=${this.query} @input=${this.updateQuery}> <input type="checkbox" .checked=${this.accepted} @change=${this.updateAccepted}> `; } updateQuery(event: InputEvent) { this.query = (event.currentTarget as HTMLInputElement).value; } updateAccepted(event: Event) { this.accepted = (event.currentTarget as HTMLInputElement).checked; }

Use input for text as it changes and change for committed choices such as checkboxes, selects, and files. Explicit handlers also make parsing, validation, and IME policy visible at the point where the view updates the model.

Control flow

Snice control-flow tags are virtual: they do not remain in the DOM.

If / else-if / else

html` <if ${this.loading}> <p>Loading…</p> <else-if ${this.error}> <p role="alert">${this.error.message}</p> </else-if> <else> <user-view .user=${this.user}></user-view> </else> </if> `

<else-if> and <else> must be direct children of <if>, and <else> must be last. Branch DOM is parked and restored, so input state and element identity survive branch switches.

Case / when / default

Static value matching compares string representations. A bare expression uses Object.is, so symbols, objects, numbers, and other typed values can be matched by identity.

html` <case ${this.status}> <when value="loading">Loading…</when> <when ${READY_STATE}>Ready</when> <default>Unknown</default> </case> `

Keyed repeat

Use repeat() when list identity matters:

html`<ul>${repeat(this.items, { key: item => item.id, render: (item, index) => html`<li>${index + 1}. ${item.label}</li>`, empty: () => html`<li>No results</li>` })}</ul>`

repeat() accepts any iterable, adds no wrapper, moves existing DOM when order changes, and rejects duplicate keys before reconciliation. A normal mapped array and key=${value} remain supported; repeat() is the explicit API when keyed identity and an empty state are required together.

Async content

A Promise or AsyncIterable can be rendered directly in a node expression. A promise replaces the pending empty range when it settles. An async iterable commits each emission. Changing the source ignores stale results, and disconnecting the owning tree stops active consumption.

userView = fetch(`/api/users/${this.userId}`) .then(response => response.json()) .then(user => html`<user-card .user=${user}></user-card>`) .catch(error => html`<p role="alert">${error.message}</p>`); render() { return html`${this.userView}`; }

Promise cancellation remains the caller's responsibility; use AbortController in the component lifecycle when a fetch must be aborted. Async iterators receive a best-effort return() call when replaced or disconnected.

Reactive authoring

Properties and state

@property({ type: Number, reflect: false }) page = 1; @property({ attribute: 'user-id' }) userId = ''; @property({ attribute: false }) service!: UserService; @state() open = false; @state({ deep: true }) filters = { tags: [] as string[] };

Plain HTMLElement subclasses retain legacy lowercase implicit attribute names. SniceElement converts implicit camelCase names to kebab-case. An explicit attribute name works the same on either base.

Deep state

deep: true observes nested writes in plain objects, arrays, Map, and Set:

@state({ deep: true }) model = { user: { name: 'Ada' }, rows: [] as Row[], flags: new Map<string, boolean>(), selected: new Set<string>() }; this.model.user.name = 'Grace'; this.model.rows.push(row); this.model.flags.set('ready', true);

Mutations are batched into the normal render scheduler. Cycles are supported, proxies are stable, collection iteration returns reactive nested values, and mutating an old graph after replacing the property does not invalidate the component.

Deep observation uses native Proxy and Reflect. It is intended for modern evergreen browsers and is not available in Internet Explorer. Regular @property() / @state() fields do not require deep proxies. Class instances such as Date, DOM nodes, and application service objects are deliberately left intact.

SniceElement conventions

@element('user-panel') class UserPanel extends SniceElement { static styles = [baseStyles, css`:host { display: block; }`]; static renderOptions = { debounce: 20 }; @state() user: User | null = null; render() { return html`...`; } }

invalidate() schedules the conventional render and returns the current rendered promise. renderNow() commits immediately. Decorated @render() / @styles() methods remain available and can be used on either base class.

Render roots

Render-root selection (shadow, closed, light) is an element-definition concern; see Elements.

Editor metadata

Published packages include:

Regenerate metadata with npm run generate:metadata; CI or local checks can use npm run check:metadata.

@render() Decorator

Returns a template using the html tagged template. Automatically re-renders when properties change due to differential rendering.

import { element, render, html, property } from 'snice'; @element('user-card') class UserCard extends HTMLElement { @property() name = 'Anonymous'; @render() renderContent() { return html` <div class="card"> <h3>${this.name}</h3> <p>User details...</p> </div> `; } }

Auto-Rendering:

Render Options:

The @render() decorator accepts an optional configuration object:

@render({ debounce?: number, // Delay re-render (ms) throttle?: number, // Limit re-render frequency (ms) once?: boolean, // Render once, disable auto-rendering sync?: boolean, // Synchronous rendering (skip batching) differential?: boolean // Disable differential rendering (default: true) })

Differential Rendering:

By default, Snice uses differential rendering which only updates changed parts of the DOM. To disable this and re-render from scratch each time:

@element('simple-list') class SimpleList extends HTMLElement { @property({ type: Array }) items = []; @render({ differential: false }) renderContent() { // Must return a string when differential: false return ` <ul> ${this.items.map(item => `<li>${item}</li>`).join('')} </ul> `; } }

When to use differential: false:

Note: When differential: false, the render method must return a string (not html\...\`). Declarative bindings and virtual control flow (<if>, <case>`, and related tags) are not available in the raw-string mode.

Imperative Rendering

Use @render({ once: true }) with @watch and @query to render the template once, then update the DOM directly. Property changes fire watchers instead of triggering re-renders.

@element('user-card') class UserCard extends HTMLElement { @property() name = ''; @property() role = ''; @query('.name') $name!: HTMLElement; @query('.role') $role!: HTMLElement; @render({ once: true }) template() { return html` <div class="card"> <h3 class="name">${this.name}</h3> <span class="role">${this.role}</span> </div> `; } @watch('name', 'role') update(oldVal: any, newVal: any, prop: string) { if (!this.$name) return; this.$name.textContent = this.name; this.$role.textContent = this.role; } }

How it works:

  1. @render({ once: true }) renders the template on first connect, then blocks all subsequent auto-renders. The initial render uses current property values via normal interpolation, so the DOM starts correct.
  2. @query provides getters that re-query the shadow DOM on each access — no stale references.
  3. @watch fires synchronously in the property setter, before requestRender is called. Since once: true blocks the render anyway, only the watcher runs.

Timing on property change:

  1. Property setter runs
  2. Value reflected to attribute (if applicable)
  3. @watch methods fire synchronously
  4. requestRender() called but immediately returns (blocked by once)

When to use imperative rendering:

Compared to declarative rendering:

Declarative (@render())Imperative (@render({ once: true }))
Template re-rendersAutomatic on property changeNever (after first render)
DOM updatesDifferential (only changed parts)Manual via @watch + @query
BoilerplateLess — just use interpolationMore — explicit update methods
ControlFramework manages updatesYou manage updates

Conditional Rendering

@element('conditional-content') class ConditionalContent extends HTMLElement { @property({ type: Boolean }) isLoggedIn = false; @render() renderContent() { return html` <if ${this.isLoggedIn}> <div>Welcome back!</div> <button @click=${this.logout}>Logout</button> <else-if ${this.sessionExpired}> <button @click=${this.login}>Sign in again</button> </else-if> <else> <a href="/login">Please login</a> </else> </if> `; } logout() { this.isLoggedIn = false; } }

The virtual control-flow tags add no wrapper elements and retain each branch's DOM identity. See Declarative Rendering for typed <when> branches, repeat(), and direct async values. See Binding Channels for exact property, attribute, event, spread, sentinel, and form semantics.

Binding Channels

Every expression in an html template writes through a specific browser channel. The channel decides whether a value becomes text, an attribute string, a JavaScript property, attribute presence, a listener, a class, or a CSS declaration. Choosing the channel is part of the component's public contract.

import { html, live, noChange, nothing } from 'snice';

This reference covers every binding channel supported by the differential renderer. For control flow, keyed repeat(), async node values, render roots, and reactive authoring, see Declarative Rendering.

Channel chooser

SyntaxWrites toUse it for
${value}A DOM range between marker commentsText, nested templates, nodes, lists, and async content
name=${value}An HTML attribute stringLabels, IDs, ARIA, URLs, and serialized data
name="a ${value} b"One interpolated attribute stringAttributes assembled from static and dynamic text
.name=${value}A JavaScript propertyObjects, arrays, functions, element APIs, and native form state
?name=${value}Attribute presenceNative boolean attributes and presence-based selectors
controller=${value}A controller attachmentController classes (preferred) or registry names
@event=${handler}An event listenerDOM and custom events
class:name=${value}One class tokenIndependent conditional classes
style:name=${value}One CSS declarationIndependent styles and CSS custom properties
...props=${bag}Multiple JavaScript propertiesDynamic or forwarded property bags
...attrs=${bag}Multiple attributesDynamic or forwarded attribute bags
...events=${bag}Multiple event listenersDynamic or forwarded listener bags
key=${value}An attribute and list identityIdentity for mapped template arrays
<!-- ${value} -->HTML comment dataInspectable diagnostics or generated metadata

Use direct bindings when names are known. Named spreads are deliberately explicit because property, attribute, and event bags have different value and cleanup rules.

Shared rules

Snice parses the static template structure once and updates only the expression-backed parts. A binding keeps its DOM node or listener when the same template is rendered again.

All channels except ordinary attributes and HTML comments take exactly one expression. Static text or additional expressions in a property, boolean, event, class, style, or named-spread binding are ignored with a warning:

// Wrong: a property is a value channel, not string interpolation. html`<user-card .user="prefix ${user}"></user-card>`; // Choose one complete JavaScript value. html`<user-card .user=${user}></user-card>`; // Or choose an attribute when the result is text. html`<user-card data-user="prefix ${user.id}"></user-card>`;

Expressions cannot appear loose inside an opening tag. Use an explicit binding name:

// Throws: expressions directly in opening tags are ambiguous. html`<input ${configuration}>`; // The destination is explicit. html`<input ...props=${configuration}>`;

The bare expressions on <if>, <else-if>, <case>, and <when> are the only opening-tag exception; they belong to Snice's virtual control-flow grammar. Bindings are unavailable when @render({ differential: false }) returns a raw string.

Node content

A node expression owns a range in the document. It accepts:

html` <h2>${title}</h2> ${items.map(item => html`<span>${item.label}</span>`)} ${ready ? html`<user-view .user=${user}></user-view>` : nothing} `;

Dynamic strings are text, not markup. unsafeHTML() is the explicit trusted-HTML boundary.

nothing, null, undefined, and the empty string clear the owned range. Other primitives are visible text, including false, 0, and NaN. A plain object that is not a supported template, node, or iterable falls back to String(value).

Promises start with an empty range and commit their result when settled. Async iterables commit each emitted value. Replacing a source prevents stale results from winning; disconnecting its owning tree stops active iteration. Promise cancellation remains the caller's responsibility.

Attributes

An ordinary attribute binding writes text through setAttribute():

html`<button title=${label} aria-label="Open ${label}" data-position="${row}:${column}" ></button>`;

Use attributes when the consumer is HTML, CSS, accessibility tooling, serialization, or a custom element's attribute API.

For a single-expression attribute:

An interpolated attribute may contain any number of expressions. null and undefined contribute empty text. If any slot is nothing, Snice removes the whole attribute. noChange preserves only that slot's previous value.

An attribute is always a string boundary. Do not use it to pass object identity to a child component; use a property binding.

Properties

A leading dot assigns directly to the element's JavaScript property:

html`<data-grid .rows=${rows} .formatter=${formatter} .selection=${selection} ></data-grid>`;

Property bindings preserve type and identity. They are the correct channel for arrays, objects, functions, class instances, custom-element APIs, and native state such as input.value, input.checked, or select.value.

null, undefined, and false are assigned unchanged. nothing assigns undefined. Re-rendering the same bound value skips the property write.

Live property values

Native controls and stateful custom elements can mutate their own properties after Snice writes them. Normally, if the bound value has not changed, a later render leaves that user- or element-edited DOM value alone. Wrap a property value in live() when the owner state must be reasserted even if the bound value itself is unchanged:

html`<input .value=${live(this.canonicalValue)}>`;

live() compares against the element's current property rather than the last value Snice committed. It is only for property bindings. It is useful for controlled fields, normalization, and resetting DOM state after validation; omit it when in-progress browser state should survive unrelated renders.

live() does not observe the DOM and does not schedule rendering. It reasserts

the value only when the template that owns the binding renders for some other

reason. This is especially useful when reopening a modal should restore the

same seed value after the user changed only the DOM control:

@state() editorOpen = false; @state() seededName = ''; openEditor(name: string) { this.seededName = name; this.editorOpen = true; // opening renders even when name equals the old seed } @render() template() { return html` <snice-modal ?open=${this.editorOpen}> <input .value=${live(this.seededName)}> </snice-modal> `; }

Without live(), reopening with the same seededName can leave the user's old

DOM edit in place because the binding remembers that it already committed that

seed. If no owner state changes, call the element's documented invalidation

path; live() by itself cannot notice drift.

Boolean attributes

A leading question mark controls presence, not a string value:

html`<button ?disabled=${saving} ?hidden=${collapsed} ?data-selected=${selected} ></button>`;

A truthy value adds the attribute with an empty value. A falsy value or nothing removes it. This includes false, 0, the empty string, null, undefined, and NaN.

Use this channel only when presence has meaning. If a consumer expects the text "false", use an ordinary attribute. If it expects a JavaScript boolean property, use .name.

Controllers

A bare controller=${value} binding attaches a controller. Bind the decorated class directly — the preferred channel — or a registry name string:

import { DataLoader } from './controllers/data-loader'; html`<user-list controller=${DataLoader}></user-list>`; html`<div controller=${DataLoader}></div>`; // native elements too

Class values skip the registry and are deduped by reference: re-rendering with the same class is a no-op; a different class (or null) detaches the previous controller first. The @controller('name') decorator is still required on the class. While a class is bound it owns the element. Snice reflects its decorator name as a diagnostic controller="name" attribute for DevTools, but that marker never resolves the registry or creates another attachment; treat it as read-only. Custom elements that have not upgraded yet hold the class until their connectedCallback runs.

String values delegate to the attribute channel and behave exactly like a static controller="name" attribute. Interpolated forms (controller="user-${kind}") are ordinary attribute interpolation, not this channel.

Controllers detach while their subtree is parked or disconnected and re-attach on reconnection, mirroring element lifecycle.

Class and style

class:name independently toggles one class without rebuilding the static class attribute:

html`<article class="card" class:selected=${selected} class:pending=${status === 'pending'} ></article>`;

A truthy value adds the class. A falsy value or nothing removes it. Static classes and other class:name bindings remain untouched.

style:name writes one declaration with CSSStyleDeclaration.setProperty():

html`<article style:color=${foreground} style:grid-column=${column} style:--card-accent=${accent} ></article>`;

CSS property names are passed through as written, so use CSS spelling such as background-color and --custom-token. nothing, null, undefined, and false remove the declaration. Every other value is stringified, including 0 and the empty string. Static style declarations and other style:name bindings remain untouched.

Use classMap() or styleMap() when producing a complete class or style attribute from an object is clearer. Use the channel bindings when each token has an independent condition or lifecycle.

Events

An event binding owns one native listener:

html`<button @click=${this.save}>Save</button>`;

A handler may be a function or an EventListenerObject with handleEvent(event). nothing, null, undefined, or false removes the listener. Any other value throws a TypeError.

Function handlers run with this set to the custom element that owns the render tree. Listener objects follow native DOM behavior: this inside handleEvent is the listener object itself. Replacing a handler updates the native listener; rendering the same handler leaves it attached.

Event modifiers

Append modifiers with a vertical bar:

html` <form @submit|prevent=${this.save}></form> <button @click|once|stop=${this.runOnce}>Run once</button> <div @click|self=${this.selectContainer}>...</div> `;

Supported modifiers:

The long aliases preventDefault, stopPropagation, and stopImmediatePropagation are also accepted. passive cannot be combined with prevent.

Modifiers use the vertical-bar form. A dot on a non-keyboard event is part of the actual event name, so @app.ready listens for the custom event app.ready; it does not apply a modifier.

Keyboard filters

keydown, keyup, and keypress support a key filter after a dot or colon:

html` <input @keydown.enter=${this.accept}> <input @keydown:escape=${this.cancel}> <input @keydown.ctrl+s|prevent=${this.save}> <input @keyup.~enter=${this.acceptWithAnyModifiers}> `;

Filters are exact by default: unspecified Ctrl, Alt, Shift, and Meta keys must be up. Prefix the key specification with ~ to ignore modifier state. Combine required modifiers with +; accepted names include ctrl or control, alt, shift, and meta, cmd, or command.

Common key aliases are normalized, including esc, return, space, arrow directions, del, backspace, tab, home, end, pageup, and pagedown.

Custom event names

Slash, colon, and dot characters may be part of a custom event name. If the actual event name starts with @, escape the template prefix by doubling it:

html` <user-card @user/saved=${this.refresh}></user-card> <user-card @app.ready=${this.ready}></user-card> <user-card @@snice/updated=${this.refresh}></user-card> `;

The last binding listens for the actual event name @snice/updated.

Listener objects and lifecycle

A listener object can carry native capture, passive, once, and signal options alongside handleEvent:

const listener = { once: true, signal: abortController.signal, handleEvent(event: Event) { consume(event); } }; html`<button @click=${listener}>Consume</button>`;

Listeners inside an inactive <if> or <case> branch detach while the branch is parked and reattach when it returns. A listener already consumed with once stays consumed. Removing and reconnecting a host retains listeners on its retained render tree, matching native DOM behavior.

Named spreads

Named spreads are for forwarding a bag whose keys are only known at runtime. They do not guess a destination:

html`<input ...props=${forwardedProperties} ...attrs=${accessibilityAttributes} ...events=${forwardedListeners} >`;

The spread value must be a non-array object. nothing, null, or undefined means an empty bag, which cleans up every previously committed key. noChange as the entire spread value preserves the current bag. false, primitives, and arrays throw. noChange is not an entry-level sentinel inside a bag. An omitted key is cleaned up according to its destination channel.

Property spreads

...props assigns each entry as a JavaScript property and preserves the entry's type and identity. An entry equal to nothing assigns undefined. A key omitted from the next bag is reset to undefined during that live update.

...properties is an accepted long alias. Prefer the shorter ...props spelling in new templates.

Attribute spreads

...attrs writes each entry as an attribute. nothing, null, undefined, and false remove that key; true writes an empty attribute; every other value is stringified. A key omitted from the next bag is removed.

...attributes is an accepted long alias. Prefer the shorter ...attrs spelling in new templates.

Event spreads

...events manages a listener per entry. Keys may be written as click or @click; the optional leading @ is removed. The two spellings cannot both appear for the same event in one bag, and empty names throw.

Values may be functions, listener objects, nothing, null, undefined, or false. Removing a key removes its listener. Listener objects support native capture, passive, once, and signal options. Function handlers use the render host as this.

Event-spread keys are literal event names. Direct-binding modifiers and keyboard-filter syntax are not parsed in spread keys; use a listener object for native options or a direct @event binding for filters and modifiers.

Keyed identity

In a normal mapped array, put key=${value} on the root element of every item template when DOM identity must follow the item instead of its array position:

html`${items.map(item => html` <user-row key=${item.id} .user=${item}></user-row> `)}`;

Keys compare by JavaScript identity. Every item must be keyed and keys must be unique. Duplicate keys throw. Mixing keyed and unkeyed item templates warns and falls back to position-based reconciliation. The key binding is also an ordinary attribute on the rendered root.

Prefer repeat(items, { key, render, empty }) when keyed identity is intentional. It supplies keys directly to the reconciler, adds no wrapper, and does not require a key attribute in the rendered template.

Comment interpolation

Expressions inside an authored HTML comment update the comment's data:

html` <!-- render revision: ${revision}; source: ${source} --> <user-view .user=${user}></user-view> `;

nothing, null, and undefined contribute empty text. Other values are stringified. noChange preserves an individual slot. A result containing -- or ending with - throws because it is not valid HTML comment data.

Comments are invisible in the rendered page but visible to DOM inspection and serialized markup. Do not place secrets in them.

Sentinel matrix

The same JavaScript value can mean something different in each channel:

Channelnull or undefinedfalsenothingnoChange
Node contentClear the rangeRender false textClear the rangeKeep the current range
AttributeWrite an empty valueWrite "false"Remove the attributeKeep the current value
PropertyAssign as-isAssign falseAssign undefinedKeep the current value
Boolean attributeRemoveRemoveRemoveKeep current presence
ControllerDetachDetachDetachKeep the current controller
Class tokenRemoveRemoveRemoveKeep current presence
Style propertyRemoveRemoveRemoveKeep the current declaration
Event listenerRemoveRemoveRemoveKeep the current listener
Whole named spreadClear the bagThrowClear the bagKeep the current bag
Comment slotWrite empty textWrite false textWrite empty textKeep the current slot

For an interpolated attribute or comment, noChange preserves only its expression slot. On first render there is no earlier slot value, so it contributes empty text.

nothing is a public rendering value. noChange is an optimization and control signal: it means “do not commit this part during this render,” not “remove this part.”

Form data flow

DOM-to-state updates remain explicit. Bind state to a native property, then handle the browser event that updates component state:

@state() query = ''; @state() accepted = false; render() { return html` <input .value=${this.query} @input=${this.updateQuery}> <input type="checkbox" .checked=${this.accepted} @change=${this.updateAccepted}> `; } updateQuery(event: InputEvent) { this.query = (event.currentTarget as HTMLInputElement).value; } updateAccepted(event: Event) { this.accepted = (event.currentTarget as HTMLInputElement).checked; }

This keeps parsing, validation, IME policy, and the event that changes state visible. Use input for text as it changes and change for committed choices such as checkboxes, selects, and files.

Property-to-attribute reflection from @property() is a custom-element API concern; it does not replace a form control's browser-to-state event. See Properties for property and reflection semantics.

Invalid placements and diagnostics

Snice rejects or warns about ambiguous channel authoring:

Enable setStrictRenderErrors(true) in tests and development when render failures should rethrow instead of being logged while the previous DOM remains in place.

Events API Documentation

Event handling in Snice provides two powerful approaches: template event syntax and the @on decorator. The @on decorator works in both elements AND controllers with full event delegation, keyboard modifiers, debounce/throttle, and more. Additionally, the @dispatch decorator enables automatic custom event dispatching.

Template Event Syntax (Preferred for Elements)

The recommended way to handle events in elements is using template event syntax with @event=${handler}:

Basic Usage

import { element, property, render, html } from 'snice'; @element('click-counter') class ClickCounter extends HTMLElement { @property({ type: Number }) count = 0; @render() renderContent() { return html` <div class="counter"> <button @click=${this.increment}>Increment</button> <button @click=${this.decrement}>Decrement</button> <button @click=${this.reset}>Reset</button> <span class="count">${this.count}</span> </div> `; } increment() { this.count++; } decrement() { this.count--; } reset() { this.count = 0; } }

Event Object Access

@element('form-handler') class FormHandler extends HTMLElement { @render() renderContent() { return html` <form @submit=${this.handleSubmit}> <input type="text" name="username" @input=${this.handleInput} @focus=${this.handleFocus} @blur=${this.handleBlur} > <button type="submit">Submit</button> </form> `; } handleSubmit(event: Event) { event.preventDefault(); const form = event.target as HTMLFormElement; const formData = new FormData(form); console.log('Form submitted:', Object.fromEntries(formData)); } handleInput(event: Event) { const input = event.target as HTMLInputElement; console.log('Input value:', input.value); } handleFocus(event: Event) { console.log('Input focused'); } handleBlur(event: Event) { console.log('Input blurred'); } }

Multiple Event Types

@element('file-upload') class FileUpload extends HTMLElement { @property({ type: Boolean }) dragOver = false; @render() renderContent() { return html` <div class="dropzone ${this.dragOver ? 'drag-over' : ''}" @dragenter=${this.handleDragEnter} @dragover=${this.handleDragOver} @dragleave=${this.handleDragLeave} @drop=${this.handleDrop} > Drop files here </div> `; } handleDragEnter(e: DragEvent) { e.preventDefault(); this.dragOver = true; } handleDragOver(e: DragEvent) { e.preventDefault(); } handleDragLeave(e: DragEvent) { this.dragOver = false; } handleDrop(e: DragEvent) { e.preventDefault(); this.dragOver = false; const files = Array.from(e.dataTransfer?.files || []); console.log('Files dropped:', files); } }

Keyboard Shortcuts in Templates

Template event syntax supports keyboard shortcuts using dot notation:

@element('keyboard-input') class KeyboardInput extends HTMLElement { @render() renderContent() { return html` <div> <input @keydown.enter=${this.handleEnter} placeholder="Press Enter" > <input @keydown.ctrl+s=${this.handleSave} placeholder="Press Ctrl+S" > <input @keydown.escape=${this.handleCancel} placeholder="Press Escape" > <input @keydown.~enter=${this.handleAnyEnter} placeholder="Press Enter with any modifiers" > </div> `; } handleEnter(e: KeyboardEvent) { console.log('Enter pressed (no modifiers)'); } handleSave(e: KeyboardEvent) { e.preventDefault(); console.log('Ctrl+S pressed'); } handleCancel(e: KeyboardEvent) { console.log('Escape pressed'); } handleAnyEnter(e: KeyboardEvent) { console.log('Enter pressed (with any modifiers)'); } }

Keyboard Shortcut Syntax:

Template Event Modifiers

Append DOM listener and propagation behavior with |:

html` <form @submit|prevent=${this.submit}></form> <button @click|once|stop=${this.runOnce}>Run once</button> <section @click|self=${this.selectSection}>...</section> <input @keydown.ctrl+s|prevent=${this.save}> <div @pointermove|capture|passive=${this.track}></div> `

Supported modifiers:

passive and prevent are contradictory and produce a render error. Modifiers compose with dot or colon keyboard filters. Exact keyboard combinations reject extra modifiers; prefix the key filter with ~ to accept any modifier combination.

Handlers may also be EventListenerObject values. Their handleEvent() method receives the object as this, and capture, once, or passive fields are honored. Function handlers receive the component that owns the render tree as this.

Arrow Functions in Templates

Use arrow functions for inline event handling or passing parameters:

@element('task-list') class TaskList extends HTMLElement { @property() tasks = [ { id: 1, name: 'Task 1', completed: false }, { id: 2, name: 'Task 2', completed: false } ]; @render() renderContent() { return html` <ul> ${this.tasks.map(task => html` <li> <input type="checkbox" ?checked=${task.completed} @change=${(e: Event) => this.toggleTask(task.id, e)} > <span>${task.name}</span> <button @click=${() => this.deleteTask(task.id)}>Delete</button> </li> `)} </ul> `; } toggleTask(id: number, e: Event) { const checkbox = e.target as HTMLInputElement; const task = this.tasks.find(t => t.id === id); if (task) { task.completed = checkbox.checked; // Trigger re-render this.tasks = [...this.tasks]; } } deleteTask(id: number) { this.tasks = this.tasks.filter(t => t.id !== id); } }

@on Decorator

The @on decorator works in both elements AND controllers. It provides powerful event delegation, keyboard modifiers, debounce/throttle, and automatic event handling features.

Use @on when you need:

Basic Controller Usage

import { controller, on, IController } from 'snice'; @controller('button-controller') class ButtonController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) { console.log('Controller attached'); } async detach(element: HTMLElement) { console.log('Controller detached'); } @on('click') handleClick(event: MouseEvent) { console.log('Button clicked'); } @on('mouseenter') handleMouseEnter(event: MouseEvent) { console.log('Mouse entered'); } @on('mouseleave') handleMouseLeave(event: MouseEvent) { console.log('Mouse left'); } }

Event Delegation with Selector

@controller('list-controller') class ListController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) {} async detach(element: HTMLElement) {} // Handle clicks on list items @on('click', '.list-item') handleItemClick(event: MouseEvent) { const item = event.target as HTMLElement; console.log('Item clicked:', item.textContent); } // Handle clicks on delete buttons @on('click', '.delete-button') handleDeleteClick(event: MouseEvent) { event.stopPropagation(); const button = event.target as HTMLElement; const item = button.closest('.list-item'); item?.remove(); } // Handle input on text fields @on('input', 'input[type="text"]') handleTextInput(event: Event) { const input = event.target as HTMLInputElement; console.log('Text changed:', input.value); } }

Three delegation rules worth knowing:

@on('click', '.delete-button') the handler receives the raw event, so

derive the match with event.target.closest('.delete-button') when you need

the element itself (as above).

elements in the component's shadow tree and its light-DOM children. A click

on content slotted into a matching shadow wrapper matches that wrapper.

Narrow the search with the light/shadow options — the same tree toggles

@query uses (see @on Options).

crossing a shadow boundary is retargeted to the shadow host, so

@on('row-clicked', 'my-row') stops matching when rows move into a list

component — from outside, the event's target is the list host. A selector

never matches a child component's internals. Listen on the container and

carry the row identity in the event detail instead.

Keyboard Events with @on

@controller('editor-controller') class EditorController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) {} async detach(element: HTMLElement) {} @on('keydown:Enter', 'textarea') handleEnter(event: KeyboardEvent) { console.log('Enter pressed in textarea'); } @on('keydown:Ctrl+S') handleSave(event: KeyboardEvent) { event.preventDefault(); console.log('Save shortcut triggered'); this.save(); } @on('keydown:Escape') handleEscape(event: KeyboardEvent) { console.log('Escape pressed'); this.cancel(); } private save() { console.log('Saving...'); } private cancel() { console.log('Cancelling...'); } }

@on Options

interface OnOptions { // Standard event listener options capture?: boolean; // Use capture phase instead of bubble phase once?: boolean; // Handler runs exactly once; non-matching selector/key events don't consume it passive?: boolean; // Passive listener (can't preventDefault) // Automatic event handling preventDefault?: boolean; // Automatically call preventDefault on the event stopPropagation?: boolean; // Automatically call stopPropagation on the event // Timing controls debounce?: EventTiming; // Debounce the handler throttle?: EventTiming; // Throttle the handler // Delegation target?: string; // CSS selector for delegation; same as the positional selector argument // Tree toggles — the same light/shadow pair @query uses; both default to true light?: boolean; // Listen in the light DOM (host element + light children) shadow?: boolean; // Listen in the shadow tree (the component's shadow root) // Where to attach the listener (see scope section below) scope?: 'global' | string | EventTarget | ((this: HTMLElement) => EventTarget | null); // Named daemon from the nearest provided app context daemon?: string; } type EventTiming = number | ((this: any) => number);

Numeric intervals remain supported. A resolver can instead read a value from

the decorated element or controller instance. For @on, it runs when the

managed listener is set up and again when it is set up after reconnect. The

resolved value must be a finite, non-negative number; 0 disables that timing

option, while invalid, negative, and NaN values throw TypeError.

light / shadow — choosing the tree

light and shadow are the same tree toggles @query uses. Both default to

true, so @on hears events from the shadow tree and the light DOM alike.

Set one to false to narrow the listener:

// Only the component's shadow tree @on('click', '.item', { light: false }) onShadowItem(e: MouseEvent) { /* ... */ } // Only light-DOM children (and the host itself) @on('click', '.item', { shadow: false }) onLightItem(e: MouseEvent) { /* ... */ }

For direct handlers the flags choose where the listener attaches: shadow

controls the shadow-root listener, light controls the host listener. For

delegated handlers they choose which tree(s) the selector matches in. Setting

both to false warns and skips the listener, and the flags are ignored (with

a warning) when scope or daemon is set — those own the listener target

outright.

scope — controlling the listener target

By default, @on attaches the listener to the host element. The scope option redirects

that attachment to another target, which is how Snice expresses cross-cutting events.

scope valueListener attaches to
omittedhost element (default)
'global'document
selector stringhost.closest(selector) — nearest matching ancestor
Element / EventTargetthat node directly
`(this) => EventTarget \null`called at connect; null skips

The resolver function is called with the host element as this and re-resolves each time

the component reconnects to the DOM, so listeners follow the host when it moves.

// Cross-cutting global event (document) @on('bus:save', { scope: 'global' }) onSave(e: CustomEvent) { /* ... */ } // Scoped to nearest ancestor matching the selector @on('bus:cart-added', { scope: 'cart-shell' }) onCartAdded(e: CustomEvent) { /* ... */ } // Explicit EventTarget @on('go', { scope: someElement }) onGo() { /* ... */ } // Resolver — full control, re-runs on reconnect @on('beep', { scope() { return this.closest('app-shell'); } }) onBeep() { /* ... */ }

If scope cannot resolve (selector matches no ancestor, resolver returns null or throws),

the listener is not attached and a console.warn is emitted. The component still

mounts; only the listener is skipped.

Disconnect removes the listener from whichever target it was attached to. Reconnect

re-resolves and re-attaches, so resolver-based scopes track DOM moves correctly.

scope is compatible with the delegation selector — the listener attaches on the

scoped target and still matches the selector when an event fires within it.

daemon is a separate, non-DOM target. It cannot be combined with scope and does

not support selector delegation. See Daemons.

Throttling

@controller('scroll-controller') class ScrollController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) {} async detach(element: HTMLElement) {} // Throttle scroll events to max once per 100ms @on('scroll', null, { throttle: 100 }) handleScroll(event: Event) { const element = event.target as HTMLElement; console.log('Scroll position:', element.scrollTop); } }

Debouncing

@controller('search-controller') class SearchController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) {} async detach(element: HTMLElement) {} // Debounce input events by 300ms @on('input', 'input[type="search"]', { debounce: 300 }) handleSearch(event: Event) { const input = event.target as HTMLInputElement; console.log('Searching for:', input.value); this.performSearch(input.value); } private async performSearch(query: string) { // Search implementation } }

Per-instance interval:

@on('input', 'input[type="search"]', { debounce() { return this.searchDebounce; } }) handleSearch(event: Event) { /* ... */ }

Use method syntax (or a normal function), not an arrow, when reading this.

Controllers receive the controller instance as this; elements receive the

element instance.

Using @on in Elements (Alternative)

While template syntax is preferred, @on can also be used in elements:

import { element, on, render, html } from 'snice'; @element('my-button') class MyButton extends HTMLElement { @render() renderContent() { return html`<button class="btn">Click me</button>`; } @on('click', '.btn') handleClick(event: MouseEvent) { console.log('Button clicked via @on decorator'); } @on('input', 'input', { debounce: 300 }) handleInput(event: Event) { console.log('Input debounced'); } }

Note: For new element code, prefer template event syntax for better readability and type safety.

@dispatch Decorator

Auto-dispatch custom events after method execution:

Basic Usage

import { element, dispatch, render, html } from 'snice'; @element('value-input') class ValueInput extends HTMLElement { private value = ''; @render() renderContent() { return html` <input type="text" .value=${this.value} @input=${this.handleInput} > `; } handleInput(e: Event) { const input = e.target as HTMLInputElement; this.setValue(input.value); } @dispatch('value-changed') setValue(newValue: string) { this.value = newValue; return { value: newValue }; // Event detail } }

Usage:

const input = document.querySelector('value-input'); input.addEventListener('value-changed', (e: CustomEvent) => { console.log('New value:', e.detail.value); });

Event Options

Events dispatched by @dispatch default to bubbles: true and composed: true (crosses shadow DOM boundaries). Override if needed:

@element('status-indicator') class StatusIndicator extends HTMLElement { @render() renderContent() { return html`<div>Status</div>`; } // Defaults: bubbles: true, composed: true @dispatch('status-changed') updateStatus(status: string) { return { status, timestamp: Date.now() }; } }

DispatchOptions

interface DispatchOptions extends EventInit { dispatchOnUndefined?: boolean; // Undefined return still dispatches unless false (default: true) debounce?: EventTiming; // Debounce dispatch throttle?: EventTiming; // Throttle dispatch // Where to dispatch the event (see scope section below) scope?: 'global' | string | EventTarget | ((this: HTMLElement) => EventTarget | null); // Named daemon from the nearest provided app context daemon?: string; }

EventTiming is the same number | ((this: any) => number) type used by

@on. @dispatch resolves it against the decorated instance on every method

invocation, so an element or controller can change its interval at runtime.

The same finite, non-negative validation applies. Debounced async methods still

dispatch only after the method resolves. Disconnect synchronously drops timed

dispatches already queued and decorated async invocations that started before

disconnect. Lifecycle hooks always run with the real element as this; an

@dispatch method called by teardown code after that cancellation is a new,

ordinary invocation (and, after reconnect, participates in the current timing

state).

Each invocation supersedes pending timed work for that decorated method. A

resolved 0 therefore cancels an older debounce/throttle timer and dispatches

the new result without delay. For throttle, a suppressed invocation replaces

the trailing detail and recalculates its deadline as the last actual dispatch

plus the newly resolved interval.

scope — controlling the dispatch target

By default, @dispatch originates from the element or a controller's host; on a

daemon it uses that instance's private target. The scope option redirects a DOM dispatch so the

event behaves as if it originated there. Use this with @on({ scope }) to express

cross-cutting events without going through bubbling.

scope valueEvent dispatched on
omittedhost element (default)
'global'document
selector stringhost.closest(selector) — nearest matching ancestor
Element / EventTargetthat node directly
`(this) => EventTarget \null`called per dispatch; null skips
// Global cart-add bus @element('add-to-cart-button') class AddToCartButton extends HTMLElement { @on('click', 'button') click() { this.add(this.productId); } @dispatch('bus:cart-added', { scope: 'global' }) add(id: string) { return { id }; } } // Listener on any other element @element('cart-counter') class CartCounter extends HTMLElement { @on('bus:cart-added', { scope: 'global' }) bump() { this.count++; } }

If scope cannot resolve (selector matches no ancestor, resolver returns null),

the event is not dispatched and a console.warn is emitted. The method's return

value still flows through dispatchOnUndefined / debounce / throttle semantics

before the scope check.

Use { daemon: 'session' } to dispatch on an explicitly provided daemon's private

communication target. daemon and scope are mutually exclusive. See

Daemons.

Debounce/Throttle

@element('search-box') class SearchBox extends HTMLElement { @render() renderContent() { return html`<input @input=${this.handleInput}>`; } handleInput(e: Event) { this.emitSearch((e.target as HTMLInputElement).value); } @dispatch('search-query', { debounce: 300 }) emitSearch(query: string) { return { query }; } } @dispatch('search-query', { debounce() { return this.searchDebounce; } }) emitSearch(query: string) { return { query }; }

Async Methods

@dispatch works with async methods — the event dispatches after the promise resolves:

@dispatch('validation-complete') async validate() { const result = await this.runValidation(); return { valid: result.isValid, errors: result.errors }; }

Multiple Events

@element('color-picker') class ColorPicker extends HTMLElement { @property() color = '#000000'; @render() renderContent() { return html` <input type="color" .value=${this.color} @input=${this.handleInput}> <button @click=${this.confirm}>OK</button> `; } handleInput(e: Event) { this.changeColor((e.target as HTMLInputElement).value); } @dispatch('color-preview') changeColor(color: string) { this.color = color; return { color }; } @dispatch('color-selected') confirm() { return { color: this.color }; } }

Event Bus

Snice has no bus object and no singleton. A bus is @dispatch publishing upward and

@on subscribing at a chosen ancestor -- the scope is a DOM node, so it is created and

torn down with the DOM.

There are two ways to publish, and the choice decides who can hear it.

Bubble up from the host. @dispatch is bubbles: true, composed: true by default, so

the event crosses shadow boundaries and passes every ancestor on its way to document.

Use this when the event is about this element and ancestors may care.

@element('product-tile') class ProductTile extends HTMLElement { @dispatch('bus:cart-added') addToCart(sku: string) { return { sku, qty: 1 }; } }

Dispatch directly on a scope. @dispatch takes the same scope option as @on, which

fires the event on that target instead of bubbling from the host. Use this when the

subscriber is not an ancestor -- a sibling subtree, or a host that may be detached.

@dispatch('bus:cart-added', { scope: 'global' }) // dispatched on document add(id: string) { return { id }; }

Subscribe at the scope you want to share.

// App-wide: listens on document @on('bus:cart-added', { scope: 'global' }) onCartAdded(e: CustomEvent) { /* ... */ } // Feature-scoped: listens on the nearest <cart-shell> ancestor, so a second // <cart-shell> elsewhere on the page keeps its own traffic @on('bus:cart-added', { scope: 'cart-shell' }) onCartAdded(e: CustomEvent) { /* ... */ }

Choosing a scope:

ReachscopeUse when
Whole document'global'Genuinely app-wide: auth expiry, theme change, save shortcut
A feature subtreeselector stringThe event belongs to one shell and must not leak to a sibling instance
A specific nodeEventTarget / resolverYou already hold the node, or the target moves and must re-resolve

Prefer the narrowest scope that works. 'global' means every instance on the page hears

every message, which is what makes singleton buses hard to reason about.

Match the two halves. A bubbling publish only reaches subscribers that sit on the host's

ancestor chain; if the subscriber lives in a sibling subtree, scope the dispatch too so

both meet on the same node. See

scope on @dispatch.

Name events so the routing is visible at the call site -- the bus: prefix above is a

convention, not a framework feature. Any string works.

Teardown is automatic: the listener is removed on disconnect from whichever target it

resolved to, and re-resolved on reconnect, so subscribers follow the host when it moves.

If a selector scope matches no ancestor the listener is skipped with a console.warn

rather than silently binding to the wrong node. See

scope for the full resolution table.

For a request that needs an answer rather than a broadcast, use

@request / @respond instead.

For app-owned state with an explicit lifecycle, provide an @daemon instance and

use { daemon: 'name' } on both publishers and subscribers. This keeps consumers

decoupled from the implementation class without introducing a global singleton.

See Daemons.

Custom Events

Prefer @dispatch for a static custom event emitted from a Snice element or a

controller's host. The decorator supplies the standard bubbling and composed

behavior, uses the method return value as detail, and keeps debounce,

throttle, and scope declarative.

Manual dispatch remains valid when code needs direct access to the Event

object, a dynamic event name, the cancellation boolean returned by

dispatchEvent(), or a target other than the Snice host.

Cancelable Events (Suppressible Default Actions)

cancelable is an EventInit field, so `@dispatch('my-event', { cancelable:

true })` really does produce a cancelable event and a listener really can call

preventDefault() on it.

What the decorator cannot give you is the answer. @dispatch returns the

method's own return value — the event detail — not the boolean

dispatchEvent() produces, so the component has no way to find out that a

listener cancelled the event. A component whose event carries a **default

action it must be able to suppress** therefore dispatches by hand:

@element('day-chip') class DayChip extends HTMLElement { // Returns false when a listener called preventDefault(). private emitMoreClick(date: Date, count: number): boolean { return this.dispatchEvent(new CustomEvent('more-click', { detail: { date, count }, bubbles: true, composed: true, cancelable: true })); } private handleChipClick(date: Date, count: number) { // The application gets first refusal on the day. if (!this.emitMoreClick(date, count)) return; this.openBuiltInPanel(date); } }

An application then chooses between the two behaviors with one line:

// Keep the built-in behavior, just observe it chip.addEventListener('more-click', (e) => log(e.detail)); // Replace the built-in behavior entirely chip.addEventListener('more-click', (e) => { e.preventDefault(); openMyOwnDayView(e.detail.date); });

Components in the library that use this pattern: snice-link's navigate,

snice-stepper's step-change, and snice-calendar's calendar-more-click.

Manual Escape Hatch

@element('manual-dispatcher') class ManualDispatcher extends HTMLElement { @render() renderContent() { return html` <button @click=${this.notify}>Notify</button> `; } notify() { // Dispatch custom event manually this.dispatchEvent(new CustomEvent('notification', { detail: { message: 'Hello!', level: 'info' }, bubbles: true, composed: true })); } }

Listening to Custom Events

@element('event-listener') class EventListener extends HTMLElement { @render() renderContent() { return html` <manual-dispatcher @notification=${this.handleNotification}></manual-dispatcher> `; } handleNotification(e: CustomEvent) { console.log('Received notification:', e.detail); } }

Event Delegation

Controller Event Delegation

@controller('table-controller') class TableController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) {} async detach(element: HTMLElement) {} // Single event listener handles all rows @on('click', 'tr') handleRowClick(event: MouseEvent) { const row = (event.target as HTMLElement).closest('tr'); console.log('Row clicked:', row?.dataset.id); } // Handle button clicks in cells @on('click', 'button.edit') handleEdit(event: MouseEvent) { const button = event.target as HTMLButtonElement; const row = button.closest('tr'); console.log('Edit row:', row?.dataset.id); } @on('click', 'button.delete') handleDelete(event: MouseEvent) { event.stopPropagation(); // Don't trigger row click const button = event.target as HTMLButtonElement; const row = button.closest('tr'); row?.remove(); } }

Template Event Delegation

For dynamic content, use controllers with @on for event delegation, or handle events on a parent element:

@element('dynamic-list') class DynamicList extends HTMLElement { @property() items = ['Item 1', 'Item 2', 'Item 3']; @render() renderContent() { return html` <ul @click=${this.handleListClick}> ${this.items.map((item, index) => html` <li data-index="${index}"> ${item} <button class="delete">Delete</button> </li> `)} </ul> `; } handleListClick(e: MouseEvent) { const target = e.target as HTMLElement; // Handle delete button if (target.classList.contains('delete')) { const li = target.closest('li'); const index = parseInt(li?.dataset.index || '-1'); if (index >= 0) { this.items = this.items.filter((_, i) => i !== index); } return; } // Handle li click if (target.tagName === 'LI') { console.log('Item clicked:', target.textContent); } } }

Keyboard Shortcuts

Template Syntax (Preferred)

@element('shortcut-handler') class ShortcutHandler extends HTMLElement { @render() renderContent() { return html` <div> <input @keydown.enter=${this.submit} placeholder="Press Enter"> <input @keydown.ctrl+s=${this.save} placeholder="Ctrl+S to save"> <input @keydown.ctrl+shift+s=${this.saveAs} placeholder="Ctrl+Shift+S for Save As"> <input @keydown.escape=${this.cancel} placeholder="Escape to cancel"> <input @keydown.~enter=${this.submitAny} placeholder="Enter with any mods"> </div> `; } submit(e: KeyboardEvent) { console.log('Submit'); } save(e: KeyboardEvent) { e.preventDefault(); console.log('Save'); } saveAs(e: KeyboardEvent) { e.preventDefault(); console.log('Save As'); } cancel(e: KeyboardEvent) { console.log('Cancel'); } submitAny(e: KeyboardEvent) { console.log('Submit with any modifiers'); } }

@on Decorator Syntax

@controller('keyboard-controller') class KeyboardController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) {} async detach(element: HTMLElement) {} @on('keydown:Enter') handleEnter(e: KeyboardEvent) { console.log('Enter pressed'); } @on('keydown:Ctrl+S') handleSave(e: KeyboardEvent) { e.preventDefault(); console.log('Save'); } @on('keydown:Escape') handleEscape(e: KeyboardEvent) { console.log('Escape'); } }

Controllers API Documentation

Controllers hold application behavior specific to a set of elements, including

their data fetching, business rules, and server communication. They can be

attached to any HTML element, including native elements.

Visual behavior belongs in elements, application behavior specific to a set of

elements belongs in a controller, and element orchestration belongs in pages.

Do not attach a controller to the page host. A host-free reusable function may

stay a plain module wherever the project keeps it. URL/query parsing belongs in

@page({ routes }), not in a controller.

Basic Usage

Creating a Controller

import { controller, IController } from 'snice'; @controller('user-controller') class UserController implements IController<HTMLElement> { element: HTMLElement | null = null; async attach(element: HTMLElement) { // Called when controller is attached to an element console.log('Controller attached to', element); } async detach(element: HTMLElement) { // Called when controller is detached from an element console.log('Controller detached from', element); } }

Attaching Controllers

Bind the controller class directly in a template — this is the preferred way:

import { UserController } from './controllers/user-controller'; html`<user-list controller=${UserController}></user-list>` // Works on native elements inside templates too html`<div controller=${UserController}></div>`

Class bindings skip the registry lookup: the imported class is attached as-is.

The @controller('name') decorator is still required — it registers the class,

marks it, and flushes pending attachments. Re-binding the same class reference

is a no-op; binding a different class (or null) detaches the old controller

first. While a class is bound, the class binding owns the element: controller

attribute writes cannot switch it until the class is unbound.

For inspection in DevTools, Snice reflects the decorator name as a

controller="name" attribute while the class is attached. This marker is

diagnostic only: it does not resolve the registry or create a second

attachment. Treat it as read-only; the class reference remains authoritative.

Snice removes the marker when the class detaches or replaces it when another

controller is bound.

An element hosts at most one controller. Attaching a different controller

always detaches the current one; this is an architectural 1:1 relationship,

not only a template rebinding detail. Put independent controller behaviors on

separate host elements (or intentionally compose them inside one controller).

Imperative equivalents:

import { attachController } from 'snice'; await attachController(element, UserController); // any element el.controller = UserController; // snice elements

Attaching by Name (strings)

Every controller is also registered under its decorator name, and attaching by

string remains fully supported. It is the only channel available in raw HTML

markup, where attributes are all you have:

<!-- Custom element --> <user-list controller="user-controller"></user-list> <!-- Native element (works automatically) --> <div controller="user-controller"></div>

Strings also work in template bindings — controller=${'user-controller'} and

interpolated forms like controller="user-${kind}" behave exactly like the

static attribute.

IController Interface

interface IController<T extends HTMLElement = HTMLElement> { element: T | null | undefined; attach(element: T): void | Promise<void>; detach(element: T): void | Promise<void>; }

Controller Lifecycle

One controller per element. An element hosts at most ONE controller at a

time — binding a different class or null detaches the current one first.

Combined with the rule that a page host may not carry a controller, this means

a resource with several endpoints cannot be split one-controller-per-endpoint

on a single host. The workable pattern: attach each mutation controller to its

own trigger element (a button, a form, a region) and let them share state

through the element's reactive properties or events.

Attachment Flow

  1. Controller instance is created
  2. element property is set
  3. Router application context is passed (if available)
  4. Channel/response handlers (@respond) are set up
  5. Element's ready promise is awaited
  6. attach() method is called
  7. @context handlers are registered and caught up with the current Router context
  8. Observers are set up
  9. Event handlers are set up
  10. controller-attached event is dispatched

The step-5 wait has one safe exception: when an element calls

await attachController(this, ControllerClass) from its own @ready handler,

Snice attaches immediately. Initial rendering has already completed at that

point, and waiting for ready would otherwise create a self-deadlock because

ready cannot settle until the current handler returns. Attaching to any

other element still awaits that element's ready promise.

This runtime safeguard does not make attaching a controller to a routed page a

good architecture; pages should orchestrate directly.

Responders Are Reachable Before the Host Is Ready

@respond handlers are installed at step 4, before the wait — the same

ordering elements use, where @respond is installed on connection rather than

at ready. This matters for the common shape where a host's @ready waits on

a child that makes a request as it mounts:

@element('report-host') class ReportHost extends HTMLElement { @ready() async onReady() { // The host cannot finish becoming ready until the child does... await this.chart.ready; } @render() renderContent() { return html`<report-chart></report-chart>`; } } @element('report-chart') class ReportChart extends HTMLElement { @request('report-data') async *load(): Response<void> { // ...and the child cannot finish until something answers this. this.data = await (yield { range: '30d' }); } @ready() async onReady() { await this.load(); } }

With <report-host controller="report-controller"> answering report-data, the

child's request reaches the controller while the host's ready is still

pending: discovery resolves immediately, the request releases the step-5 wait,

and the responder runs once attach() and the remaining setup have completed.

If the responder were only installed after ready, the two sides would wait on

each other until a timeout fired and the attachment would fail outright.

The responder body still never runs before attach(), so anything the

controller builds in attach() is safe to use inside a @respond method.

@respond('name', { daemon: 'x' }) has one extra condition. A daemon target is

resolved through the host's app context, which is unreachable while the host is

still disconnected — the shape where attachController(el, Controller) runs

before el is appended. When the context already resolves, the daemon-scoped

responder registers at step 4 with every other responder; when it does not, it

registers after attach() instead of being dropped, exactly where it registered

before.

Detachment Flow

  1. detach() method is called
  2. element property is set to null
  3. Observers are cleaned up
  4. Channel/response handlers are cleaned up
  5. Event handlers are cleaned up
  6. @context handlers are cleaned up
  7. Controller scope is cleaned up
  8. controller-detached event is dispatched

Example with Lifecycle Logging

@controller('lifecycle-controller') class LifecycleController implements IController { element: HTMLElement | null = null; private intervalId?: number; async attach(element: HTMLElement) { console.log('1. Controller attaching to', element.tagName); // Wait for any async initialization await this.initialize(); // Set up recurring tasks this.intervalId = setInterval(() => { this.updateData(); }, 5000); console.log('2. Controller attached'); } async detach(element: HTMLElement) { console.log('3. Controller detaching from', element.tagName); // Clean up resources if (this.intervalId) { clearInterval(this.intervalId); } // Perform async cleanup await this.cleanup(); console.log('4. Controller detached'); } private async initialize() { // Async initialization logic } private async cleanup() { // Async cleanup logic } private updateData() { console.log('Updating data...'); } }

Native Element Controllers

Native element controllers are enabled automatically when Snice loads in a browser environment. No setup is required.

You can attach controllers to any HTML element:

<div controller="content-controller"> <p>Content managed by controller</p> </div> <table controller="table-controller"> <tbody></tbody> </table> <form controller="form-controller"> <input type="text" name="username"> </form>

Example: Table Controller

Controllers provide specific behaviors (data fetching, sorting, filtering) to generic visual components. The component handles rendering — the controller handles data:

@controller('table-controller') class TableController implements IController<HTMLTableElement> { element: HTMLTableElement | null = null; async attach(element: HTMLTableElement) { const data = await fetch('/api/data').then(r => r.json()); // Pass data to the element — if it's a custom element, call its API if ('setData' in element && typeof (element as any).setData === 'function') { (element as any).setData(data); } } async detach() {} }

Resource Cleanup

The framework auto-cleans @on, @observe, @respond, and @context handlers. Clean up your own resources (WebSockets, timers, manual listeners) in detach:

import { controller, IController } from 'snice'; @controller('resource-controller') class ResourceController implements IController { element: HTMLElement | null = null; private websocket?: WebSocket; private eventHandler?: (e: MessageEvent) => void; async attach(element: HTMLElement) { // Open websocket this.websocket = new WebSocket('ws://localhost:8080'); // Set up event listener this.eventHandler = (e: MessageEvent) => this.handleMessage(e); this.websocket.addEventListener('message', this.eventHandler); } async detach(element: HTMLElement) { // Clean up resources if (this.websocket) { if (this.eventHandler) { this.websocket.removeEventListener('message', this.eventHandler); } this.websocket.close(); this.websocket = undefined; } this.eventHandler = undefined; } private handleMessage(event: MessageEvent) { console.log('Received:', event.data); } }

Event Handling in Controllers

Controllers can use the @on decorator to handle events from their attached element:

import { controller, on, IController } from 'snice'; @controller('form-controller') class FormController implements IController<HTMLFormElement> { element: HTMLFormElement | null = null; async attach(element: HTMLFormElement) { console.log('Form controller attached'); } async detach(element: HTMLFormElement) { console.log('Form controller detached'); } @on('submit') handleSubmit(event: Event) { event.preventDefault(); console.log('Form submitted'); this.processForm(); } @on('input', 'input[type="text"]') handleTextInput(event: Event) { const input = event.target as HTMLInputElement; console.log('Text input changed:', input.value); } @on('change', 'select') handleSelectChange(event: Event) { const select = event.target as HTMLSelectElement; console.log('Select changed:', select.value); } private processForm() { if (!this.element) return; const formData = new FormData(this.element); console.log('Processing form data:', Object.fromEntries(formData)); } }

Query Selectors in Controllers

Controllers can use @query and @queryAll to access elements. Important: By default, @query searches the shadow DOM. When attached to native elements (no shadow root), use { light: true }:

import { controller, query, queryAll, IController } from 'snice'; @controller('form-validation-controller') class FormValidationController implements IController<HTMLFormElement> { element: HTMLFormElement | null = null; // light: true is required — native elements have no shadow root @query('.error-message', { light: true }) errorEl?: HTMLElement; @queryAll('input[required]', { light: true }) requiredInputs?: NodeListOf<HTMLInputElement>; async attach() {} async detach() {} @on('submit') handleSubmit(event: Event) { const invalid = Array.from(this.requiredInputs || []).filter(i => !i.value.trim()); if (invalid.length > 0) { event.preventDefault(); invalid[0].focus(); if (this.errorEl) { this.errorEl.textContent = `${invalid.length} required field(s) missing`; } } } }

Advanced Patterns

Data Fetching Controller

A data-fetching controller is appropriate when the fetch is application behavior

specific to the elements it controls. Pass state through the element's public

API and dispatch outcome events — do not manipulate its rendered DOM. A

production controller should also prevent an older response from overwriting a

newer one:

import { context, type Context } from 'snice'; interface Order { id: string; total: number } interface OrdersView extends HTMLElement { loading: boolean; error: string; empty: boolean; orders: Order[]; } @controller('orders-data') export class OrdersDataController implements IController<OrdersView> { element: OrdersView | null = null; private ctx?: Context; private abortController?: AbortController; private requestVersion = 0; private receivedFirstContext = false; attach(element: OrdersView) { this.element = element; } // @context() fires on EVERY context update, not once — gate one-shot work // on the first delivery after attach(). Use `{ once: true }` instead when // the handler itself must never run twice. @context() receiveContext(ctx: Context) { this.ctx = ctx; if (this.receivedFirstContext) return; this.receivedFirstContext = true; void this.reload(); } async detach() { // Invalidates even a fetch implementation that ignores AbortSignal. this.requestVersion++; this.abortController?.abort(); this.abortController = undefined; this.ctx = undefined; this.receivedFirstContext = false; } async reload() { const host = this.element; if (!host) return; const version = ++this.requestVersion; this.abortController?.abort(); const abortController = new AbortController(); this.abortController = abortController; host.loading = true; host.error = ''; host.empty = false; try { const ctx = this.ctx; if (!ctx) throw new Error('OrdersDataController requires Router context'); const response = await ctx.fetch('/api/orders', { signal: abortController.signal }); if (!response.ok) throw new Error(`Orders request failed (${response.status})`); const orders = await response.json() as Order[]; // Abort is not enough: adapters/mocks may resolve after cancellation. if (version !== this.requestVersion || this.element !== host) return; host.orders = orders; host.empty = orders.length === 0; host.dispatchEvent(new CustomEvent('data-loaded', { detail: { orders, empty: host.empty }, bubbles: true, composed: true })); } catch (error) { if (abortController.signal.aborted) return; if (version !== this.requestVersion || this.element !== host) return; const message = error instanceof Error ? error.message : String(error); host.orders = []; host.empty = true; host.error = message; host.dispatchEvent(new CustomEvent('data-error', { detail: { message, error }, bubbles: true, composed: true })); } finally { if (version === this.requestVersion && this.element === host) { host.loading = false; } } } }

@context() works on controllers as well as elements. It receives the same

long-lived Context instance, including application, navigation state, and

the Router's middleware-aware fetch. Managed decorators are activated after

attach(), so start context-dependent work in the @context() handler (or in

an event handled later), not in attach().

@context() is a subscription, not a one-shot: the handler fires on every

context update for as long as the controller is attached. The initial "caught

up" delivery is only the first one. Guard first delivery (as the example

does), pass { once: true }, or diff the update — but never start unguarded

work in the handler body.

Observing a host property

A controller has no host-property watcher: @observe covers

Intersection/Resize/Media/Mutation only, and attachController installs none.

The working pattern is for the owning element to announce the change with

@dispatch and the controller to listen with a plain @on (direct handlers

already listen on the host element):

// On the element: @dispatch('page-filter', { bubbles: true, composed: true }) private emitFilter() { return { filter: this.filter }; } // On the controller: @on('page-filter') handleFilter(event: CustomEvent) { void this.reload(event.detail.filter); }

Carry every synchronously-written value in the event detail. A .prop

binding commits a microtask later, so reading this.element.filter inside the

handler would read the PREVIOUS value.

The element owns presentation for every state:

@element('orders-view') class OrdersViewElement extends HTMLElement implements OrdersView { @property({ attribute: false }) orders: Order[] = []; @state() loading = false; @state() error = ''; @state() empty = false; @render() template() { if (this.loading) return html`<snice-spinner label="Loading orders"></snice-spinner>`; if (this.error) return html`<snice-alert variant="error">${this.error}</snice-alert>`; if (this.empty) return html`<snice-empty-state heading="No orders"></snice-empty-state>`; return html`${this.orders.map(order => html` <order-row key=${order.id} .order=${order}></order-row> `)}`; } }

This separates responsibilities cleanly: the controller owns transport,

cancellation, stale-response protection, and outcome events; the element owns

loading/error/empty/data rendering. A retry button can request reload() via a

small event handled by the controller, or application code can retrieve the

controller and call its public method.

Theme Controller

@controller('theme-controller') class ThemeController implements IController { element: HTMLElement | null = null; async attach(element: HTMLElement) { const saved = localStorage.getItem('theme') || 'light'; element.setAttribute('data-theme', saved); } async detach(element: HTMLElement) {} @on('click', '[data-set-theme]') handleThemeToggle(event: MouseEvent) { const target = event.target as HTMLElement; const theme = target.dataset.setTheme!; this.element?.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); } }

WebSocket Controller

@controller('ws-controller') class WebSocketController implements IController { element: HTMLElement | null = null; private ws?: WebSocket; private reconnectTimer?: number; async attach(element: HTMLElement) { this.connect(); } async detach(element: HTMLElement) { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.ws?.close(); } private connect() { this.ws = new WebSocket('wss://api.example.com/ws'); this.ws.onmessage = (event) => { this.element?.dispatchEvent(new CustomEvent('ws-message', { detail: JSON.parse(event.data), bubbles: true })); }; this.ws.onclose = () => { this.reconnectTimer = setTimeout(() => this.connect(), 3000); }; } send(data: any) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } }

Accessing Controllers

Via Event

Listen for attachment on the element itself (the event does not bubble):

element.addEventListener('controller-attached', (e: CustomEvent) => { console.log('Name:', e.detail.name); // registry name, or the class name for class attaches console.log('Instance:', e.detail.controller); // IController instance });

Auto-Cleanup

The framework automatically cleans up @on handlers, observers, and @respond handlers during detach. Manual cleanup in detach() is only needed for resources you manage yourself (WebSockets, intervals, manual event listeners).

Daemons

Daemons are ordinary, explicitly constructed objects with state and an

application-owned lifecycle. The @daemon decorator gives each instance a

private communication target so elements, controllers, and daemons can use

Snice's two communication models:

@daemon does not construct, cache, globally register, start, or stop

anything.

Define and provide a daemon

import { daemon, dispatch, on, request, respond, provideContext } from 'snice'; import type { Response } from 'snice'; @daemon class SessionDaemon { session: Session | null = null; @respond('get-session') getSession() { return this.session; } @on('set-session') setSession(event: CustomEvent<Session>) { this.session = event.detail; this.sessionChanged(); } @dispatch('session-changed') sessionChanged() { return this.session; } } const session = new SessionDaemon(); const appContext = { daemons: { session } }; const release = provideContext(document.querySelector('#app')!, appContext);

The context key is the daemon's address. The class decorator takes no name,

avoiding two sources of truth.

Application contexts expose only this daemon surface to Snice:

type DaemonMap = Readonly<Record<string, object>>; interface AppContext { readonly daemons?: DaemonMap; [key: string]: unknown; }

They may still contain application-specific state such as user, theme, or

configuration. Extend AppContext with those fields to give application code

their concrete types.

Router integration

Router provides its context beneath its target before rendering a page.

No separate provideContext() call is needed:

const session = new SessionDaemon(); const router = Router({ target: '#app', type: 'hash', context: { user: null, daemons: { session } } });

provideContext(root, context) is the same public mechanism used internally

by Router. Use it for applications without Router and for isolated tests. It

returns an idempotent cleanup function.

getContext(elementOrController) returns the raw application context visible

to that participant. It is separate from the method-form @context(), which

receives Router navigation updates.

Communicate from elements and controllers

Consumers use the context address and never import the daemon implementation:

@element('session-view') class SessionView extends HTMLElement { @request<Session | null>('get-session', { daemon: 'session' }) async *loadSession(): Response<Session | null> { return yield {}; } @dispatch('set-session', { daemon: 'session' }) setSession(session: Session) { return session; } @on('session-changed', { daemon: 'session' }) sessionChanged(event: CustomEvent<Session | null>) { this.renderSession(event.detail); } }

Controllers resolve the context through their attached host element, so the

same syntax works in controller methods.

The reverse direction is also supported. A daemon's @request dispatches on

its own communication target, while an element or controller may install a

responder there:

@daemon class SessionDaemon { @request<boolean>('confirm-logout') async *confirmLogout(): Response<boolean> { return yield {}; } } class SessionView extends HTMLElement { @respond('confirm-logout', { daemon: 'session' }) confirmLogout() { return window.confirm('Log out?'); } }

As with DOM-scoped request channels, only one responder should own a daemon

request channel.

Resolution and lifecycle

Resolution has one path:

element/controller -> nearest explicitly provided application context -> context.daemons[name] -> that instance's private communication target

There is no global fallback, implicit construction, registry scan, or delayed

registration.

@on and @respond install listeners at those lifecycle boundaries.

listeners automatically.

starts with a fresh event target.

instances.

roots.

Missing contexts, missing daemon names, undecorated values, and inactive

daemon instances produce explicit errors or setup warnings. daemon and DOM

scope options are mutually exclusive. Selector delegation is not available

on a daemon target because it is not a DOM tree.

Testing

Each test owns its instance, context root, and cleanup:

const root = document.createElement('div'); document.body.appendChild(root); const session = new SessionDaemon(); const release = provideContext(root, { daemons: { session } }); const view = document.createElement('session-view'); root.appendChild(view); await view.ready; expect(await view.loadSession()).toBeNull(); root.remove(); release();

No shared singleton state or framework reset API is involved.

Routing

Creating a router, defining pages, and moving between them.

TopicDocumented in
Protecting routes, wrapping pages, page transitionsGuards and Layouts
Page metadata for navigationPlacards
Context-aware fetchFetcher

Router Setup

Creating a Router

import { Router } from 'snice'; const router = Router({ target: '#app', // Target element selector type: 'hash' // 'hash' or 'pushstate' }); // Destructure router methods const { page, initialize, navigate } = router;

Router Options

interface RouterOptions { target: string; // Target element selector type: 'hash' | 'pushstate'; // Routing type window?: Window; // Override window object (for testing) document?: Document; // Override document object (for testing) transition?: Transition; // Global transition config layout?: string; // Default layout for all pages context?: any; // App context (shared state and optional daemons) fetcher?: Fetcher; // Optional fetch middleware (see docs/fetcher.md) }

Router Context

The context object provides shared state across all pages and layouts:

// app-context.ts class AppContext { user: User | null = null; theme: 'light' | 'dark' = 'light'; setUser(user: User) { this.user = user; } getUser() { return this.user; } } // main.ts const { page, initialize } = Router({ target: '#app', type: 'hash', context: new AppContext() });

Router provides this application context beneath its target before it connects a

page. That includes explicitly constructed daemon instances:

const session = new SessionDaemon(); Router({ target: '#app', type: 'hash', context: { user: null, daemons: { session } } });

This uses the same provideContext(root, context) mechanism available to apps

without Router. Descendant elements and attached controllers address the instance

as { daemon: 'session' }; they do not import SessionDaemon. See

Daemons.

That provider boundary supplies the full Router Context to @context()

methods on descendant elements and attached controllers. They use ctx.fetch

with the configured request/response middleware without importing the router

or adding a reserved field to the application context. getContextFetch() is

the lower-level transport-only lookup for explicit non-router providers.

Page Components

Basic Page

import { render, html, styles, css } from 'snice'; import { page } from './router'; // page comes from Router(), not from 'snice' @page({ tag: 'home-page', routes: ['/'] }) class HomePage extends HTMLElement { @render() renderContent() { return html` <div class="home"> <h1>Welcome Home</h1> <nav> <a href="#/about">About</a> <a href="#/contact">Contact</a> </nav> </div> `; } @styles() homeStyles() { return css` .home { padding: 20px; text-align: center; } nav a { margin: 0 10px; color: blue; text-decoration: none; } nav a:hover { text-decoration: underline; } `; } }

Page with Context

The @context() decorator is a method decorator that receives context

updates from the router on pages, descendant elements, and attached

controllers. The method is called whenever navigation occurs, with a Context

object containing application state and navigation data.

import { context, render, html, Context } from 'snice'; import { page } from './router'; @page({ tag: 'profile-page', routes: ['/profile'] }) class ProfilePage extends HTMLElement { private appContext?: AppContext; @context() handleContextUpdate(ctx: Context) { this.appContext = ctx.application; this.requestRender(); } @render() renderContent() { const user = this.appContext?.getUser(); if (!user) { return html` <div> <p>Please log in to view your profile</p> <a href="#/login">Login</a> </div> `; } return html` <div class="profile"> <h1>Profile: ${user.name}</h1> <p>Email: ${user.email}</p> <button @click=${this.logout}>Logout</button> </div> `; } logout() { this.appContext?.setUser(null); } }

Context Options

The @context() decorator accepts optional timing and behavior controls:

@page({ tag: 'dashboard-page', routes: ['/dashboard'] }) class DashboardPage extends HTMLElement { private appContext?: AppContext; // Called immediately on every navigation @context() handleContext(ctx: Context) { this.appContext = ctx.application; this.requestRender(); } // Debounce: Wait 300ms after last update before calling @context({ debounce: 300 }) handleContextDebounced(ctx: Context) { // Useful for expensive operations this.updateExpensiveCalculation(ctx); } // Throttle: Call at most once per 100ms @context({ throttle: 100 }) handleContextThrottled(ctx: Context) { // Useful for frequent updates this.updateAnimation(ctx); } // Once: Call only once, then unregister @context({ once: true }) handleContextOnce(ctx: Context) { // Useful for one-time initialization this.initializeFromContext(ctx); } }

Context Object Structure

The Context object passed to @context() methods has the following structure:

interface Context { application: AppContext; // Your router context (e.g., { user, theme, config }) navigation: { placards: Placard[]; // All page placards route: string; // Current path (e.g. '/users/123') params: Record<string, string>; // Route parameters }; fetch: typeof globalThis.fetch; // Fetch function with middleware support update(): void; // Signal all @context subscribers of changes }

Example:

@page({ tag: 'user-page', routes: ['/users/:userId'] }) class UserPage extends HTMLElement { private ctx?: Context; @context() handleContext(ctx: Context) { this.ctx = ctx; // Access application state const currentUser = ctx.application.getUser(); // Access navigation data const userId = ctx.navigation.params.userId; const currentRoute = ctx.navigation.route; const allPlacards = ctx.navigation.placards; // Use this data this.loadUserData(userId, currentUser); } }

Triggering Context Updates

When you modify the application context, call update() to signal all subscribers:

@page({ tag: 'settings-page', routes: ['/settings'] }) class SettingsPage extends HTMLElement { private ctx?: Context; @context() handleContext(ctx: Context) { this.ctx = ctx; this.requestRender(); } changeTheme(theme: 'light' | 'dark') { // Modify the application context this.ctx!.application.theme = theme; // Signal all @context subscribers this.ctx!.update(); } }

Note: The router automatically signals @context() subscribers during navigation. Only call update() manually when changing application state outside of navigation (login, logout, theme changes, etc.).

Route Configuration

@page Decorator Options

interface PageOptions { tag: string; // Custom element tag name routes: Array<string | { // Strings are the normal form path: string; order?: number; // Lower wins on a specificity tie }>; transition?: Transition; // Page-specific transition guards?: Guard | Guard[]; // Route guards layout?: string | false; // Layout tag, or false to disable placard?: Placard | ((ctx: AppContext) => Placard); // Page metadata }

Multiple Routes

@page({ tag: 'user-page', routes: ['/user', '/users', '/profile'] }) class UserPage extends HTMLElement { @render() renderContent() { return html`<h1>User Page</h1>`; } }

Routes are sorted by specificity first. If specificity ties, registration

order wins, including the order of plain strings in one routes array. Keep

that compact syntax for normal pages:

@page({ tag: 'work-orders-page', routes: ['/work-orders?status=:status', '/work-orders'] }) class WorkOrdersPage extends HTMLElement {}

Object notation is optional. Use it only when a route needs an explicit

tie-break across registrations; lower order values match first. Equal or

omitted values still preserve registration order.

@page({ tag: 'override-page', routes: [{ path: '/:section/:item', order: -10 }] }) class OverridePage extends HTMLElement {}

Route with Parameters

@page({ tag: 'user-detail-page', routes: ['/users/:userId'] }) class UserDetailPage extends HTMLElement { @property() userId = ''; @render() renderContent() { return html` <div> <h1>User Details</h1> <p>Viewing user: ${this.userId}</p> </div> `; } }

Multiple Parameters

@page({ tag: 'post-detail-page', routes: ['/users/:userId/posts/:postId'] }) class PostDetailPage extends HTMLElement { @property() userId = ''; @property() postId = ''; @render() renderContent() { return html` <h1>Post ${this.postId} by User ${this.userId}</h1> `; } }

Navigation

Hash Navigation

// In templates html`<a href="#/about">About</a>` // Programmatic navigation navigate('/about'); // With parameters navigate('/users/123');

Pushstate Navigation

// In templates html`<a href="/about">About</a>` // Programmatic navigation using the router instance const { navigate } = Router({ target: '#app', type: 'pushstate' }); navigate('/about');

Back/Forward Navigation

// Browser back window.history.back(); // Browser forward window.history.forward(); // Go back 2 pages window.history.go(-2);

Route Parameters

Accessing Parameters

Route parameters are automatically mapped to element properties — a :param

segment or named splat such as *path (including an optional splat) normally

binds to a plain @property() of the same name. A field declared

@property({ attribute: false }) is opted OUT of route-param binding: the

Router never sets it and it silently keeps its initializer. (The Router sets

params through attributes; attribute: false fields have none.)

The parameter spelling must reach the property's observed attribute. On an

HTMLElement page, :articleId reaches a plain articleId property through

the lowercased articleid attribute. A SniceElement page uses kebab-case

implicit attributes, so its plain articleId property needs :article-id (or

an explicit @property({ attribute: 'articleId' })). Explicit aliases follow

the same rule: @property({ attribute: 'article-id' }) articleId binds from

:article-id, not :articleId.

A reflected native HTMLElement attribute such as id is already reachable

when no Snice property overrides it. An explicit

@property({ attribute: false }) id or differently aliased id overrides that

native channel, so :id no longer populates the property. A statically declared

observedAttributes entry paired with attributeChangedCallback can also

consume a route attribute.

Inheritance follows Snice's decorator transformation: a subclass @state()

member disables an inherited @property() channel of the same name, while a

plain field initializer or authored accessor still uses the inherited

transformed property accessor and remains bindable.

@page({ tag: 'article-page', routes: ['/articles/:articleId'] }) class ArticlePage extends HTMLElement { @property() articleId = ''; @ready() async loadArticle() { // articleId is automatically set from URL const article = await fetch(`/api/articles/${this.articleId}`); this.article = await article.json(); } @render() renderContent() { return html`<h1>Article ${this.articleId}</h1>`; } }

Multiple Parameters

@page({ tag: 'comment-page', routes: ['/posts/:postId/comments/:commentId'] }) class CommentPage extends HTMLElement { @property() postId = ''; @property() commentId = ''; @ready() async loadData() { // Both postId and commentId are set from URL const [post, comment] = await Promise.all([ fetch(`/api/posts/${this.postId}`).then(r => r.json()), fetch(`/api/comments/${this.commentId}`).then(r => r.json()) ]); this.post = post; this.comment = comment; } @render() renderContent() { return html` <div> <h2>Comment on Post ${this.postId}</h2> <p>Comment ID: ${this.commentId}</p> </div> `; } }

Query Parameters

Define query parameters directly in the route pattern — they are extracted as route params automatically:

@page({ tag: 'search-page', routes: ['/search?q=:query'] }) class SearchPage extends HTMLElement { @property() query = ''; @context() handleContext(ctx: Context) { this.query = ctx.navigation.params.query || ''; } @render() renderContent() { return html` <div> <h1>Search Results for: ${this.query}</h1> </div> `; } }

Advanced Patterns

Lazy Loading Pages

@page({ tag: 'lazy-page', routes: ['/lazy'] }) class LazyPage extends HTMLElement { @property({ type: Boolean }) loaded = false; @ready() async loadContent() { // Simulate loading external content await new Promise(resolve => setTimeout(resolve, 1000)); // Dynamically import module const module = await import('./lazy-content.js'); module.initialize(this); this.loaded = true; } @render() renderContent() { if (!this.loaded) { return html`<div>Loading...</div>`; } return html`<div>Loaded content</div>`; } }

Nested Routing

// Parent page with sub-navigation @page({ tag: 'settings-page', routes: ['/settings', '/settings/:section'] }) class SettingsPage extends HTMLElement { @property() section = 'general'; @render() renderContent() { return html` <div class="settings"> <nav> <a href="#/settings/general">General</a> <a href="#/settings/privacy">Privacy</a> <a href="#/settings/security">Security</a> </nav> <div class="content"> <case ${this.section}> <when value="general"> <div>General settings</div> </when> <when value="privacy"> <div>Privacy settings</div> </when> <when value="security"> <div>Security settings</div> </when> <default> <div>Unknown section</div> </default> </case> </div> </div> `; } }

Route-Based Data Loading

@page({ tag: 'product-page', routes: ['/products/:productId'] }) class ProductPage extends HTMLElement { @property() productId = ''; @property() product: any = null; @property({ type: Boolean }) loading = true; @ready() loadProduct() { this.fetchProduct(); } @watch('productId') onProductIdChange() { // Reload when productId changes this.fetchProduct(); } async fetchProduct() { this.loading = true; try { const response = await fetch(`/api/products/${this.productId}`); this.product = await response.json(); } catch (error) { console.error('Failed to load product:', error); } finally { this.loading = false; } } @render() renderContent() { if (this.loading) { return html`<div>Loading product...</div>`; } if (!this.product) { return html`<div>Product not found</div>`; } return html` <div class="product"> <h1>${this.product.name}</h1> <p>${this.product.description}</p> <span class="price">$${this.product.price}</span> </div> `; } }

Breadcrumb Navigation

@page({ tag: 'breadcrumb-page', routes: ['/categories/:category/products/:productId'], placard: { name: 'product-detail', title: 'Product Details', breadcrumbs: ['home', 'categories', 'products', 'product-detail'] } }) class BreadcrumbPage extends HTMLElement { @property() category = ''; @property() productId = ''; @render() renderContent() { return html` <nav class="breadcrumbs"> <a href="#/">Home</a> <span>/</span> <a href="#/categories">Categories</a> <span>/</span> <a href="#/categories/${this.category}"> ${this.category} </a> <span>/</span> <span>${this.productId}</span> </nav> <div class="content"> <h1>Product ${this.productId} in ${this.category}</h1> </div> `; } }

Error Page (404)

@page({ tag: 'not-found-page', routes: ['/404', '*'] // Catch-all route }) class NotFoundPage extends HTMLElement { @render() renderContent() { return html` <div> <h1>404 - Page Not Found</h1> <a href="#/">Go Home</a> </div> `; } }

Protected Route Pattern

// Context with auth state class AppContext { private user: User | null = null; setUser(user: User | null) { this.user = user; } getUser() { return this.user; } isAuthenticated() { return this.user !== null; } } // Auth guard — redirect to login if not authenticated const isAuthenticated: Guard<AppContext> = (ctx, params) => { if (!ctx.isAuthenticated()) { window.location.hash = '#/login'; return false; } return true; }; // Protected page @page({ tag: 'dashboard-page', routes: ['/dashboard'], guards: isAuthenticated }) class DashboardPage extends HTMLElement { private appContext?: AppContext; @context() handleContext(ctx: Context) { this.appContext = ctx.application; this.requestRender(); } @render() renderContent() { const user = this.appContext?.getUser(); return html` <div> <h1>Welcome, ${user?.name}!</h1> <p>This is your dashboard</p> </div> `; } } // Login page @page({ tag: 'login-page', routes: ['/login'] }) class LoginPage extends HTMLElement { private appContext?: AppContext; @context() handleContext(ctx: Context) { this.appContext = ctx.application; } @render() renderContent() { return html` <form @submit=${this.handleLogin}> <input type="text" name="username" placeholder="Username" required> <input type="password" name="password" placeholder="Password" required> <button type="submit">Login</button> </form> `; } handleLogin(e: Event) { e.preventDefault(); const form = e.target as HTMLFormElement; const formData = new FormData(form); // Simulate login const user = { id: 1, name: formData.get('username') as string }; this.appContext?.setUser(user); // Redirect to dashboard window.location.hash = '#/dashboard'; } }

Router API Reference

Router()

function Router(options: RouterOptions): { page: (pageOptions: PageOptions) => ClassDecorator; initialize: () => void; navigate: (path: string) => Promise<void>; register: (route: string, tag: string, transition?: Transition, guards?: Guard | Guard[], layout?: string | false, placard?: Placard | ((ctx: AppContext) => Placard), order?: number) => void; }

navigate()

navigate(path: string): Promise<void>

Navigates to the specified path. Uses hash (#) or pushstate depending on router type.

Do not combine @page with @element on the same class. @page already

registers the custom element and applies Snice element behavior.

initialize()

initialize(): void

Initializes the router and starts listening for route changes. Must be called after all pages are defined.

register()

register( route: string, tag: string, transition?: Transition, guards?: Guard | Guard[], layout?: string | false, placard?: Placard | ((ctx: AppContext) => Placard) ): void

Manually register a route without using the @page decorator.

Guards and Layouts

Deciding whether a route may render, what wraps it, and how it animates in.

The router itself is covered in Routing.

Route Guards

Guards protect routes and can redirect unauthorized access:

Basic Guard

import { Guard } from 'snice'; const isAuthenticated: Guard<AppContext> = (ctx, params) => { return ctx.getUser() !== null; }; @page({ tag: 'dashboard-page', routes: ['/dashboard'], guards: isAuthenticated }) class DashboardPage extends HTMLElement { @render() renderContent() { return html`<h1>Dashboard</h1>`; } }

Multiple Guards

const hasAdminRole: Guard<AppContext> = (ctx, params) => { const user = ctx.getUser(); return user?.role === 'admin'; }; @page({ tag: 'admin-page', routes: ['/admin'], guards: [isAuthenticated, hasAdminRole] }) class AdminPage extends HTMLElement { @render() renderContent() { return html`<h1>Admin Dashboard</h1>`; } }

Guard with Redirect

When a guard returns false, the router renders the /403 page (if registered) or a default 403 message. Guards can also trigger side effects like redirects:

const isAuthenticated: Guard<AppContext> = (ctx, params) => { if (!ctx.getUser()) { window.location.hash = '#/login'; return false; } return true; };

Permission Guard

Guards are synchronous — pre-load permissions into context before navigating:

const hasAdminAccess: Guard<AppContext> = (ctx, params) => { const user = ctx.getUser(); return user?.role === 'admin'; };

Layouts

Layouts wrap pages with shared UI like headers, footers, and navigation:

Creating a Layout

import { layout, render, html, styles, css, Layout } from 'snice'; @layout('app-shell') class AppShell extends HTMLElement implements Layout { private placards: Placard[] = []; private currentRoute = ''; @render() renderContent() { return html` <div class="app-shell"> <header> <h1>My App</h1> <nav> ${this.placards .filter(p => p.show !== false) .map(p => html` <a href="${p.href || ''}" class="${this.currentRoute === p.name ? 'active' : ''}" > ${p.icon || ''} ${p.title} </a> `)} </nav> </header> <main> <slot name="page"></slot> </main> <footer> <p>&copy; 2024 My App</p> </footer> </div> `; } @styles() shellStyles() { return css` .app-shell { display: flex; flex-direction: column; min-height: 100vh; } header { background: #333; color: white; padding: 1rem; } nav a { color: white; margin: 0 1rem; text-decoration: none; } nav a.active { font-weight: bold; text-decoration: underline; } main { flex: 1; padding: 2rem; } footer { background: #f0f0f0; padding: 1rem; text-align: center; } `; } // Called by router when route changes update(appContext: any, placards: Placard[], currentRoute: string, routeParams: any) { this.placards = placards; this.currentRoute = currentRoute; // Property changes trigger re-render } }

Using a Layout

const router = Router({ target: '#app', type: 'hash', layout: 'app-shell', // Layout tag name context: new AppContext() });

Layout Interface

interface Layout { update( appContext: any, placards: Placard[], currentRoute: string, routeParams: Record<string, string> ): void; }

Conditional Layout

Different pages can use different layouts or no layout:

// Router with default layout const router = Router({ target: '#app', layout: 'app-shell' }); // Page without layout @page({ tag: 'fullscreen-page', routes: ['/fullscreen'], layout: false // Disable layout for this page }) class FullscreenPage extends HTMLElement { @render() renderContent() { return html`<div>Fullscreen content</div>`; } }

Page Transitions

Global Transitions

import { fadeTransition } from 'snice/transitions'; const router = Router({ target: '#app', type: 'hash', transition: fadeTransition });

Page-Specific Transitions

import { slideTransition } from 'snice/transitions'; @page({ tag: 'about-page', routes: ['/about'], transition: slideTransition }) class AboutPage extends HTMLElement { @render() renderContent() { return html`<h1>About</h1>`; } }

Built-in Transitions

import { fadeTransition, slideTransition, slideRightTransition, slideUpTransition, slideDownTransition, scaleTransition, rotateTransition, flipTransition, zoomTransition, noneTransition } from 'snice/transitions';

Custom Transitions

Transitions use inline CSS property strings for out (leaving) and in (entering) states:

import { Transition } from 'snice/transitions'; const customTransition: Transition = { name: 'custom', outDuration: 300, inDuration: 300, out: 'opacity: 0; transform: translateY(-20px)', in: 'opacity: 1; transform: translateY(0)', mode: 'sequential' // or 'simultaneous' };

The out string is the end state of the leaving page. The in string is the end state of the entering page (which starts invisible and transitions to this state).

Request/Response API Documentation

Request/Response provides request/response communication between elements,

controllers, and explicitly provided daemons using async generators.

Why Request/Response?

Components are generic. A <product-card> renders a card — it doesn't know or care whether its data comes from a REST API, a GraphQL endpoint, a WebSocket, or a test fixture. Controllers are specific. They wire a particular data source, API, or business rule to a generic component.

The @request/@respond pattern keeps this separation clean:

This makes components reusable across projects and testable in isolation. An element with @request('fetch-product') works with any controller that @responds to 'fetch-product' — no imports, no interfaces, no coupling.

Basic Concept

Request/Response enables a single request/response round-trip between elements and their controllers:

  1. Element yields a request payload — "here's what I need"
  2. Controller receives the payload and returns a response — "here's the data"
  3. Element receives the response and updates its visual state

This pattern is implemented using async generators and custom events. Each @request method supports one yield per invocation — the generator yields a request payload, the controller responds, and the generator receives the response.

Request/Response Decorators

Signature

function request(requestName: string, options?: RequestOptions): MethodDecorator function respond(requestName: string, options?: RespondOptions): MethodDecorator interface RequestOptions extends EventInit { daemon?: string; // Named daemon from nearest provided app context timeout?: number; // Response timeout in ms (default: 120000ms = 2 minutes) discoveryTimeout?: number; // Handler discovery timeout in ms (default: 50ms) debounce?: number; // Debounce requests by specified ms throttle?: number; // Throttle requests by specified ms // Note: `composed` is always forced to `true` (crosses shadow DOM boundaries) // `bubbles` defaults to true, `cancelable` defaults to false } interface RespondOptions { daemon?: string; // Install responder on named daemon target debounce?: number; // Debounce responses by specified ms throttle?: number; // Throttle responses by specified ms } // Public return type for methods decorated with @request: type Response<T = any> = T | any;

TypeScript cannot model a method decorator changing an async generator into a

promise-returning method. Response<T> is the pragmatic annotation for that

boundary: it keeps strict projects usable while documenting the response value

for readers and tooling. At runtime, calling the decorated method returns a

promise for T.

Response Debounce/Throttle

Response handlers can be debounced or throttled:

@controller('processing-controller') class ProcessingController implements IController { element: HTMLElement | null = null; private ctx!: Context; async attach() {} async detach() {} @context() receiveContext(ctx: Context) { this.ctx = ctx; } @respond('search', { debounce: 300 }) async handleSearch(query: { term: string }) { return await this.ctx.fetch(`/api/search?q=${encodeURIComponent(query.term)}`).then(r => r.json()); } @respond('analytics', { throttle: 1000 }) async handleAnalytics(event: any) { return await this.ctx.fetch('/api/track', { method: 'POST', body: JSON.stringify(event) }); } }

Element-Side Requests

Elements use async generators to make requests. The element stays visual — it yields data up to the controller and renders the response:

import { element, request, property, render, html } from 'snice'; import type { Response } from 'snice'; @element('product-card') class ProductCard extends HTMLElement { @property() productId = ''; @property() name = ''; @property() price = ''; @request('fetch-product') async *loadProduct(): Response<void> { const product = await (yield { id: this.productId }); this.name = product.name; this.price = product.price; } @render() renderContent() { return html` <div class="card"> <h3>${this.name || 'Loading...'}</h3> <p>${this.price}</p> <button @click=${this.loadProduct}>Refresh</button> </div> `; } }

How it works:

  1. yield { id: this.productId } dispatches a bubbling custom event with the payload
  2. A @respond('fetch-product') handler (typically in a controller) catches it and returns data
  3. await (yield ...) resolves with the response
  4. The element updates its properties, triggering a re-render

Controller-Side Responses

Controllers handle requests — this is where business logic, API calls, and data management belong:

import { context, controller, respond, IController, type Context } from 'snice'; @controller('product-controller') class ProductController implements IController { element: HTMLElement | null = null; private ctx!: Context; async attach() {} async detach() {} @context() receiveContext(ctx: Context) { this.ctx = ctx; } @respond('fetch-product') async handleFetchProduct(request: { id: string }) { const response = await this.ctx.fetch(`/api/products/${request.id}`); return await response.json(); } }

Architecture: Elements stay visual and yield requests upward. A controller

owns application behavior specific to the elements it controls; the page still

owns element orchestration.

Request/Response Options

Timeout Behavior

The timeout system has two separate timeouts:

@request('heavy-computation', { discoveryTimeout: 50, // 50ms to find handler timeout: 30000 // 30s for actual processing }) async *compute(): Response<any> { return await (yield data); }

Debounce/Throttle

// Debounce: wait for typing to stop before searching @request('search', { debounce: 300 }) async *search(): Response<any[]> { return await (yield { query: this.searchTerm }); } // Throttle: limit analytics to 1 per second @request('track', { throttle: 1000 }) async *trackEvent(): Response<void> { await (yield { event: 'scroll', position: window.scrollY }); }

Error Handling

Element-Side

@element('safe-loader') class SafeLoader extends HTMLElement { @property() error = ''; @property() data: any = null; @request('load-data', { timeout: 5000 }) async *loadData(): Response<void> { try { this.data = await (yield { id: this.dataId }); this.error = ''; } catch (err: any) { if (err.message.includes('no handler found')) { this.error = 'Service unavailable'; } else if (err.message.includes('timed out')) { this.error = 'Request timed out'; } else { this.error = err.message; } } } @render() renderContent() { return html` <if ${this.error}> <div class="error">${this.error}</div> </if> <if ${this.data}> <div class="content">${this.data.title}</div> </if> `; } }

Controller-Side

@controller('resilient-controller') class ResilientController implements IController { element: HTMLElement | null = null; private ctx!: Context; async attach() {} async detach() {} @context() receiveContext(ctx: Context) { this.ctx = ctx; } @respond('load-data') async handleLoadData(request: { id: string }) { if (!request.id) { throw new Error('ID is required'); } const response = await this.ctx.fetch(`/api/data/${request.id}`); if (!response.ok) { throw new Error(`API error: ${response.status}`); } return await response.json(); } }

Advanced Patterns

Cached Responses

@controller('cached-controller') class CachedController implements IController { element: HTMLElement | null = null; private ctx!: Context; private cache = new Map<string, { data: any; timestamp: number }>(); private ttl = 60000; // 1 minute async attach() {} async detach() {} @context() receiveContext(ctx: Context) { this.ctx = ctx; } @respond('fetch-cached') async handleFetch(request: { key: string; forceRefresh?: boolean }) { const cached = this.cache.get(request.key); if (!request.forceRefresh && cached && Date.now() - cached.timestamp < this.ttl) { return { data: cached.data, fromCache: true }; } const data = await this.ctx.fetch(`/api/${request.key}`).then(r => r.json()); this.cache.set(request.key, { data, timestamp: Date.now() }); return { data, fromCache: false }; } }

Subscription Pattern

Use @request for one-time fetches and @dispatch + @on for ongoing updates:

// Element: visual, subscribes to updates @element('live-ticker') class LiveTicker extends HTMLElement { @property() price = '0.00'; @property() symbol = 'BTC'; @request('subscribe-ticker') async *subscribe(): Response<void> { await (yield { symbol: this.symbol }); } @on('ticker-update') onUpdate(e: CustomEvent) { this.price = e.detail.price; } @render() renderContent() { return html` <span class="symbol">${this.symbol}</span> <span class="price">${this.price}</span> `; } } // Controller: manages WebSocket, dispatches updates @controller('ticker-controller') class TickerController implements IController { element: HTMLElement | null = null; private ws?: WebSocket; async attach() {} async detach() { this.ws?.close(); } @respond('subscribe-ticker') async handleSubscribe(request: { symbol: string }) { this.ws = new WebSocket(`wss://api.example.com/ticker/${request.symbol}`); this.ws.onmessage = (msg) => { this.element?.dispatchEvent(new CustomEvent('ticker-update', { detail: JSON.parse(msg.data) })); }; return { subscribed: true }; } }

Using Without Decorators

For vanilla JS or React code that needs to respond to @request channels without using the decorator system.

Vanilla JS: createRequestHandler

import { createRequestHandler } from 'snice'; // Attach handlers to any DOM target (events bubble, so ancestors work) const cleanup = createRequestHandler(document.getElementById('app'), { 'fetch-user': async (payload) => { const res = await fetch(`/api/users/${payload.id}`); return res.json(); }, 'save-settings': async (payload) => { await fetch('/api/settings', { method: 'POST', body: JSON.stringify(payload) }); return { ok: true }; } }); // Global handler (catches all bubbling requests) const globalCleanup = createRequestHandler(document, { 'fetch-user': async (payload) => ({ name: 'Jane', id: payload.id }), }); // Remove all listeners when done cleanup();

Options:

OptionTypeDefaultDescription
passivebooleanfalseWhen true, doesn't stop event propagation (allows multiple handlers)

React: useRequestHandler

For full documentation, examples, options, and global handler patterns, see the React Integration guide.

import { useRequestHandler } from 'snice/react'; function Dashboard() { const ref = useRef<HTMLDivElement>(null); useRequestHandler(ref, { 'fetch-user': async (payload) => { const res = await fetch(`/api/users/${payload.id}`); return res.json(); }, }); return ( <div ref={ref}> <snice-user-card /> </div> ); }

Route callbacks are ref-stable — no useCallback needed. Listeners re-attach only when channel names change. Cleanup is automatic on unmount.

Observe API Documentation

The @observe decorator provides lifecycle-managed observation of external changes like viewport intersection, element resize, media queries, and DOM mutations.

Overview

The @observe decorator automatically manages browser observers with proper cleanup, preventing memory leaks and simplifying complex observation patterns.

import { element, observe, render, html } from 'snice'; @element('lazy-image') class LazyImage extends HTMLElement { @observe('intersection', 'img') loadImage(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { const img = entry.target as HTMLImageElement; img.src = img.dataset.src!; return false; // Stop observing this element } } @render() renderContent() { return html`<img data-src="image.jpg" />`; } }

Array Syntax

You can observe multiple types with a single handler using array syntax:

@element('dynamic-content') class DynamicContent extends HTMLElement { @render() renderContent() { return html`<div class="content" data-state="initial">Content</div>`; } // Watch for both child changes and attribute changes @observe(['mutation:childList', 'mutation:attributes'], '.content') handleContentChange(mutations: MutationRecord[]) { mutations.forEach(m => { if (m.type === 'childList') { console.log('Children changed'); } else if (m.type === 'attributes') { console.log('Attributes changed'); } }); } // Multiple media queries with one handler @observe(['media:(max-width: 768px)', 'media:(prefers-color-scheme: dark)']) handleResponsiveTheme(matches: boolean) { // Called for each media query independently this.updateLayout(); } updateLayout() { // Update logic } }

Intersection Observer

Detect when elements enter or leave the viewport. Perfect for lazy loading, infinite scroll, and animations.

Basic Usage

@element('scroll-trigger') class ScrollTrigger extends HTMLElement { @render() renderContent() { return html` <div class="content">Scroll to see me</div> <img class="lazy" data-src="image.jpg" /> `; } // Observe when element becomes visible @observe('intersection') handleVisible(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { this.classList.add('visible'); } } // Observe specific element with threshold @observe('intersection', '.lazy', { threshold: 0.1 }) loadImage(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { const img = entry.target as HTMLImageElement; img.src = img.dataset.src!; return false; // Stop observing after loading } } }

Options

Stopping Observation

Return false from the handler to stop observing that specific element:

@observe('intersection', '.item') handleItemVisible(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { this.animateItem(entry.target); return false; // Don't observe this item anymore } } animateItem(target: Element) { // Animation logic }

Resize Observer

Monitor element size changes for responsive components.

Basic Usage

@element('responsive-chart') class ResponsiveChart extends HTMLElement { @render() renderContent() { return html`<canvas class="chart"></canvas>`; } // Observe host element resize @observe('resize') handleResize(entry: ResizeObserverEntry) { const { width, height } = entry.contentRect; this.redrawChart(width, height); } // Observe specific element with throttling @observe('resize', '.chart', { throttle: 100 }) handleChartResize(entry: ResizeObserverEntry) { this.updateChartDimensions(entry.contentRect); } redrawChart(width: number, height: number) { // Chart redraw logic } updateChartDimensions(rect: DOMRectReadOnly) { // Update logic } }

Options

Media Query Observer

Respond to viewport and user preference changes.

Basic Usage

@element('responsive-layout') class ResponsiveLayout extends HTMLElement { isDesktop = false; isDarkMode = false; @render() renderContent() { return html`<div class="layout">Content</div>`; } // Desktop breakpoint @observe('media:(min-width: 768px)') handleDesktop(matches: boolean) { this.isDesktop = matches; this.updateLayout(); } // Dark mode preference @observe('media:(prefers-color-scheme: dark)') handleDarkMode(matches: boolean) { this.isDarkMode = matches; this.updateTheme(); } // Portrait orientation on mobile @observe('media:(orientation: portrait) and (max-width: 768px)') handleMobilePortrait(matches: boolean) { if (matches) { this.adjustForMobilePortrait(); } } updateLayout() { // Layout update logic } updateTheme() { // Theme update logic } adjustForMobilePortrait() { // Adjustment logic } }

Important Notes

Mutation Observer

Watch for DOM changes like added/removed nodes or attribute modifications.

Basic Usage

@element('dynamic-list') class DynamicList extends HTMLElement { @render() renderContent() { return html` <ul class="list"></ul> <div class="count">0 items</div> `; } // Watch for child list changes @observe('mutation:childList', '.list') handleListChange(mutations: MutationRecord[]) { const count = this.querySelector('.list')?.children.length || 0; this.updateCount(count); } // Watch for specific attribute changes @observe('mutation:attributes:data-state', '.item') handleStateChange(mutations: MutationRecord[]) { const mutation = mutations[0]; const newState = (mutation.target as Element).getAttribute('data-state'); this.updateItemDisplay(mutation.target, newState); } updateCount(count: number) { const countDiv = this.querySelector('.count'); if (countDiv) countDiv.textContent = `${count} items`; } updateItemDisplay(target: Node, newState: string | null) { // Update display logic } }

Mutation Types

Options

Safety Features

Using with Controllers

Controllers can also use @observe for separation of concerns. When used in controllers, observers operate on the attached element:

@controller('viewport-controller') class ViewportController implements IController { element: HTMLElement | null = null; @observe('media:(min-width: 1024px)') handleLargeScreen(matches: boolean) { if (this.element) { this.element.classList.toggle('large-screen', matches); } } @observe('intersection', { threshold: 0.5 }) handleVisibility(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { this.trackImpression(); } } async attach(element: HTMLElement) { this.element = element; } async detach(element: HTMLElement) { this.element = null; // Observers are automatically cleaned up } trackImpression() { // Analytics tracking } }

Options

All observer types share a single options interface. Pass only the fields relevant to the observer type you're using:

interface ObserveOptions { // Intersection Observer threshold?: number | number[]; // 0-1 visibility percentage rootMargin?: string; // Margin around root root?: Element | null; // Viewport element // Resize Observer box?: 'content-box' | 'border-box'; // Box model to observe // Mutation Observer subtree?: boolean; // Observe descendants (use with caution) maxDepth?: number; // Safety limit for subtree depth // All observers throttle?: number; // Throttle callbacks by milliseconds }

Best Practices

1. Be Specific

// Good - specific selector and threshold @observe('intersection', '.lazy-image', { threshold: 0.1 }) // Bad - observing everything @observe('intersection', '*')

2. Use Throttling for High-Frequency Events

// Good - throttled resize observer @observe('resize', { throttle: 100 }) handleResize(entry: ResizeObserverEntry) { this.expensiveOperation(); } // Bad - unthrottled resize can fire many times per second @observe('resize') handleResize(entry: ResizeObserverEntry) { this.expensiveOperation(); } expensiveOperation() { // Expensive logic }

3. Stop Observing When Done

@observe('intersection', '.load-more') handleLoadMore(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { this.loadMoreContent(); return false; // Stop observing after trigger } } loadMoreContent() { // Load more logic }

4. Avoid Deep Subtree Observation

// Bad - observing entire subtree @observe('mutation:childList', { subtree: true }) // Good - observe specific container @observe('mutation:childList', '.list-container')

5. Use Media Queries for Responsive Design

// Good - declarative responsive behavior @observe('media:(min-width: 768px)') handleDesktop(matches: boolean) { this.layout = matches ? 'desktop' : 'mobile'; } // Avoid - manual window resize listening // window.addEventListener('resize', () => { // if (window.innerWidth >= 768) { /* ... */ } // });

Lifecycle and Cleanup

All observers are automatically:

Disconnecting stops more than the observer itself. A throttled callback holds

a timer for its trailing edge, and that timer is cancelled on disconnect too —

so a callback queued moments before removal never runs against the disposed

element. A disconnected element schedules nothing.

No manual cleanup is required - the framework handles everything:

@element('auto-cleanup') class AutoCleanup extends HTMLElement { // All observers are automatically managed @observe('intersection') handleIntersection(entry: IntersectionObserverEntry) { } @observe('resize') handleResize(entry: ResizeObserverEntry) { } @observe('media:(min-width: 768px)') handleMedia(matches: boolean) { } // No cleanup code needed! @render() renderContent() { return html`<div>Auto-cleanup content</div>`; } }

Performance Considerations

  1. Browser Support: Observers check for API availability and warn if unsupported
  2. Shared Observers: Media queries are cached globally
  3. Automatic Throttling: Built-in throttle option prevents callback flooding
  4. Memory Management: Proper cleanup prevents memory leaks
  5. Error Isolation: Errors in one observer don't affect others

Examples

Virtual Scrolling

@element('virtual-list') class VirtualList extends HTMLElement { visibleItems = new Set<Element>(); @render() renderContent() { return html`<div class="viewport"></div>`; } @observe('intersection', '.item', { rootMargin: '100px' }) handleItemVisibility(entry: IntersectionObserverEntry) { if (entry.isIntersecting) { this.visibleItems.add(entry.target); this.renderItem(entry.target); } else { this.visibleItems.delete(entry.target); this.unrenderItem(entry.target); } } renderItem(target: Element) { // Render logic } unrenderItem(target: Element) { // Unrender logic } }

Responsive Dashboard

@element('dashboard') class Dashboard extends HTMLElement { columns = 1; @observe('media:(min-width: 768px)') handleTablet(matches: boolean) { this.columns = matches ? 2 : 1; } @observe('media:(min-width: 1024px)') handleDesktop(matches: boolean) { this.columns = matches ? 3 : this.columns; } @observe('resize', { throttle: 200 }) handleResize(entry: ResizeObserverEntry) { this.adjustCardSizes(entry.contentRect.width); } @render() renderContent() { return html`<div class="dashboard">Dashboard with ${this.columns} columns</div>`; } adjustCardSizes(width: number) { // Adjust logic } }

Dynamic Form Fields

@element('dynamic-form') class DynamicForm extends HTMLElement { @observe('mutation:childList', '.dynamic-fields') handleFieldsAdded(mutations: MutationRecord[]) { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { this.initializeField(node as Element); } }); }); } @render() renderContent() { return html` <form> <div class="dynamic-fields"></div> </form> `; } initializeField(field: Element) { // Initialize logic } }

Fetch Middleware

Snice provides a context-aware fetch implementation with middleware support, allowing you to intercept and modify HTTP requests and responses globally across your application.

Overview

The ContextAwareFetcher class enables you to:

Basic Usage

import { Router, ContextAwareFetcher } from 'snice'; // Create a fetcher instance const fetcher = new ContextAwareFetcher(); // Attach JWT to outgoing requests fetcher.use('request', function(request, next) { // `this` is bound to the Context instance const jwt = this.application.principal?.token; if (jwt) { request.headers.set('Authorization', `Bearer ${jwt}`); } return next(); }); // Add response middleware (runs after fetch) fetcher.use('response', async function(response, next) { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return next(); }); // Pass fetcher to Router const router = Router({ target: '#app', context: { auth: null }, fetcher }); router.initialize();

Pages, descendant elements, and attached controllers receive the Router's

long-lived Context through @context(); its ctx.fetch is the bound,

middleware-aware function. getContextFetch(this) remains the lower-level

lookup for code using an explicit non-router

provideContext(root, appContext, { fetch }) boundary.

Using Context.fetch in Pages, Elements, and Controllers

Once configured, ctx.fetch is available in all pages and components that use the @context decorator:

@page({ tag: 'user-page', routes: ['/users/:id'] }) class UserPage extends HTMLElement { private ctx: Context; @context() handleContext(ctx: Context) { this.ctx = ctx; } @ready() async loadUser() { try { // Middleware is automatically applied const user = await this.ctx.fetch('/api/users/123') .then(r => r.json()); console.log('User loaded:', user); } catch (error) { console.error('Failed to load user:', error); } } }

Controllers use the same decorator. Managed decorators are activated after

attach(), so start context-dependent work from the handler:

@controller('user-data') class UserDataController implements IController<HTMLElement> { element: HTMLElement | null = null; private ctx?: Context; attach(element: HTMLElement) { this.element = element; } detach() { this.ctx = undefined; } @context() receiveContext(ctx: Context) { this.ctx = ctx; void this.load(); } async load() { const response = await this.ctx!.fetch('/api/users'); // Commit through the host's public API. } }

Middleware Types

Request Middleware

Request middleware runs before the actual fetch call. It receives the Request object and can modify it before the request is sent.

Signature:

type RequestMiddleware = ( this: Context, request: Request, next: () => Promise<Response> ) => Promise<Response>

Common use cases:

Example - JWT Bearer Token:

this in middleware is the Context instance. Store auth state in application (your AppContext):

// Attach JWT to every request fetcher.use('request', function(request, next) { const jwt = this.application.principal?.token; if (jwt) { request.headers.set('Authorization', `Bearer ${jwt}`); } return next(); }); // Handle expired tokens — refresh or redirect to login fetcher.use('response', async function(response, next) { if (response.status === 401) { const refreshToken = this.application.principal?.refreshToken; if (refreshToken) { const res = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }) }); if (res.ok) { const { token } = await res.json(); this.application.principal.token = token; // Retry original request with new token const retry = new Request(response.url, { headers: { 'Authorization': `Bearer ${token}` } }); return fetch(retry); } } this.application.principal = null; window.location.hash = '#/login'; throw new Error('Session expired'); } return next(); });

Example - Request Logging:

fetcher.use('request', function(request, next) { console.log(`[${this.navigation.route}] ${request.method} ${request.url}`); return next(); });

Response Middleware

Response middleware runs after the fetch call completes. It receives the Response object and can inspect or transform it.

Signature:

type ResponseMiddleware = ( this: Context, response: Response, next: () => Promise<Response> ) => Promise<Response>

Common use cases:

Example - Error Handling:

fetcher.use('response', async function(response, next) { if (!response.ok) { const error = await response.text(); console.error(`[${this.navigation.route}] HTTP ${response.status}:`, error); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return next(); });

Example - Response Logging:

fetcher.use('response', async function(response, next) { console.log(`[${this.navigation.route}] Response ${response.status} from ${response.url}`); return next(); });

Example - Performance Metrics:

const timings = new Map<string, number>(); fetcher.use('request', function(request, next) { timings.set(request.url, Date.now()); return next(); }); fetcher.use('response', async function(response, next) { const start = timings.get(response.url); if (start) { console.log(`${response.url} took ${Date.now() - start}ms`); timings.delete(response.url); } return next(); });

Accessing Context

Middleware functions have this bound to the Context instance, giving you access to:

Example - Context-Aware Error Handling:

fetcher.use('response', async function(response, next) { if (response.status === 401) { // Unauthorized - clear user and redirect to login this.application.user = null; window.location.hash = '#/login'; throw new Error('Authentication required'); } if (response.status === 403) { // Forbidden - log and show error console.error(`Access denied on route: ${this.navigation.route}`); throw new Error('Access forbidden'); } return next(); });

Middleware Execution Order

Middleware executes in the order it's registered:

  1. Request middleware runs in registration order (first registered = first executed)
  2. Actual fetch() call happens
  3. Response middleware runs in registration order

Example:

const fetcher = new ContextAwareFetcher(); fetcher.use('request', function(request, next) { console.log('Request middleware 1'); return next(); }); fetcher.use('request', function(request, next) { console.log('Request middleware 2'); return next(); }); fetcher.use('response', async function(response, next) { console.log('Response middleware 1'); return next(); }); fetcher.use('response', async function(response, next) { console.log('Response middleware 2'); return next(); }); // Output when fetch is called: // Request middleware 1 // Request middleware 2 // (actual fetch happens) // Response middleware 1 // Response middleware 2

Complete Example

Here's a complete example with authentication, error handling, and logging:

import { Router, ContextAwareFetcher } from 'snice'; interface AppContext { auth?: { token: string; // JWT access token refreshToken: string; userId: string; }; } const fetcher = new ContextAwareFetcher(); // Attach JWT to every request fetcher.use('request', function(request, next) { const jwt = this.application.principal?.token; if (jwt) { request.headers.set('Authorization', `Bearer ${jwt}`); } return next(); }); // Log all requests fetcher.use('request', function(request, next) { const route = this.navigation.route; console.log(`[${route}] ${request.method} ${request.url}`); return next(); }); // Handle 401 — attempt token refresh, then redirect on failure fetcher.use('response', async function(response, next) { if (response.status === 401) { this.application.principal = undefined; window.location.hash = '#/login'; throw new Error('Session expired'); } if (response.status >= 400) { const errorText = await response.clone().text(); console.error(`HTTP ${response.status}:`, errorText); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return next(); }); const router = Router({ target: '#app', context: {}, fetcher }); router.initialize();

Important Notes

Context is Long-Lived

The Context instance is created once per Router and persists for the entire application lifetime. This means:

Modifying Request Headers

The Request object's headers property is mutable — you can call request.headers.set() directly in middleware:

fetcher.use('request', function(request, next) { request.headers.set('X-Custom', 'value'); return next(); });

No Fetcher Means Native Fetch

If no fetcher is provided to the Router, ctx.fetch defaults to the native fetch function bound to the Context instance:

// No fetcher provided const router = Router({ target: '#app', context: {} }); // In pages, ctx.fetch is just native fetch (with `this` bound to Context)

API Reference

ContextAwareFetcher

Constructor:

new ContextAwareFetcher()

Methods:

use(type: 'request', middleware: RequestMiddleware): void

Add request middleware that runs before the fetch call.

use(type: 'response', middleware: ResponseMiddleware): void

Add response middleware that runs after the fetch call.

create(ctx: Context): typeof globalThis.fetch

Create a fetch function bound to the given Context instance. This is called internally by the Router.

Type Definitions

type RequestMiddleware = ( this: Context, request: Request, next: () => Promise<Response> ) => Promise<Response> type ResponseMiddleware = ( this: Context, response: Response, next: () => Promise<Response> ) => Promise<Response> interface Fetcher { use(type: 'request', middleware: RequestMiddleware): void; use(type: 'response', middleware: ResponseMiddleware): void; create(ctx: Context): typeof globalThis.fetch; }

Notes

```typescript

fetcher.use('response', async function(response, next) {

const clone = response.clone();

const text = await clone.text();

console.log('Response body:', text);

return next(); // Original response still has readable body

});

```

Placards API

Placards provide rich metadata about pages that layouts can consume to dynamically build navigation, breadcrumbs, help systems, and other UI elements. This enables layouts to be populated with data instead of having hardcoded content.

Overview

The Placard system allows pages to declare metadata that describes their purpose, structure, and behavior. Layouts can then query this metadata to automatically build:

Basic Usage

Define a placard for your page using the placard option in the @page decorator:

import { Placard, render, html } from 'snice'; import { page } from './router'; // page comes from Router(), not from 'snice' const placard: Placard<AppContext> = { name: 'dashboard', title: 'Dashboard', href: '#/dashboard', description: 'Main analytics and overview dashboard', icon: '📊', show: true, order: 1 }; @page({ tag: 'dashboard-page', routes: ['/dashboard'], placard: placard }) class DashboardPage extends HTMLElement { @render() renderContent() { return html`<h1>Dashboard</h1>`; } }

Placard Interface

interface Placard<T = any> { // Identification name: string; // Core display title: string; href?: string; description?: string; icon?: string; // Help & discovery tooltip?: string; searchTerms?: string[]; hotkeys?: string[]; helpUrl?: string; // Navigation structure breadcrumbs?: string[]; group?: string; parent?: string; order?: number; show?: boolean; // Dynamic visibility visibleOn?: Guard<T> | Guard<T>[]; // Extensibility attributes?: Record<string, any>; }

Field Reference

Identification

name (required)

Core Display

title (required)

href (optional)

description (optional)

icon (optional)

Help & Discovery

tooltip (optional)

searchTerms (optional)

searchTerms: ['settings', 'preferences', 'config', 'options']

hotkeys (optional)

hotkeys: ['ctrl+d', 'cmd+d', 'alt+shift+d']

helpUrl (optional)

Navigation Structure

group (optional)

group: 'admin' // Groups with other admin pages

parent (optional)

parent: 'users' // Child of the 'users' page

order (optional)

show (optional)

Dynamic Visibility

visibleOn (optional)

// sync visibleOn: [isAuthenticated, hasAdminRole] // async — nav item appears only once the permission check resolves true visibleOn: async (ctx) => { const perms = await fetch('/api/me/permissions').then(r => r.json()); return perms.includes('admin'); }

Extensibility

attributes (optional)

attributes: { category: 'reporting', experimental: true, requiredFeatures: ['analytics', 'charts'] }

Hierarchical Navigation Example

// Parent page const usersPlacard: Placard<AppContext> = { name: 'users', title: 'Users', icon: '👥', show: true, order: 1, group: 'admin' }; // Child pages const userListPlacard: Placard<AppContext> = { name: 'user-list', title: 'All Users', parent: 'users', order: 1, show: true }; const userCreatePlacard: Placard<AppContext> = { name: 'user-create', title: 'Create User', parent: 'users', order: 2, show: true, visibleOn: [canCreateUsers] }; // Grandchild page const userEditPlacard: Placard<AppContext> = { name: 'user-edit', title: 'Edit User', parent: 'user-list', show: false, // Hidden from nav, accessible via direct link breadcrumbs: ['users', 'user-list', 'user-edit'] };

Breadcrumb Resolution

Breadcrumbs can be automatically resolved using the parent hierarchy or explicitly defined:

// Automatic breadcrumbs using parent chain const settingsPlacard: Placard<AppContext> = { name: 'user-settings', title: 'Settings', parent: 'user-profile' // Layout can resolve breadcrumbs using parent chain: Users > Profile > Settings }; // Explicit breadcrumbs const advancedPlacard: Placard<AppContext> = { name: 'advanced-settings', title: 'Advanced', breadcrumbs: ['dashboard', 'settings', 'advanced-settings'] // Explicitly defined breadcrumb path };

Layout Integration

Layouts can access placard data to build dynamic UI. The exact mechanism depends on your router implementation, but typically involves:

  1. Router Context - Placards available through router context
  2. Navigation Builder - Helper functions to build nav from placards
  3. Event System - Layouts listen for route changes and update UI
import { layout, render, html, Layout } from 'snice'; @layout('app-shell') class AppShell extends HTMLElement implements Layout { private placards: Placard[] = []; private currentRoute = ''; @render() renderContent() { return html` <header> <nav> ${this.placards .filter(p => p.show !== false && !p.parent) .map(p => html` <a href="${p.href || ''}" class="${this.currentRoute === p.name ? 'active' : ''}" > ${p.icon} ${p.title} </a> `)} </nav> </header> <main> <slot name="page"></slot> </main> `; } // Called by router when route changes update(appContext: any, placards: Placard[], currentRoute: string, routeParams: any) { this.placards = placards; this.currentRoute = currentRoute; // Property changes trigger re-render } }

Building Navigation from Placards

@layout('sidebar-layout') class SidebarLayout extends HTMLElement implements Layout { private placards: Placard[] = []; private grouped: Record<string, Placard[]> = {}; @render() renderContent() { return html` <aside class="sidebar"> ${Object.entries(this.grouped).map(([group, items]) => html` <div class="nav-group"> <h3>${group}</h3> <ul> ${items .sort((a, b) => (a.order || 0) - (b.order || 0)) .map(p => html` <li> <a href="${p.href || ''}" title="${p.tooltip || ''}"> ${p.icon} ${p.title} </a> </li> `)} </ul> </div> `)} </aside> <main> <slot name="page"></slot> </main> `; } update(appContext: any, placards: Placard[], currentRoute: string, routeParams: any) { this.placards = placards.filter(p => p.show !== false); // Group placards this.grouped = this.placards.reduce((acc, p) => { const group = p.group || 'main'; if (!acc[group]) acc[group] = []; acc[group].push(p); return acc; }, {} as Record<string, Placard[]>); } }

Building Breadcrumbs

@layout('breadcrumb-layout') class BreadcrumbLayout extends HTMLElement implements Layout { private breadcrumbs: Placard[] = []; @render() renderContent() { return html` <nav class="breadcrumbs"> ${this.breadcrumbs.map((p, i) => html` ${i > 0 ? html`<span class="separator">/</span>` : ''} <a href="${p.href || ''}">${p.title}</a> `)} </nav> <main> <slot name="page"></slot> </main> `; } update(appContext: any, placards: Placard[], currentRoute: string, routeParams: any) { // Find current page placard const current = placards.find(p => p.name === currentRoute); if (!current) return; // Build breadcrumb trail this.breadcrumbs = this.buildBreadcrumbs(current, placards); } buildBreadcrumbs(placard: Placard, all: Placard[]): Placard[] { const trail: Placard[] = [placard]; // Use explicit breadcrumbs if defined if (placard.breadcrumbs) { return placard.breadcrumbs .map(name => all.find(p => p.name === name)) .filter(Boolean) as Placard[]; } // Otherwise follow parent chain let current = placard; while (current.parent) { const parent = all.find(p => p.name === current.parent); if (!parent) break; trail.unshift(parent); current = parent; } return trail; } }

Styling

Scoped CSS, host styling, dynamic styles, and icons. Theme tokens are documented in Theming.

@styles() Decorator

Returns CSS using the css tagged template, scoped to the element's shadow DOM.

import { element, render, styles, html, css } from 'snice'; @element('styled-card') class StyledCard extends HTMLElement { @render() renderContent() { return html`<div class="card">Content</div>`; } @styles() cardStyles() { return css` .card { padding: 20px; border: 1px solid #ddd; border-radius: 8px; } `; } }

Note: Only one @styles() method is supported per element. If multiple are declared, only the last one is used. Combine all styles in a single method:

@styles() componentStyles() { return css` :host { display: block; } .card { background: var(--bg-color); } `; }

Scoped Styles

Styles are automatically scoped to the component's shadow DOM:

@element('scoped-styles') class ScopedStyles extends HTMLElement { @render() renderContent() { return html` <div class="container"> <h1>Title</h1> <p class="content">Content</p> </div> `; } @styles() componentStyles() { return css` :host { display: block; padding: 20px; } .container { border: 1px solid #ccc; } h1 { color: blue; /* Only affects h1 inside this component */ } `; } }

Dynamic Styles

@styles() is called once during initialization and does not update on property changes. For dynamic styling, use CSS custom properties set in the template:

@element('theme-component') class ThemeComponent extends HTMLElement { @property() accentColor = '#007bff'; @render() renderContent() { return html` <div class="themed" style="--accent: ${this.accentColor}"> Themed content </div> `; } @styles() themeStyles() { return css` .themed { color: var(--accent); } `; } }

Host Styling

@element('host-styled') class HostStyled extends HTMLElement { @styles() hostStyles() { return css` :host { display: block; width: 100%; max-width: 600px; margin: 0 auto; } :host([disabled]) { opacity: 0.5; pointer-events: none; } :host(:hover) { background: #f0f0f0; } `; } @render() renderContent() { return html`<div>Content</div>`; } }

Icons

Many Snice components accept an icon property (or prefix-icon / suffix-icon for inputs). The icon value is auto-detected:

ValueRendered As
"search", "check_circle"Ligature icon with icon font
"🔍", "$"Text/emoji as-is
"https://example.com/icon.svg"<img> element
"logo.png", "icon.svg"<img> element
"img://url"Explicit <img>
"text://content"Explicit <span>

Changing the Icon Font

By default, ligature icons (lowercase words like search, home, check_circle) use Material Symbols Outlined. To use a different icon font like Font Awesome, set the --snice-icon-font CSS custom property:

:root { --snice-icon-font: 'Font Awesome 6 Free'; }

Make sure to load the corresponding font in your HTML:

<!-- Material Symbols (default) --> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap"> <!-- Or Font Awesome --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">

Icon Slots

For full control over icon rendering (e.g., using a specific icon library class), use named slots instead of the icon attribute:

<snice-input label="Search"> <span slot="prefix-icon" class="fa-solid fa-magnifying-glass"></span> </snice-input> <snice-button> <svg slot="icon" viewBox="0 0 24 24">...</svg> Submit </snice-button>

Theming

Every Snice component is styled entirely through CSS custom properties. Override a token and every component that uses it follows — no component code changes, no build step.

Load the stylesheet once:

<link rel="stylesheet" href="theme/theme.css">

Component-level styling — @styles(), host styling, and icons — is covered in

Styling. The full token table lives in the

theme reference.

Color Format

Colors are stored as HSL channels, space separated: hue saturation% lightness%. Tokens hold the raw channels so they can be reused with any alpha:

--snice-color-blue-600: 217 83% 45%; color: hsl(var(--snice-color-blue-600)); background: hsl(var(--snice-color-blue-600) / 0.12); /* modern alpha syntax */

Use hsl(0 0% 0% / 0.15), not the legacy hsla(0, 0%, 0%, 0.15).

Primitives and Semantic Tokens

There are two layers, and you almost always want the second.

Primitives are raw scales — gray, blue, green, red, and yellow, each running 50 (lightest) through 950 (darkest). They do not change between light and dark.

Semantic tokens describe a role, and they do change with the theme:

--snice-color-primary: hsl(var(--snice-color-blue-600)); --snice-color-success: hsl(var(--snice-color-green-600)); --snice-color-warning: hsl(var(--snice-color-yellow-600)); --snice-color-danger: hsl(var(--snice-color-red-600)); --snice-color-text: hsl(var(--snice-color-gray-900)); --snice-color-text-secondary: hsl(var(--snice-color-gray-600)); --snice-color-text-tertiary: hsl(var(--snice-color-gray-500));

Override semantic tokens to rebrand; override primitives only to change the underlying palette.

The complete token list

Every token — colors, surfaces, borders, interaction overlays, motion, shadow

glows, state-aware focus rings, density, texture, print, spacing, typography,

radius, and layers — is tabulated in the

theme reference. That page is the exhaustive list;

this one explains how the system fits together.

Dark Mode

Set data-theme on the root element. Semantic tokens switch; primitives do not.

<html data-theme="dark"> document.documentElement.setAttribute('data-theme', 'dark');

Because components reference semantic tokens only, dark mode requires no component changes. Always check both themes when you override tokens — a colour that passes contrast on a light surface often fails on a dark one.

Native form controls are corrected with a filter token:

--snice-color-scheme-filter: none; /* light */ --snice-color-scheme-filter: invert(1) hue-rotate(180deg); /* dark */

It is applied to ::-webkit-calendar-picker-indicator at 0.7 opacity, rising to 1.0 on hover.

Overriding Tokens

Scope an override wherever you need it — globally, or on a subtree:

:root { --snice-color-primary: hsl(280 70% 50%); } .checkout { --snice-color-primary: hsl(142 71% 40%); }

Every component inside .checkout picks up the local value, because tokens inherit through the shadow boundary.

CLI

Snice ships a command line tool for scaffolding projects, diagnosing setup, validating source, and serving documentation to AI agents.

npx snice <command>

Commands

CommandPurpose
create-app <path>Create a complete vanilla or React application
init-ai [path]Install the version-matched Snice skill and agent pointers
check [path]Run all package, configuration, and source checks
doctor [path]Diagnose configuration, imports, dependencies, and AI setup
validate [path]Run the source analyzer only
generate-component <name>Print a current element scaffold
build-component <name>Build a CDN bundle from a Snice source checkout

Creating a Project

npx snice create-app my-app npx snice create-app my-app --template=react

The default template is vanilla Snice with routing and a build already configured. --template=react scaffolds the React adapter setup described in React Integration.

Checking a Project

check is the broad gate — it runs the package and configuration checks and the source analyzer together.

npx snice check npx snice check --json # machine-readable, for CI

Run the halves individually when you want a narrower signal:

npx snice doctor # configuration, imports, dependencies, AI setup npx snice validate # source analyzer only

doctor reports on the things that silently break a Snice project: decorator configuration in tsconfig.json, a bundler that cannot handle decorators, missing peer dependencies, and whether the Snice skill is installed for your coding agent.

validate runs the analyzer over your source. It catches mistakes that compile but never work, including:

target (snice/route-param-has-no-binding-target, warning), including

attribute: false and mismatched explicit aliases

without { once: true }, a first-delivery guard, or an update diff

(snice/unguarded-context-load, warning)

The route-param check follows proven local declarations, direct relative

re-exports, and named or namespace Snice imports, so an inherited bindable

property satisfies it. It deliberately defers a missing-target warning for an

unresolved/ambiguous base or dynamic route/attribute contract; locally visible

disabled or mismatched properties are still diagnosed. Native reflected IDL

attributes such as an unmodified id, and statically known custom

observedAttributes handlers, also satisfy the attribute target.

The route-param rule is non-blocking because its dependency-free static lexer

cannot model every valid JavaScript/TypeScript grammar edge. Treat the warning

as a strong prompt to verify the Router attribute channel; validate and

check fail only when another error-level diagnostic is present.

It also gives non-blocking architecture suggestions: keep @page, @element,

@controller, and @daemon classes under src/pages, src/components,

src/controllers, and src/daemons; keep visual behavior in elements,

application behavior specific to a set of elements in controllers, and element

orchestration in pages. A host-free reusable function may remain a plain module

wherever the project convention places it.

Both accept --json for CI.

Diagnostic codes and .sniceignore

Every error, warning, and suggestion has a stable code in human and JSON

output. When a project intentionally accepts a finding, create .sniceignore

in the project root. Prefer the narrowest entry:

# One exact diagnostic snice/prefer-dispatch-decorator src/components/legacy-filter.ts:42:5 # Every instance in one file snice/prefer-dispatch-decorator src/components/legacy-filter.ts # Every instance in the project snice/prefer-dispatch-decorator

code path:line is also accepted. Paths are project-relative and entries are

exact rather than glob patterns. Suppressed diagnostics are omitted from both

text and JSON output and do not affect the exit status. The file applies to

check, doctor, and validate, including doctor codes such as

snice-skill.

AI Setup

npx snice init-ai npx snice init-ai --force # overwrite an existing install

This installs a skill matched to the Snice version in your project, so an agent reads the documentation for the version you actually have rather than whatever it remembers. See AI Assistance below.

AI Assistance

There are two ways to give a coding agent the Snice skill. Both install the same

skill; they differ in what it is scoped to.

Per project, from npm

Installs into the current project, matched to the Snice version that project has:

npx snice init-ai

This writes .agents/skills/snice/ plus AGENTS.md and CLAUDE.md pointing at

it. Use --force to overwrite an existing install. Because the skill reads

node_modules/snice/docs/ai/, the agent always sees documentation for the

version actually installed — not whatever it remembers.

Per harness, from the repository

Installs globally for your agent, straight from the repo. Snice ships the plugin

manifests these harnesses expect, so no clone or build step is needed.

Claude Code

/plugin marketplace add https://gitlab.com/Hedzer/snice /plugin install snice@snice

Antigravity

agy plugin install https://gitlab.com/Hedzer/snice

Gemini CLI

gemini extensions install https://gitlab.com/Hedzer/snice gemini extensions update snice # later

Kimi Code

/plugins install https://gitlab.com/Hedzer/snice

Factory Droid

droid plugin marketplace add https://gitlab.com/Hedzer/snice droid plugin install snice@snice

Prefer init-ai when you work on one Snice project and want the skill pinned to

its version. Prefer the repository install when you move between Snice projects

and want the skill always available.

Token-efficient copies of every reference page live in docs/ai/, mirroring these documents without the prose. Agents should read those instead of the human pages.

Generating a Component

Prints a scaffold using the current decorator conventions, so a new element

starts from correct code rather than a half-remembered example:

npx snice generate-component task-item npx snice generate-component task-item --props=label:string,done:boolean --events=status-changed
OptionMeaning
--props=name:type,…Declared @property() fields. Types: string, number, boolean, array, object (default string)
--events=name,…A @dispatch() method per event
--no-stylesOmit the @styles() block
--out=<path>Write to a file instead of stdout. Never overwrites

Output goes to stdout by default, so you can review or pipe it. --out is the

explicit opt-in to writing, and refuses to clobber an existing file.

Building CDN Bundles

build-component is for building CDN bundles from a Snice source checkout; application projects do not need it.

npx snice build-component button npx snice build-component table --format=iife,es --with-theme
OptionDefaultMeaning
--output=<dir>./dist/cdnOutput directory
--format=iife,esiifeOutput formats (table defaults to iife,es)
--no-minifyoffDisable minification
--with-themeoffInclude theme.css

See CDN for how the resulting bundles are loaded.

Testing

Snice elements are custom elements, so they test in any DOM-capable runner. The only Snice-specific part is knowing when an element is ready to assert against.

The Two Promises

Rendering is asynchronous and batched, so assertions must wait.

PromiseAwait itResolves when
el.readyafter mounting, before the first assertionthe first render is done and every @ready() handler has finished
el.renderedafter writing a reactive propertythe render queued by that write has been applied
import { describe, it, expect } from 'vitest'; import './my-counter'; describe('my-counter', () => { it('renders its initial count', async () => { const el = document.createElement('my-counter'); document.body.appendChild(el); await el.ready; expect(el.shadowRoot.querySelector('button').textContent).toBe('0'); }); it('re-renders when the count changes', async () => { const el = document.createElement('my-counter'); document.body.appendChild(el); await el.ready; el.count = 5; await el.rendered; expect(el.shadowRoot.querySelector('button').textContent).toBe('5'); }); });

Awaiting ready on an element that never connects will hang; append it to the document first.

ready rejects when any @ready() handler throws or returns a rejected

promise. Always await it: the rejection points at the initialization failure

instead of allowing later assertions to fail against a half-initialized

element. Snice form controls also retain their native-input/proxy fallback in

DOM runners such as jsdom that expose only the ARIA subset of

ElementInternals.

Strict Render Errors

Rendering logs failures by default and retains the previous DOM. Tests that need a synchronous render failure to fail the assertion directly can temporarily enable strict mode:

import { setStrictRenderErrors } from 'snice'; try { setStrictRenderErrors(true); expect(() => { element.invalid = true; }).toThrow(/<my-element> \(MyElement\)/); } finally { setStrictRenderErrors(false); }

Template parse and authoring errors identify the owning component by its authoritative registered tag and, when safely available, its class, and include a nearby static-template excerpt. Minified CDN constructors may have no class name, so tests should accept tag-only attribution. Attribution requires the exact constructor and immediate prototype successfully registered by @element, @layout, or Router (or that exact constructor already present in the registry): undecorated subclasses stay generic, and document adoption does not change an instance's captured registration identity. The original error is retained as cause, so assertions and debugging can inspect its stack. Snice cannot recover a source filename from a runtime tagged-template value and does not fabricate one. A template prepared outside a component render therefore keeps the generic nearby-template diagnostic.

Promise and async-iterable values settle after the synchronous render call has returned, so their failures are reported through console.error even while strict mode is enabled. Spy on console.error, wait for the deferred value to settle, and assert against the Error argument; it carries the same owning-component context.

Partial DOM Compatibility

In a simulated DOM, opt into Snice's standards compatibility layer from your

test setup file:

import 'snice/testing/dom';

The module capability-tests the current DOM and fills only missing IDL

behavior. For example, jsdom and some happy-dom versions omit the browser's

reflective HTMLElement.autofocus property for generic custom-element hosts;

the adapter adds that property without replacing an implementation already

provided by the runner. Application tests can then use

element.autofocus = true exactly as browser code does.

The adapter does not simulate layout, paint, or real focus navigation. Keep a

browser-test lane for behavior that depends on those capabilities. If a runner

creates its DOM after module evaluation, import and call

installDOMTestingCompatibility(scope) explicitly.

Reading the DOM

Use the element's render root directly:

el.shadowRoot.querySelector('.label');

For components whose render root may be closed or light, use @query inside the component and assert against the property, or select from el rather than el.shadowRoot. See Queries.

Events

Snice events carry their payload in detail, so type handlers as CustomEvent:

const seen: string[] = []; el.addEventListener('status-changed', (e: CustomEvent) => seen.push(e.detail.value)); el.shadowRoot.querySelector('input').click(); await el.rendered; expect(seen).toEqual(['done']);

Assert on e.detail, not e.target.value.

Controllers

A controller attaches asynchronously, so wait for the element before asserting on its effects:

import { attachController } from 'snice'; const el = document.createElement('user-list'); document.body.appendChild(el); await attachController(el, UserController);

Swapping a controller is the supported way to test an element in isolation: attach a fixture controller that responds on the same channel the production controller uses, and the element itself never changes. See Request / Response and Controllers.

When a controller calls getContext(this), install the same application

context on its host (or an ancestor) before attachment, then release it during

cleanup:

import { attachController, detachController, provideContext } from 'snice'; const host = document.createElement('user-list'); document.body.append(host); const releaseContext = provideContext(host, { api: { listUsers: async () => [{ id: 'u1' }] } }, { fetch: async () => new Response(JSON.stringify([{ id: 'u1' }])) }); try { await attachController(host, UserController); // assert controller effects } finally { await detachController(host); releaseContext(); host.remove(); }

Assigning the host's own @context field does not create a provider;

getContext() resolves the nearest provideContext() boundary.

When production code uses getContextFetch(), pass the test fetch function in

the same provider's third argument as shown above. Router supplies its

middleware-bound fetch function there in an application.

If the controller uses @context(), test it beneath a real Router target.

provideContext() intentionally provides application state and transport, not

navigation notifications — a provideContext() boundary can NEVER satisfy a

@context() handler. The handler simply never fires, with no error, so a

harness built on provideContext() alone shows a silently empty page and a

green-looking suite. Using Router exercises the same registration, initial

catch-up, middleware-aware ctx.fetch, updates, and detach cleanup as

production:

const router = Router({ target: '#fixture', context: { accountId: 'a1' }, fetcher }); @router.page({ tag: 'fixture-page', routes: ['/fixture'] }) class FixturePage extends HTMLElement {} router.initialize(); await router.navigate('/fixture'); const host = document.createElement('user-list'); document.querySelector('fixture-page')!.append(host); await attachController(host, UserController);

To re-deliver a Context to an already-attached controller — exactly what

testing a first-delivery guard requires — capture the Context on the fixture

page and call its public update(), which re-notifies every registered

handler:

@router.page({ tag: 'fixture-page', routes: ['/fixture'] }) class FixturePage extends HTMLElement { ctx?: Context; @context() capture(ctx: Context) { this.ctx = ctx; } } // after attach: fixturePage.ctx!.update(); // assert the guarded controller did NOT start its work again

Two traps: invoking the @context() method directly does not work (the

registered handler is a wrapped method), and detachController() nulls

element before any late delivery, so a post-detach assertion must be set up

before detaching.

Validating Source

Beyond unit tests, the analyzer catches Snice-specific mistakes that still compile:

npx snice validate npx snice check --json

See CLI.

Utilities

Small helpers exported from snice alongside the decorators.

Method Decorators

Rate-limit or cache a method without writing the plumbing:

import { debounce, throttle, once, memoize } from 'snice'; class SearchBox extends HTMLElement { @debounce(300) search(term: string) { /* runs 300ms after the last call */ } @throttle(100) onScroll() { /* runs at most once per 100ms */ } @once() initialize() { /* runs one time per instance */ } @memoize({ maxSize: 50, ttl: 60_000 }) expensive(id: string) { /* result cached by argument */ } }
DecoratorOptions
@debounce(wait?, options?)leading, trailing, maxWait
@throttle(wait?, options?)leading, trailing
@once(perInstance?)perInstance — once per instance rather than per class
@memoize(options?)keyGenerator, maxSize, ttl

Pending timers are cleared automatically when the element disconnects. Clear

them yourself — in a test, say — with clearDebounceTimers(instance),

clearThrottleTimers(instance), clearMemoizeCache(instance), and

resetOnce(instance).

Escaping

import { escapeHtml, escapeAttr } from 'snice'; escapeHtml(value); // text destined for markup escapeAttr(value); // text destined for an attribute value

Template bindings escape for you; reach for these only when assembling markup

by hand — see Binding Channels.

Durations

import { parseDuration } from 'snice'; parseDuration('150ms'); parseDuration('1.5s');

Parses a CSS-style duration string, so a component can accept "200ms" from an

attribute and get a number back.

Scroll Locking

Used by overlay components (modal, drawer) to stop the page scrolling behind

them. The lock is reference counted, so nested overlays release correctly:

import { lockBodyScroll, unlockBodyScroll, getBodyScrollLockCount } from 'snice'; lockBodyScroll(); unlockBodyScroll(); getBodyScrollLockCount(); // 0 when nothing holds the lock

Always pair a lock with an unlock — usually @ready / @dispose.

Controllers

import { attachController, detachController, getController } from 'snice'; await attachController(element, MyController); getController(element); // the attached instance, or undefined await detachController(element);

Native elements pick up controller="name" automatically in the browser;

useNativeElementControllers() exists to enable that manually in environments

where it is not auto-started. See Controllers.

Debugging

import { trackRenders } from 'snice'; trackRenders(element); // logs each render pass for this element

CDN / Standalone Usage

Use any Snice component on a plain HTML page — no bundler, no build step. Each component is a standalone .min.js bundle that shares one small runtime.

Load Order

Load the runtime once, first, then one bundle per component. Components load in any order after the runtime:

<link rel="stylesheet" href="theme.css"> <!-- optional: design tokens + dark mode --> <script src="snice-runtime.min.js"></script> <!-- required, load first --> <script src="snice-button.min.js"></script> <!-- one bundle per component --> <script src="snice-card.min.js"></script> <snice-button variant="primary">Click me</snice-button>

snice-runtime.min.js is shared by every component. Load it before any snice-<name>.min.js — without it the custom elements never register and the page renders blank.

Bundle Families

One bundle registers its whole element family — you don't load sub-elements separately:

BundleRegisters
snice-tabs.min.js<snice-tabs>, <snice-tab>, <snice-tab-panel>
snice-select.min.js<snice-select>, <snice-option>
snice-toast.min.js<snice-toast>, <snice-toast-container>

Theme and Dark Mode

Components ship with built-in light-mode token fallbacks, so they render correctly with no stylesheet. Add theme.css for the full design-token set and dark mode:

<link rel="stylesheet" href="theme.css">

Dark mode follows the OS setting automatically. To force it, set data-theme on the root element:

<html data-theme="dark"> <!-- or data-theme="light" to pin light -->

Use the .min.js Builds

Load the .min.js (IIFE) bundles in <script src> tags. The .esm.min.js builds are ES modules for bundler/import use, not <script src>.

Generating Bundles

Bundles are published to the CDN, or build them yourself:

snice build-component button

React Integration

Snice's React integration lets you build full Snice-powered apps using hooks and JSX — routing, guards, layouts, context — without decorators or custom element classes.

React pages and Snice web component pages coexist in the same route table. Migration is incremental.

Installation

Snice's React integration is included in the main package:

npm install snice

Everything imports from snice/react:

import { SniceRouter, Route, SniceProvider, useSniceContext, useNavigate, useParams, useRoute, useRequestHandler, } from 'snice/react';

You can also deep-import individual modules:

import { useRequestHandler } from 'snice/react/useRequestHandler';

TypeScript Types

import type { SniceReactContext, SniceRouterProps, RouteProps, SniceProviderProps, Placard, UseRequestRouteMap, UseRequestHandlerOptions, } from 'snice/react';

Quick Start

import { SniceRouter, Route } from 'snice/react'; function App() { return ( <SniceRouter mode="hash" context={{ user: null, theme: 'dark' }}> <Route path="/" page={HomePage} /> <Route path="/about" page={AboutPage} /> </SniceRouter> ); } function HomePage() { return <h1>Welcome</h1>; } function AboutPage() { return <h1>About</h1>; }

SniceRouter

The root provider component. Manages URL state, route matching, guard execution, layout selection, and context propagation.

<SniceRouter mode="hash" context={{ user, theme: 'dark' }} layout={DefaultLayout} loading={<Spinner />} fallback={<NotFound />} > <Route ... /> </SniceRouter>

Props

PropTypeDefaultDescription
mode"hash" \"history"requiredURL strategy. Hash uses #/path, history uses browser pushState.
contextobject{}Application context passed to guards and available via useSniceContext().
layout`Component \string`noneDefault layout wrapping all pages. String = Snice web component tag.
loading`Component \string \JSX`centered spinnerShown while async guards are running.
fallback`Component \string \JSX`"404 — Page not found"Shown when no route matches.

For layout, loading, and fallback: pass a React component, a Snice web component tag name (string), or raw JSX.

Route

Defines a route within <SniceRouter>. The <Route> component itself renders nothing — SniceRouter reads its props to build the route table.

<Route path="/users/:id" page={UserPage} guard={authGuard} guardRedirect="/login" layout={DashboardLayout} />

Props

PropTypeDescription
pathstringURL pattern. Supports dynamic segments: /users/:id, /posts/:slug.
ordernumberOptional specificity tie-break. Lower values match first; equal or omitted values preserve declaration order.
page`Component \string`What to render. React component receives route params as props. String = Snice web component tag name (params set as attributes).
guard`(ctx, params) => boolean \Promise<boolean>`Single guard function.
guardsfunction[]Multiple guards — all must pass (AND logic).
guardRedirectstringRedirect path if any guard rejects. Without this, the router renders nothing on rejection.
layout`Component \string \false`Override the router's default layout. false = explicitly no layout.
placardPlacardPage metadata for layouts — navigation titles, icons, breadcrumbs. Import Placard type from snice/react.

Hooks

useSniceContext()

Returns the full merged context. Mirrors the shape of Snice's @context decorator:

import { useSniceContext } from 'snice/react'; function UserPage() { const ctx = useSniceContext(); // ctx.application — your context object (whatever you passed to SniceRouter) // ctx.navigation.route — matched route pattern, e.g., "/users/:id" // ctx.navigation.params — route params, e.g., { id: "42" } // ctx.navigation.placards — all registered placards // ctx.navigate — (path: string) => void // ctx.fetch — fetch function (if provided to SniceProvider) return <div>User: {ctx.application.user?.name}</div>; }

Must be used inside <SniceRouter> or <SniceProvider>. Throws if used outside.

useNavigate()

Convenience hook for programmatic navigation:

import { useNavigate } from 'snice/react'; function LoginButton() { const navigate = useNavigate(); return <button onClick={() => navigate('/dashboard')}>Go to Dashboard</button>; }

useParams()

Returns current route parameters. Shortcut for useSniceContext().navigation.params:

import { useParams } from 'snice/react'; // Route: <Route path="/users/:id" page={UserPage} /> function UserPage() { const params = useParams(); // { id: "42" } return <div>User #{params.id}</div>; }

Note: React page components also receive params directly as props (e.g., function UserPage({ id }) { ... }), so useParams() is optional if you prefer the props pattern.

useRoute()

Returns the current matched route pattern string:

import { useRoute } from 'snice/react'; function ActiveIndicator() { const route = useRoute(); // "/users/:id" return <span>{route}</span>; }

useRequestHandler()

Handle @request channels from Snice web components in React. This is how React code responds to requests from Snice elements that use the @request decorator.

Why: Snice elements make requests via @request('channel-name'). Normally a @respond controller handles these. useRequestHandler lets React components be the responder — no controller class needed.

import { useRef } from 'react'; import { useRequestHandler } from 'snice/react'; function Dashboard() { const containerRef = useRef<HTMLDivElement>(null); useRequestHandler(containerRef, { 'fetch-user': async (payload) => { const res = await fetch(`/api/users/${payload.id}`); return res.json(); }, 'save-settings': async (payload) => { await fetch('/api/settings', { method: 'POST', body: JSON.stringify(payload) }); return { ok: true }; }, }); return ( <div ref={containerRef}> <snice-user-card /> <snice-settings-panel /> </div> ); }

Global Handler

Pass null as the ref to listen on document (catches all bubbling requests):

function GlobalProvider({ children }) { useRequestHandler(null, { 'fetch-config': async () => ({ theme: 'dark', locale: 'en' }), }); return <>{children}</>; }

Options

useRequestHandler(ref, routes, { passive: true });
OptionTypeDefaultDescription
passivebooleanfalseWhen true, doesn't stop event propagation. Allows multiple handlers to observe the same request (only one can respond).

Behavior

Guards

Guards protect routes. The same function signature works in both Snice and React:

type Guard = ( context: Record<string, any>, // your context object params: Record<string, string> // route params ) => boolean | Promise<boolean>;

Sync Guards

const authGuard = (ctx, params) => !!ctx.user; <Route path="/settings" page={SettingsPage} guard={authGuard} guardRedirect="/login" />

Async Guards

Async guards show the loading component while resolving:

const roleGuard = async (ctx, params) => { const roles = await fetchUserRoles(ctx.user.id); return roles.includes('admin'); }; <SniceRouter loading={<Spinner />}> <Route path="/admin" page={AdminPage} guard={roleGuard} guardRedirect="/forbidden" /> </SniceRouter>

If no loading prop is set, a default centered spinner is shown.

Multiple Guards

All guards must pass (AND logic). They run sequentially — first failure short-circuits:

<Route path="/admin/users/:id" page={AdminUserPage} guards={[authGuard, roleGuard]} guardRedirect="/login" />

Guard Failure

When a guard returns false (or rejects):

  1. If guardRedirect is set → navigate to that path
  2. If no guardRedirect → render nothing (blank)

Guard errors (thrown exceptions) are treated as rejection.

Sharing Guards

Guards are plain functions. The same guard works in both vanilla Snice and React:

// guards.ts — shared between vanilla and React export const isAuthenticated = (ctx, params) => !!ctx.user; export const isAdmin = (ctx, params) => ctx.user?.role === 'admin'; export const hasPermission = (permission) => (ctx, params) => ctx.user?.permissions?.includes(permission);

Layouts

React layouts are components that render {children}:

function DashboardLayout({ children }) { const ctx = useSniceContext(); return ( <div className="dashboard"> <Sidebar user={ctx.application.user} /> <main>{children}</main> </div> ); }

Default Layout

Set on the router — wraps all pages by default:

<SniceRouter layout={DashboardLayout}> <Route path="/" page={HomePage} /> <Route path="/users/:id" page={UserPage} /> </SniceRouter>

Per-Route Override

Override the default layout for specific routes:

<SniceRouter layout={DashboardLayout}> <Route path="/" page={HomePage} /> <Route path="/reports" page={ReportsPage} layout={ReportsLayout} /> <Route path="/login" page={LoginPage} layout={false} /> </SniceRouter>

Snice Layouts

Pass a string to use a Snice web component as the layout:

<Route path="/legacy" page="legacy-page" layout="legacy-layout" />

Mixed Pages

Snice web component pages and React pages coexist in the same route table:

function App() { return ( <SniceRouter mode="hash" context={{ user, theme: 'dark' }} layout={AppShell}> {/* React pages */} <Route path="/" page={HomePage} /> <Route path="/settings" page={SettingsPage} guard={authGuard} guardRedirect="/login" /> {/* Snice web component pages */} <Route path="/legacy" page="legacy-dashboard" /> <Route path="/reports" page="report-page" layout="report-layout" /> {/* No layout */} <Route path="/login" page={LoginPage} layout={false} /> </SniceRouter> ); }

When page is a string, the router creates the web component element and sets route params as attributes. When page is a React component, params are passed as props.

The same string-or-component pattern works for layout, loading, and fallback.

Context (Standalone)

<SniceProvider> can be used without a router for simpler apps that just need shared context:

import { SniceProvider, useSniceContext } from 'snice/react'; function App() { return ( <SniceProvider context={{ user: currentUser, theme: 'dark' }}> <MyComponent /> </SniceProvider> ); } function MyComponent() { const ctx = useSniceContext(); return <div>Theme: {ctx.application.theme}</div>; }

SniceProvider Props

PropTypeDefaultDescription
contextobject{}Application context.
navigate(path: string) => voidno-opNavigation function.
routestring""Current route pattern.
paramsRecord<string, string>{}Current route params.
placardsPlacard[][]Registered placards.
fetchtypeof fetchnoneOptional context-aware fetch function.

When used inside <SniceRouter>, these props are set automatically. Use <SniceProvider> directly only when you don't need routing.

Context Shape Reference

useSniceContext() returns a SniceReactContext object:

interface SniceReactContext { /** Your application context (whatever you passed to context={}) */ application: Record<string, any>; /** Navigation state */ navigation: { route: string; // matched route pattern, e.g., "/users/:id" params: Record<string, string>; // route params, e.g., { id: "42" } placards: Placard[]; // all registered placards }; /** Programmatic navigation */ navigate: (path: string) => void; /** Context-aware fetch (if provided to SniceProvider) */ fetch?: typeof globalThis.fetch; }

This mirrors the shape of Snice's vanilla Context class used by the @context decorator.

Vanilla Snice Comparison

ConceptVanilla SniceReact
Router setupRouter({ target, type: 'hash', layout, context })<SniceRouter mode="hash" layout={...} context={...}>
Page definition@page({ tag, routes, guards })<Route path="..." page={...} guard={...} />
Navigationnavigate('/path')const nav = useNavigate(); nav('/path')
Context access@context() handleCtx(ctx) { ... }const ctx = useSniceContext()
Request handling@respond('channel') controlleruseRequestHandler(ref, { 'channel': handler })
Guards`(ctx, params) => boolean \Promise<boolean>`Same — shared functions work in both
Layouts@layout('tag') class ... { }function Layout({ children }) { ... }

Guards, context shape, and page contracts are identical across both systems. A guard written for vanilla Snice works in React and vice versa.

Behavior Notes

Guards run on navigation, not on context change. If your context changes (e.g., user logs out), guards on the current route are not re-evaluated until the next navigation. This matches standard router behavior.

Route params as props. React page components receive the matched route params directly as props: <Route path="/users/:id" page={UserPage} /> means UserPage gets { id: "42" } as props. You can also access them via useParams().

Route matching uses pica-route — the same library as vanilla Snice's router. Routes are sorted by specificity (longest path first), so /users/:id takes priority over /users.

Default loading. If no loading prop is provided, async guards show a simple centered CSS spinner. Pass your own loading component/JSX to customize it.