No description
  • JavaScript 74.2%
  • Go 24.7%
  • HTML 1.1%
Find a file
2026-07-21 10:35:58 -03:00
docs reference §3.6: slots pass content through a wrapper 2026-07-20 22:23:37 -03:00
public live props, morphed re-renders, declarative @event handlers, load chrome 2026-07-20 22:23:01 -03:00
v1 live props, morphed re-renders, declarative @event handlers, load chrome 2026-07-20 22:23:01 -03:00
go.mod light: server-delivered shadow-DOM islands with bus-based communication 2026-07-16 09:32:31 -03:00
LICENSE Add MIT license 2026-07-21 10:35:58 -03:00
light Document light's client, protocol, and patterns 2026-07-16 22:39:36 -03:00
light.go Document light's client, protocol, and patterns 2026-07-16 22:39:36 -03:00
main.go Document light's client, protocol, and patterns 2026-07-16 22:39:36 -03:00
README.md SKILL.md: teach LLM agents the contract and idioms 2026-07-20 22:38:41 -03:00
SKILL.md SKILL.md: teach LLM agents the contract and idioms 2026-07-20 22:38:41 -03:00

light

light is a client-side component framework in a single file, with no build step and no dependencies. The server delivers components — ES modules bundling markup, style, and behavior — and light mounts them in the page as isolated shadow-DOM islands. Components communicate through named event buses, and a bus can be connected to the backend over a websocket or a polled endpoint, so in-app and client/server messaging share the same API.

The backend can be written in any language: it serves the component modules and, for live features, speaks a small JSON protocol described below. This repository includes a reference backend in Go.

go run .    # demo backend — open http://localhost:4173 in two windows

Documentation

  • docs/reference.md — complete client API: every element, field, method, option, and console message
  • docs/protocol.md — the wire contract and how to write a backend in any language (+ the Go hub's API)
  • docs/patterns.md — the idioms: families and libraries, events up/calls down, sticky state, theming
  • SKILL.md — a loadable skill that teaches LLM agents the contract and idioms up front, so they build with light correctly on the first try

The README below is the narrative tour; the docs are the exhaustive version.

Pinned builds live in v1/ — serve the folder (the demo backend exposes it at /v1/) and import one file:

<script type="module">
  import { light } from '/v1/light.min.js';   // 7.2 KB, ~3.4 KB gzipped
</script>

The build is capped at 8 KB minified, so the framework plus a page fit inside one initial TCP congestion window (~14 KB).


Why

light is built around two goals.

1. Components you can understand in one read. A component is a single file holding its markup, styles, and behavior, and its isolation is enforced by the platform, not by convention: styles cannot leak in or out, and DOM queries cannot reach outside the component. Understanding or modifying a component never requires reading a global stylesheet, a theme layer, or the rest of the page — everything true about the component is in its file. There are no specificity conflicts to untangle, no dozens of overlapping CSS declarations to trace. This is as deliberate for LLMs as for people: an agent can safely edit a component after reading only that component, with a guarantee — rather than a hope — that nothing else is affected.

2. One way to communicate. Sibling widgets, parent and child, and the backend all talk through the same primitive: emit and on over named buses. Effects propagate through subscriptions instead of hand-written wiring, and how a bus is routed — scoped to a subtree, page-global, or connected to the server — is declared outside the components that use it. Client and server state live in the same layer: sticky bus events carry the current value to anything that mounts, whenever it mounts.

A consequence of the first goal, unplanned but welcome: sharing a component is sharing a file. Since a component has no imports, no build step, and no external stylesheet, a component library is just a folder of files — copy one, or reference it by URL, and it works. There is no dependency tree to manage. One file: all the HTML, CSS, and JS in one place.

Hello, component

A component is an ES module living at a URL:

// served at /components/hello
import { html } from '/v1/light.min.js';

export default {
  css: `p { color: hotpink; }`,

  render: props => html`<p>hello, ${props.name}!</p>`,

  mount(root, props, ctx) {
    // the DOM from render() exists now — attach listeners, subscribe to buses
  },
};

The html tag escapes every interpolation, so props and data are inert text by default. Nested html results and arrays of them compose without double-escaping; raw() embeds a string as markup when you really mean it. Plain strings are also accepted from render — escaping is then on you.

Use it anywhere — in a page, or in another component's render() — as a tag. Attributes become props:

<x-light src="/components/hello" name="world"></x-light>

When the tag enters the DOM, light fetches the module, attaches a shadow root, applies the CSS, injects the rendered markup, and calls mount.

Anatomy of a component

Every field is optional:

field what it is
css styles for this component, parsed once and shared by all instances
render(props) pure function: props in, HTML string out
mount(root, props, ctx) behavior hook, called once when the DOM is live; may return @event handlers
on static @event handlers (see Declarative events)
update(props, ctx) called when attributes change after mount; default is a morphed re-render
provides: ['name'] declares scoped buses for this component's subtree

mount receives:

  • root — the component's shadow root. Both styles and DOM are isolated at this boundary: root.querySelector only sees this component's own markup, outside styles don't leak in, and the component's styles don't leak out. Class names and ids only need to be unique within the component. (Isolation is structural, not a security boundary: a component module is plain JavaScript running with normal page privileges — it is your server's code, exactly as trusted as any script you serve.)
  • props — the host tag's attributes, as a plain object of strings. Props are live: changing an attribute after mount re-renders the component (or calls its update hook), so a parent pushes new configuration down by writing attributes on the child's tag. State that changes without a parent can live on a bus (sticky) and trigger ctx.rerender() from inside. src itself is the component's identity and changing it after mount has no effect (light warns in dev builds); remove the element and create a new one to swap components.
  • ctx — framework services: { buses, provide, signal, host, rerender }.

Cleanup is automatic. When the element is removed from the DOM, ctx.signal (an AbortSignal) fires: all bus subscriptions made through ctx.buses are released. Use the same signal for anything else you start:

mount(root, props, { signal }) {
  const id = setInterval(tick, 2000);
  signal.addEventListener('abort', () => clearInterval(id));
}

Moving a component in the DOM (reordering a list, drag & drop) is transparent: the element reconnects within the same task, nothing is aborted, and its DOM, listeners, and subscriptions survive intact. Removing an element and reinserting it later is a fresh mount from scratch — no state handover, same rule as a re-rendered subtree.

While a module loads, the element shows a throbber in its shadow root — loading="off" disables it, loading="some text" shows that text instead. A failed load renders an error box with the failing src.

Re-rendering

render is not one-shot. ctx.rerender() runs it again and morphs the result into the live DOM — matching nodes are kept and synced in place, so focus, selection, and input contents survive, and unchanged child components are not remounted:

mount(root, props, ctx) {
  let count = 0;
  root.querySelector('button').addEventListener('click', () => {
    count++;
    ctx.rerender();            // patches the DOM, keeps the <input> focused
  }, { signal: ctx.signal });
}

Changing a host attribute after mount re-renders too — a parent re-render that writes new attributes onto a child's tag propagates by itself. Components that prefer manual DOM updates define update(props, ctx) instead, and attribute changes call it rather than re-rendering.

Declarative events

Render output can bind events with @event attributes; the handler is looked up in mount's return value merged over the component's on field:

export default {
  render: p => html`<button @click="save">save</button>`,
  mount(root, props, ctx) {
    return {
      save(e, el) { ctx.buses.api.post('items', collect(root)); },
    };
  },
};

Handlers receive (event, el), ride on the component's abort signal (no cleanup), and are re-wired after every morph without duplicating. Manual addEventListener(..., { signal }) in mount remains available for anything else.

Composition

A parent renders children as tags. Children need no imports or registration — the custom-element lifecycle loads them, in parallel, and repeated component types share one module fetch:

render: () => `
  <h1>crew quarters</h1>
  <x-light src="/components/chat" name="Port Pod"></x-light>
  <x-light src="/components/chat" name="Starboard Pod"></x-light>
`,

Mounting at runtime is plain DOM:

const el = document.createElement('x-light');
el.setAttribute('src', '/components/monitor');
someContainer.append(el);

One file, many components

A module can export more than one component. The default export answers the URL itself; named exports are addressed with a URL fragment:

// /components/chat
export default {
  render: () => `
    <x-light src="#log"></x-light>
    <x-light src="#input"></x-light>
  `,
};

export const log   = { css: `...`, render: () => `...`, mount() {} };
export const input = { css: `...`, render: () => `...`, mount() {} };
src resolves to
/components/chat the module's default export
/components/chat#log the module's log export
#log the log export of the enclosing component's module

The fragment never reaches the network: however many components a module exports, it is fetched once, and every reference on the page shares that one fetch. Children declared with fragments mount with no additional requests — a composite widget arrives whole.

The bare #name form keeps a file self-contained: it never mentions its own URL, so it can be renamed, copied, or served from a different path without editing its contents.

Two shapes earn a shared file. A family: a component and the private parts only it uses. And a library: a file of self-contained primitives referenced from everywhere —

<x-light src="/lib#prim-panel" heading="SETTINGS" closable style="--accent: #7fdb8f">
  <x-light src="/lib#form-input" placeholder="search…"></x-light>
</x-light>

Library primitives stay context-free: they signal up with bubbling DOM events (press, field:submit, panel:close), take configuration as props, and expose their colors as CSS custom properties so consumers theme them from the tag. However many components use the library, it costs one fetch.

A grab-bag of unrelated components is neither shape — it trades away the one-file-one-read property that makes components easy to work on.

Parent and child: events up, calls down

A child announces things by dispatching a DOM event that bubbles out through its shadow boundary:

// child
root.host.dispatchEvent(new CustomEvent('panel:close', { bubbles: true, composed: true }));

// parent — event retargeting makes e.target the child's <x-light> element
root.addEventListener('panel:close', e => e.target.remove());

Parents act on children by selecting the child element and calling it directly.

Buses

For components that don't share a parent/child relationship, ctx.buses.<name> is a named channel, created on first use:

buses.cart.emit('item-added', { sku: 'x-99' });
buses.cart.on('item-added', e => badge.textContent = e.detail.sku);

Subscriptions are bound to the component's lifetime and need no manual cleanup.

An emit can be sticky: the last payload is stored and replayed immediately to any later subscriber. Use this for anything that represents current state rather than a one-off occurrence:

buses.session.emit('login', user, { sticky: true });
// a component mounted later still learns who is logged in, immediately

Sticky means the same thing everywhere. On a page-local bus the page remembers; on a server-connected bus the flag crosses the wire and the server remembers, so a client that connects later gets caught up too. One option, one mental model — a client-side todo list and a multi-window chat use the exact same call.

Scoped buses

By default, bus names are global. If a component uses a bus internally and may appear on the page more than once, scope the bus to its subtree with provides:

export default {
  provides: ['thread'],   // this instance's subtree gets its own 'thread' bus
  render: p => `
    <x-light src="#log"></x-light>
    <x-light src="#input"></x-light>
  `,
};

Bus names resolve upward through the tree: scoped → global → server-connected. Consumers always write buses.thread and get the nearest bus with that name, so the same component works unchanged in any context:

<x-light src="#log"></x-light>                             <!-- parent's scoped bus -->
<x-light src="/components/chat#log" bus="comms"></x-light> <!-- a global, server-connected bus -->

Server buses

To connect a bus to the backend, declare it once in the page:

<script type="module">
  import { light } from '/v1/light.min.js';

  light.serverBus('station', { ws: '/light/ws' });                  // live push
  light.serverBus('comms',   { ws: '/light/ws' });                  // same socket, multiplexed
  light.serverBus('news',    { poll: '/api/news', every: 10_000 }); // polled endpoint
  light.serverBus('api',     { rest: '/api' });                     // request/response verbs
</script>

Components subscribe to server buses exactly like any other bus:

mount(root, props, { buses }) {
  buses.station.on('telemetry', e => updateGauges(e.detail));
}

Transport semantics:

  • WebSocket buses are server-authoritative. A client emit is sent to the server and fires locally only when the server broadcasts it back, so every client observes the same event stream in the same order.
  • Poll buses fetch the endpoint on the given interval and emit whatever it returns. Client emits are POSTed to the endpoint and also fire locally right away.
  • All websocket buses share one connection per URL. The connection reconnects with exponential backoff; connection state is published as a sticky server:status event on buses.system.

A server bus is a page bus whose subscribers span clients: the same emit /on calls, the same sticky semantics, the same cleanup — the only difference is where the bus is declared, and that happens in the app shell, not in components.

The framework also emits component:mounted and component:unmounted on buses.system for every component on the page.

Requests: HTTP verbs on a bus

Events carry state; requests carry commands. A bus connected with { rest: url } exposes the verbs — the message path is appended to the bus root, so it's plain REST any backend router handles natively:

const { target } = await buses.api.get('temp');        // GET  /api/temp
await buses.api.put('temp', { target: 22 });            // PUT  /api/temp
await buses.api.post('orders', { items });              // POST /api/orders
await buses.api.delete('orders/42');                    // DELETE /api/orders/42

Replies are parsed JSON (or null for 204); non-2xx rejects. get sends its detail as query parameters. The intended split: commands request, state broadcasts — a mutation goes up as a verb, and the resulting state change comes back to every client through a push or poll bus, not through the reply.

Backend integration

The full client/server contract is one JSON shape:

websocket text frame, both directions:
  {"bus": "comms", "type": "post", "detail": {...}, "sticky": true?}

poll bus:
  GET  →  {"type": "...", "detail": {...}}   (or an array of them)
  POST ←  {"type": "...", "detail": {...}}   (client emits)

Server semantics mirror a page-local bus, stretched across clients:

  • A client frame with no registered handler is relayed to every client as-is — sender included, so all clients share one event order. A chat or presence bus needs zero server code.
  • Frames marked sticky are remembered per (bus, type) and replayed to each client that connects, so new clients receive current state immediately — just like late subscribers on a page.
  • Register a handler for a (bus, type) to intercept instead: validate, persist, rate-limit. The handler then owns whether and what to relay.

Go (this repository)

light.go implements the protocol in pure stdlib, including the websocket framing, in about 300 lines. Server-side usage mirrors the client API:

h := newHub()
http.Handle("/light/ws", h)

// push to all clients; late joiners receive the latest value on connect
station := h.bus("station")
station.emitSticky("telemetry", state)

// optional: intercept client emits — validate, persist, then relay (or not)
comms := h.bus("comms")
comms.on("post", func(detail json.RawMessage) {
    if acceptable(detail) {
        comms.emit("post", detail)
    }
})

The demo's STATION COMMS bus registers no handler at all — the hub's relay does everything. Before exposing a hub beyond localhost, add an Origin check and per-bus authorization to ServeHTTP; as written, every connected client receives every frame.

Content Security Policy

Components are real ES modules — no eval, no inline script — so light runs under a strict policy. The demo backend sends one:

Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:

style-src 'unsafe-inline' is only needed for inline style="--accent:…" theming on tags; keep bus wiring in a file (app.js), not an inline script.

Any other language

A poll bus works with any endpoint that returns the expected shape:

# python/flask
@app.get("/api/news")
def news():
    return {"type": "notice", "detail": {"text": latest_headline()}}
light.serverBus('news', { poll: '/api/news', every: 10_000 });

The demo — Lighthouse Station

try this what it shows
watch TELEMETRY server-pushed state: the component holds a subscription, not a fetch loop
adjust CLIMATE rest bus verbs: PUT /api/temp, then the cabin drifts toward the target via the push bus
open two windows, use STATION COMMS client → server → all clients with zero per-bus server code (hub relay); new windows see the last post via sticky replay; the Send button throbs until the server round-trips your post
type in one chat pod a scoped thread bus — the other pod (and the server) never sees the message; the pod itself mirrors a copy to SYSTEM LOG
click + monitor runtime mounting + sticky replay: the new chip shows data immediately
click ✕ on a pod an event bubbles up, the parent removes the child, the unmount cascade appears in SYSTEM LOG
restart the Go server SYSTEM LOG shows the disconnect, backoff reconnect, and state resync
ANNOUNCEMENT bar a poll bus: a plain JSON endpoint re-fetched every 10 seconds

Current limitations

  • Load waterfall: each nesting level costs a round trip, since a parent must load before its children are discovered. Servers can mitigate with <link rel="modulepreload"> hints for known subtrees.
  • No state handover: removing and reinserting a component remounts it from scratch. (Moving one — disconnect and reconnect within the same task — is transparent and preserves everything.)
  • Buses are cooperative, not access-controlled — scoping prevents accidental collisions, not deliberate interference from other page code.
  • No auth or rooms on server buses yet: every connected client receives every frame. Per-connection subscriptions belong in the server hub when needed.