Skip to content

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

search: Local[str] = ""
selected: Local[set] = set()
dialog_open: Local[bool] = False

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

filter: Url[str] = "all"

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

issues: Server[IssuesFacade]

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:

  1. Server rendering. The template's Jinja renders rows from context(); a command re-renders the region with fresh data.
  2. Explicit per-row shipping. Inside a loop, data-* attributes (or a projection) carry named fields the client may read — t.id in a row-scoped binder reads the row's dataset.
  3. 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.