declui — a model becomes a UI¶
declui is situ's declarative layer: a typed Python model plus a small Screen declaration generate a working form, list, master-detail, or server-backed tracker. It revives the metadata-driven UI idea (AribaWeb/MetaUI, Naked Objects, admin generators) with one inversion that changes its nature: everything resolves once, at mount time — there is no runtime rule engine.
flowchart LR
MODEL["typed model + Screen<br/>(dataclass · attrs · msgspec ·<br/>pydantic · SQLAlchemy)"]
MODEL --> GEN["declui generators<br/>form · list · master-detail · tracker"]
GEN --> SRC["ordinary situ source<br/>(.html + .py — readable, ejectable)"]
SRC --> COMP["the unmodified situ compiler"]
COMP --> ISL["one island + derived routes"]
The generated artifact is an ordinary situ component — indistinguishable from hand-written source, riding the same compiler, the same seam, and the same wire. Nothing declui-specific ships to the browser.
The magic form¶
The canonical demo: a bare typed model, zero template, a full edit form.
class Shirt(str, Enum):
small = "Small"; medium = "Medium"; large = "Large"; extra_large = "Extra large"
@dataclass
class Sample:
title: str
shirt: Annotated[Shirt, Field()] = Shirt.medium # Enum → <select>
price: Annotated[Decimal, Field()] = Decimal("10.50") # Decimal → decimal input
quantity: Annotated[int, Field()] = 1 # int → number input
expedite: Annotated[bool, Field(label="Expedite shipping")] = False
need_by: Annotated[date | None, Field()] = None # date → date input
description: Annotated[str, Field(widget="RichText")] = ""
SAMPLE = Screen(model=Sample) # no zones, no rules — introspection is the whole form
app = Litestar(route_handlers=[
mount_model(path="/sample", screen=SAMPLE),
...
])

Each field's type picks its widget and its signal; every control two-way-binds to a generated Local; unmapped types fail closed with an error naming the type. The full mapping: Fields & models.
mount_model is declui's Litestar mount. The generation itself (generate_form / generate_list / generate_server_tracker) is framework-neutral — it produces an ordinary situ component, which you can also serve on Flask via situ.mount.flask.mount_flask (Serving on Flask).
From form to application¶
The same model vocabulary scales up through the screen modes:
- list — seeded rows in a client
:each; - master-detail — a list plus a detail pane, selection entirely client-side;
- server tracker — live server rows,
@actioncommand buttons (gated, message-bearing, optionally bulk), client search, a relationship-walking detail pane.
The capstone example generates a whole issue tracker — list, detail with an owner reference and a notes one-to-many, Close/Reopen/Assign actions gated by status — from one Issue model and an 8-line Screen. The equivalent hand-built demo carries 311 lines of components and templates.
![]()
What is automatic, what is yours¶
The generator decides: widget per type, signal site and type per field, defaults and seeding, reference option labels, the client/server split of actions (by await), the editing mode machinery when a predicate needs it, and all the fail-closed validation.
You declare: per-field intent (Field(...) — labels, widgets, predicates), layout (zones), actions, and the mount wiring (rows/choices seeding or the service/facade/context triple).
The escape hatch, stated up front¶
declui generates a first draft in a canonical style; regenerating a hand-tuned screen is out of scope by design. When a screen outgrows the generated surface, eject: the generated .html + .py are real source you can write to disk, own, and mount like any hand-written component (mount_component(..., overrides=...) keeps generated siblings around it). An app that ejects every screen still got its first drafts, its type→widget defaults, and its seam wiring for free.