Sites & the seam¶
One declaration, four derivations¶
Every signal names the place its authoritative value lives. Everything else — the client code, the endpoints, the wire format, the trust boundary — is derived from that one word:
flowchart LR
SIG["name: Site of T = default"]
SIG --> L["Local"]
SIG --> U["Url"]
SIG --> SV["Server"]
SIG --> SY["Synced"]
L --> LT["compiled into the island —<br/>zero network"]
U --> UT["the query string IS the store —<br/>links; the server re-renders"]
SV --> ST["a facade on the server —<br/>await → POST command → region swap"]
SY --> SYT["RESERVED — local-first replica<br/>(see Status)"]
Local[T] — the browser owns it¶
Compiled to the island; reads and writes are S.get/S.set on the client signal store; never crosses the wire unless you explicitly send it with a command. Local[set] is first-class: id in selected compiles to Set.has, len(selected) to .size, mutation helpers keep updates immutable so change notification always fires. This is the site for view-state — the thing hypermedia stacks have no home for.
Url[T] — the address bar owns it¶
There is no cell: a client read goes to location.search live, and the server reads the same query params into your context(). You set it with a link (<a href="../?filter=active">); a client write to a Url signal is a CompileError, because the value is the URL.
The disclosed trade-off
Url state is shareable and survives reload — and changing it is a real navigation, which resets in-progress Local state (an open search, a selection). Neither behavior is wrong; the site is the choice. Put state in the URL exactly when you want link semantics.
Server[Facade] — the database owns it¶
Inside handlers, the bare name is the injected facade (typed by your Protocol); in the template, it is whatever your context() returned under that name. On the client it is nothing at all — see the seam below. Handlers that touch it are commands: POST, re-render the one region, swap (Handlers & commands).
Synced[T] — reserved¶
The marker parses and the site is part of the design (a local-first replica reconciled by a sync engine), but no codegen or transport exists for it yet. Today's labelled stand-in for "shared, live" is mount_component(live=True) — SSE-driven region refetch across clients (wire protocol). The full list of limits is in Status & roadmap.
The seam¶
The client/server boundary is a compile-time invariant, enforced at three explicit points — these are real compiler messages:
>>> a client binder reads a Server signal
CompileError: server-sited 'items' cannot be read on the client;
DB-backed state stays on the server
>>> a client action assigns a Server signal
CompileError: cannot assign server-sited signal 'items' on the client
>>> a local handler loops over Server state
CompileError: refusing to compile a loop over server-sited 'items':
DB iteration stays on the server
…plus one structural guarantee that outranks all three: the emitter has no code path that lowers database I/O. An await facade.method(...) can only compile to a cmd() POST — there is no case to forget, no convention to slip past. And because a component tree is spliced into one module before emission, the guarantee holds across composition: a Server-derived value can't sneak into a child's client handler through a prop or a provider.
So how does server data reach the screen?¶
Three sanctioned paths, each explicit in the source:
- Server rendering. The template's Jinja renders rows from
context(); a command re-renders the region with fresh data. - Explicit per-row shipping. Inside a loop,
data-*attributes (or a projection) carry named fields the client may read —t.idin a row-scoped binder reads the row's dataset. - Signal patches. A server handler pushes values into client signals via
signal("name", value)/reset("name"), riding the command response header.
What you can never do is accidentally any of these. That's the point.