type converts strings arriving from attributes. Direct JavaScript assignments keep their original type and object identity. Use reflect: false for attribute input without property-to-attribute output, or attribute: false for a JavaScript-only public property.
Add deep: true when nested object and collection mutations should invalidate the view without replacing the top-level field.
@state({ deep: true }) model = {
tasks: [],
selected: new Set(),
flags: new Map()
};
// Each write is observed and batched into one render.
this.model.tasks.push(task);
this.model.selected.add(task.id);
Deep state uses native Proxy and Reflect, including cycles, arrays, Map, and Set. It targets modern browsers; ordinary state does not require deep proxies.
Choose the channel that matches what the browser consumes: text, an attribute, a JavaScript property, boolean presence, a listener, one class, or one CSS property.
Use .property for objects, arrays, functions, native form state, and any value whose JavaScript type or identity matters. Use ?attribute when only attribute presence matters.
Promises and async iterables render directly in node expressions. Replacing a source ignores stale results; disconnecting stops active iterator consumption.
Prefer direct bindings when the keys are known. Use a named spread when a wrapper, plugin, or generated view receives a dynamic bag that must be forwarded through one explicit DOM channel.
...props preserves JavaScript values, ...attrs manages attributes, and ...events manages listeners. Keys omitted from the next bag are removed or reset.
Use input for text as it changes and change for committed choices such as checkboxes, selects, and files. Parsing, validation, and IME behavior stay visible in the handler.
The declarative binding re-asserts the property on every render; the imperative version guards the write so it never fights the caret while typing.
Swap behavior on any element without changing its code. A controller attaches logic from the outside.
Visual behavior belongs in elements. Application behavior specific to a set of elements belongs in a controller. Element orchestration belongs in pages. Do not attach another controller to a page host. A host-free reusable function may stay a plain module wherever the project keeps it.
import { controller, on } from 'snice';
import { navigate } from '../router';
@controller('weather')
class WeatherController {
element = null;
attach(el) { this.element = el; }
@on('click')
viewDetails() {
navigate('/weather');
}
detach() {}
}
Attach the class directly — import it, bind it, done.
@element('weather-dashboard')
class WeatherDashboard extends HTMLElement {
@render()
template() {
return html`
<stat-card controller=${WeatherController}></stat-card>
<!-- swap behavior: same card, different logic -->
<stat-card controller=${StocksController}></stat-card>
<!-- works on plain HTML elements too -->
<div controller=${WeatherController}></div>
`;
}
}
Binding the same class again is a no-op; binding a different one detaches the old controller first.
The decorator name is reflected in the DOM as controller="weather" for DevTools. The class reference still owns the attachment; this is a diagnostic marker and cannot double-attach through the registry.
In raw HTML, attach by the registered name instead: <stat-card controller="weather"></stat-card>
A daemon is an ordinary app-owned object with state and a lifecycle. Construct it yourself, provide the instance through app context, and let elements/controllers communicate with its address instead of importing its class.
@request/@respond handles one reply; @dispatch/@on handles notifications. There is no singleton, implicit construction, or global registry. Provide context before elements connect or controllers attach, and call release() during teardown.
A page is an element bound to a route. Create the router once, then use the page decorator it returns.
// router.ts — create it once, export the pieces
import { Router } from 'snice';
export const { page, navigate, initialize } = Router({
target: '#app',
type: 'hash' // 'hash' or 'pushstate' — required
});// pages/product.ts — `page` comes from router.ts, not from 'snice'
import { page } from '../router';
@page({ tag: 'product-page', routes: ['/products/:id?tab=:tab', '/products/:id'] })
class ProductPage extends HTMLElement {
@property() id = ''; // route params arrive as properties
@property() tab = ''; // query params do too; no URLSearchParams needed
@render()
template() {
return html`<h1>Product ${this.id}</h1>`;
}
}
Pages own element orchestration: they compose elements, pass properties, handle events, bind controllers, and coordinate the screen. Routing is one page concern, not the definition of the role. Declare path and query parameters in routes; do not attach a controller to the page host or build a URL-parsing controller for it.
Routes use specificity first and declaration order for ties, so keep the query-bearing string before its bare fallback. Plain strings are the normal form. Optional { path, order } entries provide an explicit cross-registration tie-break; lower order values match first.
// main.ts — import pages for their side effects, then start
import './pages/product';
import { initialize, navigate } from './router';
initialize();
navigate('/products/42');
Guards return a boolean or a promise of one. Async guards are awaited before the page mounts.
// A layout is an element with a slot for the page
@layout('app-shell')
class AppShell extends HTMLElement {
@render()
template() {
return html`
<nav>…</nav>
<main><slot name="page"></slot></main>
`;
}
}
// Apply to every route…
Router({ target: '#app', type: 'hash', layout: 'app-shell' });
// …or opt a single page out
@page({ tag: 'login-page', routes: ['/login'], layout: false })
class LoginPage extends HTMLElement {
@render()
template() {
return html`<h1>Sign in</h1>`;
}
}
A convention-driven component with public input, internal deep state, explicit form events, declarative styling, control flow, and differential updates.
Snice ships its own AI tooling: a version-matched skill for coding agents, a project doctor, and a source analyzer.
Install the skill per project from npm, or per harness straight from the repository.
# Install the skill matched to this project's Snice version
npx snice init-ai
# Writes:
# .agents/skills/snice/ the skill itself
# AGENTS.md, CLAUDE.md pointers to it
# Overwrite an existing install
npx snice init-ai --force# 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
# 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
Use init-ai when you work on one project and want the skill pinned to its Snice version — it reads node_modules/snice/docs/ai/, so the agent sees the docs for the version you actually have. Use the repository install when you move between Snice projects and want the skill always available.
# Diagnose configuration, imports, dependencies, and AI setup
npx snice doctor
# Run the source analyzer on its own
npx snice validate
# Everything above in one pass
npx snice check
validate catches the mistakes agents make most: an @element class that never extends HTMLElement, or an invented deep import like snice/decorators that was never a released package path.
Token-efficient copies of every reference page live in docs/ai/, mirroring these docs without the prose. The skill loads only the pages a task needs.