# situ — complete reference for AI coding tools > situ is a Python→JS compiler plus a framework-neutral component mount for reactive web UIs. Each piece of state declares its SITE (`Local` browser / `Url` query string / `Server` database / `Synced` reserved) and everything else is derived: the client island is generated from the authored Python, the routes are derived from the handlers, and the client/server boundary is a compile-time invariant. The mount has a portable core (`situ.mount.core`, no framework imported) with two adapters: Litestar (the reference: `mount_component`) and Flask (`situ.mount.flask.mount_flask`, WSGI); the Litestar imports are lazy, so importing the Flask path pulls in no Litestar. Authors write real HTML plus a bounded dialect of Python and zero client JavaScript. Alpha software; Python ≥ 3.12; `pip install situ` (extras: `situ[flask]`, `situ[sqlalchemy]`, `situ[model-adapters]`). This file is self-contained: everything needed to write correct situ code. Every code sample and error message in it is verified output. Rules an agent must never violate are marked RULE. ## 1. The component model A component is TWO SIBLING FILES SHARING A STEM: - `name.py` — module-level sited signal declarations + module-level handler functions. - `name.html` — real HTML; Jinja (`{% for %}`, `{{ x }}`) renders server state; `:`/`@` shorthand attributes bind client behavior. RULE: every component `.py` starts with `from __future__ import annotations` (markers are never evaluated at runtime). RULE: every reactive element (any `:x`/`@x` attribute) must be inside `
` OR inside the single `
`. Elements outside both are silently inert. `
` survives command swaps (put search boxes, dialogs, toasts there); the region is what commands re-render. RULE: `data-region` must be the FIRST attribute on its `
`. RULE: components compile once per process — restart (or run uvicorn with `--reload`) to pick up edits. ## 2. Sites ```python from situ import Local, Url, Server, Synced search: Local[str] = "" # browser owns it; compiled to JS; zero network tags: Local[set] = set() # Local[set] is first-class (membership, size, mutation) filter: Url[str] = "all" # the query string IS the store; set by links only issues: Server[IssuesFacade] # server owns it: a facade in handlers, template data from context() # Synced[T] parses but is reserved/unimplemented — do not use it ``` Markers are transparent `Annotated` aliases: `Local[str]` IS `str` to type checkers. THE SEAM (enforced, verbatim compiler errors): - Client read of Server state → `CompileError: server-sited 'issues' cannot be read on the client; DB-backed state stays on the server` - Client write → `CompileError: cannot assign server-sited signal 'issues' on the client` - Loop over Server state in a client handler → `CompileError: refusing to compile a loop over server-sited 'issues': DB iteration stays on the server` - A client write to a `Url` signal is also a `CompileError` (URL state is set by navigation). Server data reaches the browser three ways ONLY: (1) Jinja renders it; (2) per-row `data-*` attributes project named fields (see §5); (3) a command pushes values into client signals via `signal()`/`reset()`. ## 3. Handlers RULE: a handler whose body contains ANY `await` is a SERVER COMMAND (compiled to a POST dispatch; body runs server-side with the facade injected under the Server signal's name). A handler with no `await` compiles to client JS. There is no decorator. ```python from collections.abc import Callable from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Protocol class IssuesFacade(Protocol): async def create(self, title: str) -> None: ... async def set_status(self, issue_id: int, status: str) -> None: ... reset: Callable[..., None] # declare the injected helpers you use (type-checker hygiene) def clear(): # → client JS function, zero network global search # RULE: declare `global` for bare-name signal writes search = "" async def close(id): # → POST {mount}/cmd/close/{id} (positional arg = path segment) await issues.set_status(id, "closed") async def create(new_title): # param binds from the submitted form field name="new_title" await issues.create(new_title) reset("new_title", "dialog_open") # reset local signals to defaults (rides the response) # signal("toast", "Created") # push an explicit value into a client signal ``` Command mechanics: form fields + listed local signals + current url params ride the POST; `Local[set]` serializes as a JSON array; the response is the re-rendered region HTML plus an `X-Siting-Signals` JSON patch applied before re-bind. In flight: the control is `disabled`, the region `aria-busy`. Non-2xx/network error: automatic rollback, no swap. ## 4. Events and binders (the template vocabulary) Events (value = `;`-separated statements: `sig = expr` assignments and handler calls): - `@click` `@dblclick` `@submit` `@input` `@escape` - `@submit` goes on the `
` and preventDefaults; its command sends the form's fields. - RULE: inside a server-rendered row or `:each` row, only `@click` and `@submit` are allowed. - Optimistic updates: leading LOCAL assignments before a trailing server call are applied immediately and rolled back if the command fails: `@click="selected_id = t.id; open_msg(t.id)"`. Binders: - `:text="expr"` — textContent tracks the expression. - `:show="expr"` — sets/clears the `hidden` attribute. RULE: keep `[hidden] { display: none !important; }` in the page CSS; any author `display:` on the element defeats the UA hidden rule otherwise. - `:class="name: expr"` — toggles one class. - `:attr="name: expr"` — sets/removes an attribute (falsy/None removes; True → empty). - `:bind="sig"` — two-way input binding. RULE: the signal must be `Local`. `type=number` inputs store JS numbers. - `:each="row in sig"` — the keyed client reconciler over a `Local[list]` of dicts. RULE: put `:each` on a wrapper element with no other binders; rows are keyed by their `id` field. - `:tree="row.children"` — self-recursive row template (inside a `:each`). - `:virtual="handler(scroll)"` — windowed rows; needs a server handler + `mount_component(window_template=…)`. - `:autohide="sig"` — clears a status signal back to its default after ~4 s. - Kit widget binders (need situ_ui's `widgets.js`): `:tabs`, `:table="rows"`, `:combobox="value"`, `:datagrid="rows"` + `:datagridsel="sel"`, `:menu`, `:palette`, `:sortable="list"`, `:cells="grid"`. SERVER-ROW IDIOM (the standard list pattern — no `:each` involved): ```html
    {% for t in issues %}
  • {% endfor %}
``` Rows are server-rendered Jinja; each row carries `data-id` plus a `data-*` attribute per field the CLIENT reads. In binders on/inside the row, `t.` compiles to a dataset read from the nearest `[data-id]` ancestor. You choose per field what crosses the wire. Numeric comparisons are normalized (`Number()` both sides, then strict equality), so an `int` signal compares correctly against a string dataset id. ## 5. The dialect (what compiles to the client) Expressions: names (signals, row vars) · str/int/float/bool/None literals · `and` `or` `not` · `== != < <= > >=` · `+ - * / %` · `x if c else y` · `in`/`not in` (`Set.has` on `Local[set]`, `.includes` on strings) · f-strings (conversions `!r` and format specs `:.2f` are rejected) · subscripts and slices (no step) · dict/list literals (string-constant keys) · calls: `len` `sum()` `min` `max` `abs` `round` `today()` · string methods: `strip lower upper startswith endswith`. Statements (client handler bodies): single-target assignment · `+= -= *= /= %=` · `if/elif/else` · `return` (incl. early) · `set.add/.discard`, `list.append/.remove` on Local collections · `reset("sig", ...)` · `global`/`nonlocal` (no-op emission) · `pass`, docstrings. REJECTED, always with a `CompileError` naming the construct: `for`/`while` loops · `with` `try` `yield` `lambda` · comprehensions (beyond the one `sum` generator) · `import` · nested `def`/`class` · unknown names, calls, attributes, methods — plus every seam case of §2. When rejected: move the logic to the server (add an `await`/do it in `context()`), or restructure into the dialect. Semantics: `Decimal` is conventionally kept string-valued (money precision); `today()` yields the local ISO date string, comparable against `` values; set/list mutations are immutable under the hood so subscribers always fire. ## 6. The mounts (framework-neutral core + adapters) The mount has a portable core (`situ.mount.core`: `dispatch_command` / `page_data` / `view_from_query` + the compile cache + `Hub`, no web framework imported) wrapped by a per-framework adapter. Litestar is the reference adapter; the Litestar names below load lazily (PEP 562), so `import situ` and the Flask path import no Litestar. ```python import situ from situ import mount_static_component, mount_component, mount_tree, Context mount_static_component(path="/x", stem=DIR / "counter", template="page.html", meta={"name": "Counter"}) # pure client mount_component(path="/x", stem=DIR / "components" / "tracker", template="page.html", meta={"name": "Tracker"}, service_type=IssueStore, # resolved per request from Dishka facade=None, # optional wrapper; default: the service itself context=context, # async (service, view) -> template dict components=Context.from_dir(DIR), # optional component tree overrides=None, unified=False, live=False, window_template=None) mount_tree(path="/x", root=DIR / "board", components=Context.from_dir(DIR), template="page.html", meta={"name": "Board"}) # pure-client tree ``` RULE: `mount_component` requires a Dishka container on the app (`setup_dishka(make_async_container(Provider()), app)`) — it resolves `service_type` from `request.state.dishka_container`. Wiring: serve `situ.static_dir()` at `/static` (the shim `_rt.js`); template dirs `[yours, situ.templates_dir()]`; kit users also serve `situ_ui.STATIC` and load `widgets.js` after `_rt.js`, before the island. Derived routes per mount: `GET /` (page) · `GET /island.js` · per server handler `POST /cmd/{name}` or `POST /cmd/{name}/{arg:int}` · with `live=True`: `GET /feed` (SSE "dirty" broadcast) + `GET /region` · with `window_template=`: `GET /window`. ### Flask (WSGI) — `situ.mount.flask.mount_flask` (extra: `situ[flask]`) ```python from flask import Flask from situ import compile_app from situ.declui import generate_server_tracker from situ.mount.flask import mount_flask app = Flask(__name__, static_folder=None) # free /static for situ's shim app.register_blueprint(mount_flask( path="/issues", app_factory=lambda cmd: compile_app(generate_server_tracker(SCREEN, context_key="items"), cmd), template="page.html", meta={"name": "Issues"}, resolve=lambda: store, # the service (no Dishka); a plain callable facade=Issues, # optional wrapper for the handlers context=context)) # async (service, view) -> template dict ``` Same four routes as `mount_component`, over a Flask `Blueprint` (no Dishka, no Litestar on the path). `app_factory(cmd_base)` returns the compiled `EmittedApp` (the caller compiles, so the command URLs match the mount). Commands run the async core via `asyncio.run` — fine for an in-memory service; a loop-bound async resource (a SQLAlchemy pool) needs a persistent loop or an ASGI adapter. Serve situ's shim at `/static/_rt.js` yourself (`static_folder=None` frees the path; Flask's built-in static route would otherwise shadow it). ## 7. A complete, verified minimal app Layout: `app.py`, `store.py`, `components/tracker.py`, `components/tracker.html`, `templates/page.html`. Run: `uvicorn app:app --reload`. ```python # store.py — the domain service: DI service_type AND the facade the handlers await from __future__ import annotations from dataclasses import dataclass @dataclass class Issue: id: int title: str status: str = "open" class IssueStore: def __init__(self) -> None: self._items = [Issue(1, "Login button misaligned on mobile")] self._next = 2 async def create(self, title: str) -> None: title = title.strip() if title: self._items.append(Issue(self._next, title)); self._next += 1 async def set_status(self, issue_id: int, status: str) -> None: for t in self._items: if t.id == issue_id: t.status = status def visible(self, which: str) -> list[Issue]: if which == "all": return list(self._items) return [t for t in self._items if t.status == which] ``` ```python # app.py from __future__ import annotations from collections.abc import Mapping from pathlib import Path import situ from dishka import Provider, Scope, make_async_container, provide from dishka.integrations.litestar import setup_dishka from litestar import Litestar from litestar.plugins.jinja import JinjaTemplateEngine from litestar.static_files import create_static_files_router from litestar.template.config import TemplateConfig from situ import mount_component from store import IssueStore HERE = Path(__file__).parent class AppProvider(Provider): scope = Scope.APP @provide def store(self) -> IssueStore: return IssueStore() async def context(service: IssueStore, view: Mapping[str, str]) -> Mapping[str, object]: which = view.get("filter", "all") return {"issues": service.visible(which), "filter": which} def create_app() -> Litestar: app = Litestar( route_handlers=[ mount_component(path="/", stem=HERE / "components" / "tracker", template="page.html", meta={"name": "Tracker"}, service_type=IssueStore, context=context), create_static_files_router(path="/static", directories=[situ.static_dir()]), ], template_config=TemplateConfig(directory=[HERE / "templates", situ.templates_dir()], engine=JinjaTemplateEngine), ) setup_dishka(make_async_container(AppProvider()), app) return app app = create_app() ``` ```python # components/tracker.py from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING from situ import Local, Server, Url if TYPE_CHECKING: from typing import Protocol class IssuesFacade(Protocol): async def create(self, title: str) -> None: ... async def set_status(self, issue_id: int, status: str) -> None: ... reset: Callable[..., None] search: Local[str] = "" selected_id: Local[int] = 0 dialog_open: Local[bool] = False new_title: Local[str] = "" filter: Url[str] = "all" issues: Server[IssuesFacade] async def create(new_title): await issues.create(new_title) reset("new_title", "dialog_open") async def close(id): await issues.set_status(id, "closed") async def reopen(id): await issues.set_status(id, "open") ``` ```html
    {% for t in issues %}
  • {{ t.status }}
  • {% endfor %}
{% for t in issues %}

{{ t.title }}

#{{ t.id }} · {{ t.status }}

{% if t.status == 'open' %} {% else %}{% endif %}
{% endfor %}
``` ```html {{ demo.name | default("situ") }} {{ body | safe }} ``` This app derives `POST /cmd/create`, `POST /cmd/close/{id}`, `POST /cmd/reopen/{id}`; its island is ~6.8 KB. Selection and search survive command swaps (signals live at page scope; the header is outside the region). ## 8. Composition Resolution — a `Context` maps PascalCase tags to component stems: ```python from situ import Context import situ_ui ctx = situ_ui.kit().merge(Context.from_dir(COMPONENTS)).with_override("Toast", HERE / "fancy_toast") mount_tree(path="/app", root=COMPONENTS / "page", components=ctx, template="page.html", meta=META) ``` Static check (run it in lint): `python -m situ.check path/to/root --from-dir path/to/components [--kit]`. Contracts — a child declares `Prop[T]` (parent→child expression) and `Emit[T]` (child→parent event); the parent binds them at the placement site. Matching is BY NAME; missing/extra/duplicate/unused bindings are CompileErrors. ```python # confirm.py (child) from situ import Prop, Emit open: Prop[bool] confirmed: Emit[None] ``` ```html Body fills the default slot. ``` Slots: ``/`` in the child; content is spliced in the PARENT's scope (a passed `@click="h()"` calls the parent's handler). Content to a slot-less child is a CompileError. Provide/Inject: an ancestor declares `theme: Provide[str] = "dark"`; any descendant declares `theme: Inject[str]` and reads `theme` like a signal. An Inject with no ancestor Provide is a CompileError. Uncontrolled components: a child that owns `Local`s + handlers needs zero parent wiring; placed N times it is monomorphized into N independent states at compile time. Scoped CSS: a CHILD component may carry its own `