snice
v7.11.0
Published
A decorator-driven web component library with routing, controllers, daemons, and 130+ UI components. For better coding-agent results, run npx snice init-ai.
Maintainers
Keywords
Readme
Snice
A decorator-driven web component library with differential rendering, routing, controllers, and 130+ ready-made UI components. Use as much or as little as you want. Zero dependencies, works anywhere.
Quick Start
npx snice create-app my-app
cd my-app
npm run devGenerated apps include the Snice agent skill plus matching AGENTS.md and
CLAUDE.md pointers. Add them to an existing app with:
npx snice init-ai
npx snice checkcheck verifies installation and TypeScript configuration, catches common
generated-code mistakes, and recommends existing Snice components when an app
reimplements them. doctor and validate expose its focused halves.
Templates
# Default - routing, auth, guards, middleware, services
npx snice create-app my-app
# React + Snice - React router, hooks, guards, layouts
npx snice create-app my-app --template=reactWhat's in the Box
Basic Building Blocks
@page({ tag: 'user-profile-page', routes: ['/users/:userId'], guards: [isAuthenticated] })
class UserProfilePage extends HTMLElement { ... }
@element('user-stats')
class UserStats extends HTMLElement { ... }
@controller('real-time-user-loader')
class RealTimeUserLoader { ... }
// And within these classes, use decorators like:
@property() name = 'default';
@render() fn() { return html`...`; }
@styles() fn() { return css`...`; }
@ready() async fn() { ... }
@reconnect() fn() { ... }
@dispose() fn() { ... }
@watch('name') fn(oldVal, newVal) { ... }
@query('input') input!: HTMLInputElement;
@queryAll('.item') items!: NodeListOf<HTMLElement>;
@on('click', 'button') fn(e: Event) { ... }
@dispatch('value-changed') fn(val: string) => Event Detail
@context() fn(ctx: Context) { ... }
@request('user') async *fn(): Response<User> { return yield {}; }
@respond('user') fn(req) => Response;1. Cross-Cutting Concerns: Router + Context
// sample-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
import { Router } from 'snice';
const { page, navigate, initialize } = Router({
target: '#app',
context: new AppContext(), // Global state
type: 'hash'
});
// Any page can access context
@page({ tag: 'dashboard-page', routes: ['/dashboard'] })
class DashboardPage extends HTMLElement {
private appContext?: AppContext;
@context()
handleContext(ctx: Context) {
this.appContext = ctx.application;
const user = this.getUser();
}
// ...
}2. Pages: Orchestrating Intent
// pages/user-profile-page.ts
@page({ tag: 'user-profile-page', routes: ['/users/:userId'] })
class UserProfilePage extends HTMLElement {
@property()
userId = ''; // From URL parameter
@property({ type: Object })
user = null;
@property({ type: Object })
userStats = null;
@ready()
async loadUserData() {
// Pages handle data fetching, elements just display
const [user, stats] = await Promise.all([
fetch(`/api/users/${this.userId}`).then(r => r.json()),
fetch(`/api/users/${this.userId}/stats`).then(r => r.json())
]);
this.user = user;
this.userStats = stats;
}
@render()
renderContent() {
return html`
<page-header .user=${this.user}></page-header>
<user-stats .stats=${this.userStats}></user-stats>
<user-activity .userId=${this.userId}></user-activity>
`;
}
}3. Elements: Pure Presentation
// elements/user-stats.ts
@element('user-stats')
class UserStats extends HTMLElement {
@property({ type: Object })
stats = null;
@render()
renderContent() {
if (!this.stats) return html`<div>Loading...</div>`;
return html`
<div class="stats">
<div class="stat">
<span class="label">Views</span>
<span class="value">${this.stats.views}</span>
</div>
<div class="stat">
<span class="label">Followers</span>
<span class="value">${this.stats.followers}</span>
</div>
</div>
`;
}
@styles()
statsStyles() {
return css`
.stats { display: flex; gap: 2rem; }
.stat { text-align: center; }
`;
}
}
// Usage in parent page (which handles data fetching):
// <user-stats .stats=${this.userStats}></user-stats>4. Controllers: Behavior Management
// controllers/real-time-user-loader.ts
@controller('real-time-user-loader')
class RealTimeUserLoader {
async attach(element: IUserList) {
this.socket = new WebSocket('/api/users/stream');
this.socket.onmessage = (e) => {
element.setUsers(JSON.parse(e.data));
};
}
// ...
}
// controllers/cached-user-loader.ts
@controller('cached-user-loader')
class CachedUserLoader {
async attach(element: IUserList) {
const cached = localStorage.getItem('users');
if (cached) element.setUsers(JSON.parse(cached));
}
// ...
}
// elements/user-list.ts - stays the same
@element('user-list')
class UserList extends HTMLElement {
setUsers(users: User[]) {
this.users = users;
// ...
}
@render()
renderContent() {
return html`
<ul>${this.users.map(u => html`<li>${u.name}</li>`)}</ul>
`;
}
}Usage - swap behavior without touching presentation. Bind the controller class directly in a template:
html`<user-list controller=${RealTimeUserLoader}></user-list>`
html`<user-list controller=${CachedUserLoader}></user-list>`Class bindings reflect the decorator name in the DOM (for example,
controller="real-time-user-loader") so DevTools still shows which controller
is active. The class reference remains the attachment authority; the reflected
attribute is diagnostic and does not cause a second registry attachment.
Registry names still work everywhere, and are the channel for raw HTML markup:
<user-list controller="real-time-user-loader"></user-list>
<user-list controller="cached-user-loader"></user-list>Key Features
Differential Rendering - Only updates changed parts of the DOM, not entire components
Auto-Rendering - Components automatically re-render when properties change
Template Syntax - Clean html\...`andcss`...`` tagged templates
Type Safety - Full TypeScript support with decorator-based APIs
Zero Dependencies - No external runtime dependencies
Standards-Based - Built on web components, works with any library or framework
Core APIs
Class Decorators
@element('tag-name') - Define reusable UI components
@element('my-button')
class MyButton extends HTMLElement { }@page({ tag, routes }) - Define routable pages
@page({ tag: 'home-page', routes: ['/'] })
class HomePage extends HTMLElement { }@controller('controller-name') - Define behavior modules
@controller('data-loader')
class DataLoader {
async attach(element) { }
async detach(element) { }
}Rendering
@render(options?) - Define a component template (auto re-renders on property changes)
@render()
renderContent() {
return html`<div>${this.data}</div>`;
}SniceElement - Optional convention-driven base class with render(), static styles, kebab-case attributes, and imperative invalidation
@element('user-card')
class UserCard extends SniceElement {
static styles = css`:host { display: block; }`;
@state() selected = false;
render() {
return html`<article class:selected=${this.selected}>...</article>`;
}
}@styles() - Define scoped styles
@styles()
componentStyles() {
return css`.container { padding: 1rem; }`;
}await el.rendered - Wait for the pending render to commit
el.count = 42;
await el.rendered; // resolves after the batched (or debounced/throttled)
// render commits; resolves immediately when idlesetStrictRenderErrors(true) - Rethrow render errors instead of logging them. Off by default (errors log and the element keeps its previous DOM); turn on in tests and dev so broken templates fail loudly.
Properties & State
@property(options?) - Reactive properties that sync with attributes
@property()
name = 'default';
@property({ type: Boolean })
enabled = false;
@property({ type: Number, reflect: false })
page = 1; // accepts the attribute, does not reflect property writes
@state()
open = false; // reactive internal state, never an attribute
@state({ deep: true })
model = { rows: [] }; // nested object/array/Map/Set writes re-renderProperty type conversion applies at the string attribute boundary. Direct JavaScript assignments preserve the assigned type and object identity.
@watch(...propertyNames) - React to property changes
@watch('name')
onNameChange(oldVal, newVal) {
console.log(`Name changed from ${oldVal} to ${newVal}`);
}Lifecycle
@ready() - Runs once, after initial render completes
@ready()
async initialize() {
// Fetch data, set up listeners, etc.
}@reconnect() - Runs on every connect AFTER the first
@reconnect()
onReconnect() {
// Re-attach long-lived global subscriptions (document listeners, etc.)
// that @dispose tears down. Framework-managed @on/@observe handlers
// are re-established automatically and don't need this hook.
}@dispose() - Runs every time element is removed from DOM
@dispose()
cleanup() {
// Clean up listeners, close connections, etc.
}DOM Queries
@query(selector) - Query single element from shadow DOM
@query('input')
input!: HTMLInputElement;@queryAll(selector) - Query multiple elements from shadow DOM
@queryAll('.item')
items!: NodeListOf<HTMLElement>;Events & Communication
Template Events - Handle events directly in templates (with keyboard modifiers!)
html`
<button @click=${this.handleClick}>Click</button>
<input @keydown:Enter=${this.submit} />
<input @keydown:ctrl+s=${this.save} />
`@on Decorator - Event delegation with selectors
// Works in both elements AND controllers
@on('click', 'button') // Event delegation
handleClick(e: Event) {
console.log('Button clicked!');
}
@on('keydown:Enter', 'input') // Keyboard modifiers
handleEnter(e: KeyboardEvent) {
this.submit();
}
@on('input', 'input', { debounce: 300 }) // Debounce support
handleInput(e: Event) {
this.search((e.target as HTMLInputElement).value);
}@dispatch(eventName) - Auto-dispatch custom events after method execution
@dispatch('value-changed')
setValue(val: string) {
this.value = val;
return { value: val }; // Event detail
}Global State
@context(options?) - Receive Router context updates on pages, descendant
elements, and attached controllers. Controllers can use the same
middleware-aware ctx.fetch as pages; context-dependent controller startup
belongs in the @context() handler because managed decorators activate after
attach().
// Method decorator that receives context updates
@context()
handleContext(ctx: Context) {
this.appContext = ctx.application;
this.requestRender();
}
// With timing options
@context({ debounce: 300 })
handleContextDebounced(ctx: Context) {
// Called after 300ms of no updates
}
@context({ throttle: 100 })
handleContextThrottled(ctx: Context) {
// Called at most once per 100ms
}
@context({ once: true })
handleContextOnce(ctx: Context) {
// Called only once, then unregisters
}Context Object Structure:
interface Context {
application: AppContext; // Your router context
navigation: {
placards: Placard[]; // Page metadata
route: string; // Current route
params: Record<string, string>; // Route parameters
};
update(): void; // Notify all subscribers
}Triggering Context Updates:
When you modify the application context, call update() to notify all subscribers:
@page({ tag: 'login-page', routes: ['/login'] })
class LoginPage extends HTMLElement {
private ctx?: Context;
@context()
handleContext(ctx: Context) {
this.ctx = ctx;
this.requestRender();
}
login(user: User) {
// Modify the application context
this.ctx!.application.setUser(user);
// Notify all @context subscribers
this.ctx!.update();
}
}Note: The router calls update() automatically during navigation. Only call it manually when you change application state (like login/logout, theme changes, etc.).
Request/Response
For the few cases where elements need to request data from controllers (like fetching user info or current state), Snice provides a request/response pattern:
@request(channel) - Make requests to controllers from elements
@respond(channel) - Respond to requests from elements in controllers
This pattern is useful when:
- Elements need to fetch data without direct controller access
- You want to keep elements decoupled from specific controller implementations
- Multiple elements may request the same data
Example:
// Controller responds to requests
@controller('app-controller')
class AppController {
private currentUser = { name: 'Alice', role: 'admin' };
@respond('user')
getUserData() {
return this.currentUser;
}
}
// Element makes requests
@element('user-badge')
class UserBadge extends HTMLElement {
@state() private user: User | null = null;
@request('user')
async *getUser(): Response<User> {
return yield {};
}
@ready()
async init() {
const user = await this.getUser();
this.user = user;
console.log('Current user:', user);
}
@render()
renderContent() {
return html`<div>Welcome, ${this.user?.name ?? 'Guest'}!</div>`;
}
}
// A parent template attaches the responder by class.
html`<user-badge controller=${AppController}></user-badge>`;See Request/Response documentation for details.
Daemons
Daemons are explicitly constructed, app-owned objects with state and a lifecycle. They support both request/response and event communication without requiring an element or a global singleton:
@daemon
class SessionDaemon {
@respond('get-session')
getSession() { return this.session; }
@dispatch('session-changed')
changed() { return this.session; }
}
const session = new SessionDaemon();
const release = provideContext(appRoot, { daemons: { session } });
// Elements/controllers use the context address, not SessionDaemon.
@request('get-session', { daemon: 'session' })
async *loadSession(): Response<Session | null> {
return yield {};
}Router provides its context through the same mechanism. There is no implicit
construction or global registry, and multiple instances remain independent. See
Daemons for lifecycle, testing, and both communication models.
Project Organization
Keep application roles easy to find: pages in src/pages/, reusable visual
elements in src/components/, attached behavior in src/controllers/, and
app-owned services in src/daemons/. src/router.ts constructs and exports the
router pieces; src/main.ts imports page modules and calls initialize().
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 in the project's chosen location. snice check reports
page-host controllers, manual URL parsing, and decomposition opportunities while
treating broken Router construction as an error.
Template Syntax
Auto-Rendering with Differential Updates
@element('counter-display')
class CounterDisplay extends HTMLElement {
@property({ type: Number })
count = 0;
@render()
renderContent() {
return html`
<div class="counter">
<span class="count">${this.count}</span>
<button @click=${this.increment}>+</button>
</div>
`;
}
@styles()
counterStyles() {
return css`.counter { display: flex; gap: 1rem; }`;
}
increment() {
this.count++;
// Auto re-renders! Only <span class="count"> updates
}
}Key Points:
- Properties trigger automatic re-renders
- Only changed parts update (differential rendering)
- Event handlers:
@click=${this.method} - Batched updates (multiple changes = single render)
Property Binding
Use .property=${value} to set element properties directly:
html`
<input .value=${this.text} />
<custom-element .complexData=${this.dataObject}></custom-element>
`Boolean Attributes
Use ?attribute=${boolean} for boolean attributes:
html`
<button ?disabled=${this.isLoading}>Submit</button>
<input type="checkbox" ?checked=${this.isChecked} />
`Conditionals
// Ternary operator
html`
${this.isLoggedIn
? html`<span>Welcome!</span>`
: html`<a href="/login">Login</a>`
}
`
// Virtual if / else-if / else retains each branch's DOM
html`
<if ${this.isLoggedIn}>
<span>Welcome, ${this.user.name}!</span>
<button @click=${this.logout}>Logout</button>
<else-if ${this.sessionExpired}>
<button @click=${this.signIn}>Sign in again</button>
</else-if>
<else>
<a href="/login">Login</a>
</else>
</if>
`
// <case>/<when>/<default> for multiple branches
html`
<case ${this.status}>
<when value="loading">
<span>Loading...</span>
</when>
<when value="success">
<span>Success!</span>
</when>
<when ${OFFLINE_STATE}>
<span>Offline</span>
</when>
<when value="error">
<span>Error occurred</span>
</when>
<default>
<span>Unknown status</span>
</default>
</case>
`Lists
html`
<ul>
${this.items.map(item => html`
<li @click=${() => this.select(item.id)}>${item.name}</li>
`)}
</ul>
`Add a key binding when list items hold state (inputs, media, animations). Keyed items keep their DOM when the list reorders or items are removed; unkeyed lists reuse DOM by position:
${this.items.map(item => html`<li key=${item.id}>${item.name}</li>`)}Use repeat() for an explicit wrapper-free keyed collection with duplicate-key validation and an empty state:
${repeat(this.items, {
key: item => item.id,
render: item => html`<li>${item.name}</li>`,
empty: () => html`<li>No results</li>`
})}Declarative Bindings
updateQuery = (event: InputEvent) => {
this.query = (event.currentTarget as HTMLInputElement).value;
};
html`<input
.value=${this.query}
@input=${this.updateQuery}
class:invalid=${this.invalid}
style:--field-accent=${this.accent}
@keydown.enter|prevent=${this.submit}
...attrs=${this.aria}
...events=${this.listeners}
>`- Property bindings and event handlers keep both form-data directions explicit.
...props,...attrs, and...eventsupdate named channels and remove stale keys.- Event modifiers:
prevent,stop,immediate,once,capture,passive, andself.
Promises and async iterables render directly in node expressions. Use repeat() for explicit keyed identity and an empty state.
See Declarative Rendering for control flow, deep reactivity, render roots, and metadata. See Binding Channels for exact node, attribute, property, event, class, style, spread, sentinel, and form semantics.
Template Helpers
classMap(obj) / styleMap(obj) - Build class and style strings from objects
html`<div
class="${classMap({ box: true, 'box--active': this.active })}"
style="${styleMap({ color: this.color, fontWeight: 'bold' })}"
></div>`
// styleMap converts camelCase to kebab-case; --custom-props pass throughlive(value) - Compare a property binding against the live DOM value
html`<input .value=${live(this.text)} />`
// Without live(), re-rendering with an unchanged bound value skips the DOM
// write, leaving user-typed text in place. live() resets the input.svg\...` - SVG fragments
html`<svg viewBox="0 0 100 100">
${this.points.map(p => svg`<circle cx=${p.x} cy=${p.y} r="4"></circle>`)}
</svg>`
// Bare SVG elements need svg`` to parse in the SVG namespace.
// Full inline <svg>...</svg> blocks inside html`` work as-is.Keyboard Shortcuts
html`
<input @keydown.enter=${this.submit} />
<input @keydown.ctrl+s=${this.save} />
<input @keydown.ctrl+shift+s=${this.saveAs} />
<input @keydown.escape=${this.cancel} />
<input @keydown.~enter=${this.submitAny} />
`Keyboard syntax:
@keydown.enter- Plain Enter (no modifiers)@keydown.ctrl+s- Ctrl+S combination@keydown.~enter- Enter with any modifiers@keydown.down- Arrow keys (up, down, left, right)@keydown.escape- Escape key
Router
// main.ts
const { page, navigate, initialize } = Router({
target: '#app',
context: new AppContext()
});
// pages/home-page.ts
@page({ tag: 'home-page', routes: ['/'] })
class HomePage extends HTMLElement {
@render()
renderContent() {
return html`<h1>Home</h1>`;
}
}
// pages/user-page.ts
@page({ tag: 'user-page', routes: ['/users/:userId'] })
class UserPage extends HTMLElement {
@property()
userId = ''; // Auto-populated from URL
// ...
}
// main.ts
initialize();
navigate('/users/123');Route Guards
Protect routes with guard functions:
const isAuthenticated: Guard<AppContext> = (ctx) => ctx.getUser() !== null;
@page({
tag: 'dashboard-page',
routes: ['/dashboard'],
guards: isAuthenticated
})
class DashboardPage extends HTMLElement { }Layouts
Layouts wrap pages with shared UI and dynamically build navigation from page metadata:
// layouts/app-shell.ts
@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)
.map(p => html`
<a href="#/${p.name}"
class="${this.currentRoute === p.name ? 'active' : ''}">
${p.icon} ${p.title}
</a>
`)}
</nav>
</header>
<main><slot name="page"></slot></main>
`;
}
// Called when route changes
update(appContext, placards, currentRoute, routeParams) {
this.placards = placards;
this.currentRoute = currentRoute;
// Property changes trigger re-render
}
}
// main.ts - configure router with layout
const { page, initialize } = Router({
target: '#app',
layout: 'app-shell'
});Pages render inside <slot name="page"></slot>. Layout persists, only page content swaps.
Placards
Page metadata that layouts use to build navigation, breadcrumbs, and help systems:
// pages/dashboard-page.ts
const placard: Placard<AppContext> = {
name: 'dashboard',
title: 'Dashboard',
icon: '📊',
order: 1,
searchTerms: ['home', 'overview', 'stats'],
hotkeys: ['ctrl+d'],
visibleOn: [isAuthenticated]
};
@page({
tag: 'dashboard-page',
routes: ['/dashboard'],
placard: placard
})
class DashboardPage extends HTMLElement { }Features:
- Navigation -
title,icon,order,show - Hierarchy -
parent,group,breadcrumbs - Discovery -
searchTerms,hotkeys,tooltip - Visibility -
visibleOnguards control who sees what
Layouts receive placard data in update() and auto-build navigation. See docs.
Using Snice Components in Other Environments
Standalone Builds (CDN)
Use any Snice component without a bundler — load the runtime first, then one .min.js bundle per component:
<script src="snice-runtime.min.js"></script> <!-- required, load first -->
<script src="snice-button.min.js"></script>
<snice-button variant="primary">Click me</snice-button>Full details — load order, theme.css + dark mode, bundle families — in CDN usage.
React Integration
All components have React adapters (React 17+):
import { Button, Input } from 'snice/react';
function MyComponent() {
const [value, setValue] = useState('');
return (
<div>
<Input
value={value}
onInputInput={(e) => setValue(e.detail.value)}
/>
<Button variant="primary" onClick={() => alert('Clicked!')}>
Submit
</Button>
</div>
);
}See DEVELOPMENT.md for build system details
Documentation
User Documentation
- Elements API - Defining elements, render roots, and extending existing elements
- Properties - Public input, internal state, attribute conversion, and reflection
- Lifecycle - Connection, readiness, teardown, and reacting to change
- Queries - Resolving elements with
@queryand@queryAll - Styling - Scoped CSS, host styling, and icons
- Theming - Design tokens, surfaces, and dark mode
- CLI - Scaffolding, diagnostics, source validation, and AI setup
- Utilities - Method decorators, escaping, durations, scroll lock, controller helpers
- Testing - Waiting on
ready/rendered, asserting events, swapping controllers - Declarative Rendering - Bindings, control flow, reactivity, render roots, and async values
- Binding Channels - Detailed DOM destinations, value/removal rules, events, spreads, sentinels, and forms
- Controllers API - Data fetching, business logic, and controller patterns
- Daemons - Explicit app-context state/lifecycle objects with request/response and event communication
- Routing API - Router setup, pages, route params, and navigation
- Guards and Layouts - Protecting routes, page wrappers, and transitions
- Placards API - Rich page metadata for dynamic navigation and discovery
- Events API - Event handling, dispatching, and custom events
- Request/Response API - Bidirectional communication between elements and controllers
- Observe API - Lifecycle-managed observers for external changes
- Fetcher - Context-aware fetch middleware for pages and controllers
- CDN - Load order, bundle families, and standalone usage without a bundler
- React Integration - Router, hooks, guards, and mixing React and Snice pages
- Code Block - The syntax-highlighting component, its grammar system, and highlighter API
Developer Documentation
- DEVELOPMENT.md - Build system, testing, and contributing to Snice
- Dumb-agent gauntlet - Maintainer-only local model loop for hardening
snice check
License
MIT
