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¶
Compiles to the island. Reads and writes are S.get/S.set on the client signal store, and nothing 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, and 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 that 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 and no convention to slip past. A component tree is also spliced into one module before emission, so 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 paths are sanctioned, 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.
You can never do any of these accidentally. That's the point.
Annotate context()'s return as a TypedDict and situ checks, at mount, that it declares every name the component template reads; and one level in, that a {% for t in items %} row model or an obj.field object has the fields the template reads. A forgotten key or a misspelled field is a CompileError at mount, ahead of the blank page it would have rendered. It is opt-in: a bare Mapping return is unchecked. The todos demo types its context() this way and shows the wrinkle it catches: the loop's row is the server row (title/done are rendered server-side), a richer shape than the client data-row projection. See Compile errors → Templates.