Cheat sheet¶
The whole authoring surface on one page. Each section links to the full treatment.
The model, in 30 seconds¶
A component = name.py (sited signals + handlers) beside name.html (real HTML + :/@ binders). One mount call compiles the pair once and derives every route; the browser gets a 5–12 KB generated island over a shared shim. The site decides the transport. await in a handler makes it a server command. Everything outside the dialect is a CompileError.
Sites¶
| Declaration | Lives | Client | Server | Set by |
|---|---|---|---|---|
x: Local[T] = default |
browser | S.get/S.set, compiled |
— | code |
x: Url[str] = "all" |
query string | read-only | context(view) |
<a href="../?x=…"> |
x: Server[Facade] |
server | any access = CompileError |
facade in handlers, rows in template | commands |
x: Synced[T] |
reserved | — | — | — |
The .py side¶
from __future__ import annotations # required
from situ import Local, Url, Server # + Synced, Prop, Emit, Provide, Inject
search: Local[str] = ""
tags: Local[set] = set() # Local[set] is first-class
filter: Url[str] = "all"
issues: Server[IssuesFacade] # Protocol under TYPE_CHECKING
def clear(): # no await → compiled to client JS
global search # bare-name signal write: declare global
search = ""
async def close(id): # await → POST /cmd/close/{id}
await issues.set_status(id, "closed")
reset("search") # injected: back to defaults, rides the response
signal("toast", "Closed") # injected: push a value into a client signal
The .html side — two rules¶
Every reactive element lives in <header> (survives command swaps — search boxes, dialogs, toasts) or in the single <div data-region> (re-rendered by commands). data-region is that div's first attribute.
Events & binders¶
| Token | Does | Notes |
|---|---|---|
@click @dblclick @submit @input @escape @clickout |
listeners | @submit preventDefaults; @clickout fires on a click outside the element (pair it with the element the :show controls); :each rows allow only @click/@submit |
:text :show :class="c: expr" :attr="a: expr" |
DOM updates | :show sets hidden — keep [hidden]{display:none!important} in your CSS |
:bind="sig" |
two-way input | Local only; type=number coerces |
:each="r in list" |
keyed client reconciler | Local[list]; on a wrapper element; rows keyed by id |
:tree="r.children" |
self-recursive rows | inside a :each |
:virtual |
windowed rows via /window |
server handler + window_template= |
:autohide="sig" |
self-clearing status | |
<Tabs> <DataTable> <DataGrid> <Combobox> <Menu> <CommandPalette> |
kit widget components (the :table/:menu/… binders are internal wiring) |
load widgets.js; data stays in Locals |
:sortable="list" :cells="grid" |
drag-reorder / spreadsheet — binders you compose onto your own markup | load widgets.js |
Server rows: {% for t in issues %} + data-row="Issue" data-id="{{ t.id }}" data-title="{{ t.title }}" → binders on/inside the row may read t.id, t.title. A row the client reads must declare data-row="<Record>" + a matching TypedDict (class Issue(TypedDict): id: int; title: str), so each DOM string is decoded to its real type; declui derives it, the unified idiom's ~ Issue[id, title] is it. Full table
The dialect (client-compiled Python)¶
Typed: every construct compiles from a rule verified against CPython, so it means in the browser what it means in Python — or it is a CompileError. (Truthiness is Python's: []/{} are falsy, so :show="not items" shows on an empty list.)
Expressions: names · literals · and or not (return an operand, Python's truthiness) · == != (structural for list/dict/set; int == str is dead code → error) · < <= > >= (1 < 'a' raises in Python → error) · + - * / // % (% floor-mods; 'ab' * 3 repeats; / throws on ÷0) · x if c else y · in/not in (substring / list-scan / Set.has / dict-KEY Object.hasOwn) · f-strings, Python's str() (no !r/format specs) · [i] (xs[-1] from the end, throws out of range) / slices · {...}/[...] literals · set algebra (- | & ^) · len sum(gen) min max abs round str today() · .strip() .lower() .upper() .startswith() .endswith()
Statements: x = expr (single target) · += -= *= /= //= %= (desugar to the binary op's rule) · if/elif/else · return · s.add/.discard, l.append, remove_row(list, id) · reset(...) · global
Rejected, always loudly: loops · while with try yield lambda · comprehensions (beyond the sum generator) · imports · nested defs · unknown names/calls — and every client touch of Server state. Dialect · Errors
Actions (@click="…"): ;-separated sig = expr + calls. Leading local assigns before a server call = optimistic apply, auto-rollback on failure.
Mounts¶
# Litestar (the reference adapter; from `situ`) — lazily imported, no Litestar until called
mount_static_component(path=, stem=, template=, meta=) # pure client
mount_component(path=, stem=, template=, meta=, # + commands, DI, server render
service_type=Store, context=ctx, # Litestar mount: Dishka container
components=Context.from_dir(DIR), # optional tree
live=True, window_template=..., unified=True, # opt-ins
hub=Hub(), # publish from outside a request
fallback="<p>Unavailable.</p>") # region markup if the render raises
mount_component(path="/things/{id:int}", ...) # entity mount: view["id"] from the path, one island
mount_tree(path=, root=, components=, template=, meta=) # pure-client tree
# Any ASGI host (FastAPI / Starlette / uvicorn) — imports no web framework, no extra dependency
from situ.mount.asgi import asgi_app
app.mount("/board", asgi_app(app_factory=lambda cmd: compile_mount(STEM, cmd), template=, meta=,
resolve=lambda: store, context=ctx)) # resolve may be sync or async
# Flask (from `situ.mount.flask`, in situ[flask]) — a Blueprint, no Dishka, no Litestar
mount_flask(path=, app_factory=lambda cmd: compile_app(front, cmd), template=, meta=,
resolve=lambda: store, context=ctx, facade=Store,
guard=require_login, # before_request: per-mount auth
head='<link rel=stylesheet href=/static/declui.css>') # declui scaffold styling
# CSRF: command POSTs must carry the shim's `X-Siting: 1` header (enforced) — csrf.exempt(bp) form-CSRF
Serve situ.static_dir() at /static; template dirs: [yours, situ.templates_dir()]. API
The command wire¶
POST {mount}/cmd/{name}[/{id:int}] X-Siting: 1 · form fields + local `with`s + url params
← 200 region HTML + X-Siting-Signals: {"sig": value} (resets/pushes)
In flight: control disabled, region aria-busy. Non-2xx: rollback, no swap. Wire protocol
Composition¶
<Confirm :open="flag" @confirmed="save"/> <!-- Prop / Emit, name-matched -->
<Card><template slot="title">Hi</template>body</Card> <!-- slots: parent scope -->
Provide[T]/Inject[T] = ancestor value channel. A child owning Locals + handlers is uncontrolled — place it N times, get N independent states. Child <style> scopes to the child. Cycles error; data recursion = :each + :tree. Check trees statically: python -m situ.check ROOT --from-dir DIR [--kit]. Composition
declui, in one breath¶
class Issue: # @dataclass / attrs / msgspec / pydantic / SQLAlchemy (info={"declui": Field(...)})
id: Annotated[int, Field(hidden=True)]
title: Annotated[str, Field(label_field=True, search=True)]
status: Status # Enum → <select>
owner: User | None # reference → <select>/Combobox
notes: list[Note] # one-to-many, read-only
bio: Annotated[str, Field(widget="RichText", editable="rating > 50")]
place: Annotated[str, Field(widget="custom", control="<div…>", binds=("lat","lng"))] # composite: 1 control, N attrs
loc: Annotated[str, Field(widget="custom", component="CountryCity", binds=("country","city"))] # linked: a real component (has handlers) + mount_model(components=Context…)
@action(label="Close", visible="status == 'open'", message="Closed") # async → per-row command
async def close(self):
return Redirect("/done") # optional: navigate on success
mount_model(path=, screen=Screen(model=Issue, zones={...}), # form (default)
screens=("list","detail"), rows=[...], choices={...}) # client modes
mount_model(path=, screen=, service_type=, facade=, context=) # server tracker
mount_model(path="/new", screen=, screens=("create",), service_type=, facade=, after="/") # create form
mount_model(path="/{id:int}/edit", screen=, screens=("edit",), service_type=, facade=, context=, after="/") # edit
Predicates (visible/editable/valid, object_editable) read value / siblings / editing → client binders; @action(visible=) → a server row gate. valid="cond or 'message'". Write-form submit gates on required=/valid=. Screen(rules=(Rule({"type":"Status"},{"widget":"Popup"}),)) — the rule cascade, by selector over {field,type,operation}; python -m situ.declui explain <mod>:<Screen> <field> <op>. declui
Gotchas¶
- Components compile once per process — run with
--reloadduring development. [hidden] { display: none !important; }in your CSS, or:showloses to yourdisplay:rules.- Buttons in forms default to
type=submit— give Canceltype="button". - A
@submithandler's params bind from the form'sname=fields. - The Litestar
mount_componentneeds a Dishka container (request.state.dishka_container); the Flaskmount_flasktakes aresolvecallable instead. Local[set]crosses to commands as a JSON array.- A
Urlsignal is set by navigation only; changing it resets in-progressLocalstate.
Machine-readable versions of this reference: /llms.txt (index) and /llms-full.txt (the single-file reference for AI coding tools).