1. The view tree¶
The basic unit of tempestweb is the view() function. It receives the app
(which exposes the current state) and returns a widget tree. No JSX, no
template — just plain, typed Python.
The view function¶
from tempest_core import App, Column, Style, Text, Widget
from tempest_core import Edge
def view(app: App[CounterState]) -> Widget: # (1)!
"""Render the counter UI from the current state."""
return Column( # (2)!
style=Style(gap=8.0, padding=Edge.all(16)),
children=[
Text(content=f"Count: {app.state.value}", key="label"), # (3)!
],
)
viewreceivesApp[CounterState]— the typed state handle — and always returns aWidget. Input and output types are part of the contract.Columnis a vertical flex container.Rowis the horizontal one. Both takestyleandchildren.Textshows text.app.state.valuereads the current state — the view is a function of state.
The view is pure
view() mutates nothing. It reads app.state and describes the UI that
matches that state. Changing the state is the handlers' job (next page) — the
view only draws.
The counter's widgets¶
The counter uses four widget types, all from the core:
| Widget | What it is | Main props |
|---|---|---|
Column |
Vertical flex container | style, children |
Row |
Horizontal flex container | style, children |
Text |
Text | content, style, key |
Button |
Clickable button | label, on_click, style, key |
The key: stable identity¶
Notice key="label". The key gives the widget a stable identity across
rebuilds. When the state changes and the view runs again, the reconciler uses the
key to match the new widget with the old one — so it can emit a minimal patch
(change only the text) instead of recreating the node.
When to give a key
Give a key to any widget that persists across rebuilds and whose content
changes (the count text, the buttons). Dynamic list items also want a stable
key. Without a key, reconciliation falls back to positional matching.
Style is a typed object¶
Style is a Pydantic object — not a CSS string. You declare intent and the
client translates it to CSS:
gap=8.0→gap: 8pxon the flex container.Edge.all(16)→padding: 16px 16px 16px 16px.
Style → CSS is almost identity
Style was designed by copying the CSS vocabulary (flexbox, box model,
typography). The translation lives in the client (client/style.js) and is
shared by both modes. Full detail in the wire contract.
The complete counter tree¶
Putting container, text and buttons together:
from tempest_core import App, Button, Column, Row, Style, Text, Widget
from tempest_core import Edge
def view(app: App[CounterState]) -> Widget:
"""Render the counter UI from the current state."""
return Column(
style=Style(gap=8.0, padding=Edge.all(16)),
children=[
Text(content=f"Count: {app.state.value}", key="label"),
Row(
style=Style(gap=4.0),
children=[
Button(label="-", key="dec"), # on_click comes on page 2
Button(label="+", key="inc"),
],
),
],
)
This produces the tree (IR) that the reconciler serializes to the client — the
exact format is pinned in
tests/fixtures/node_initial.json.
Responsiveness: app.media¶
The browser owns the viewport; the view reads it from app.media. The client
reports size, density, OS dark mode and orientation on mount and on every
change, in all three modes — so picking a layout by width is just an if:
from tempest_core import App, Column, Row, Text, Widget
def view(app: App[object]) -> Widget:
"""One row on a desktop, one column on a phone."""
fields = [
Text(content="Name", key="label-name"),
Text(content="Email", key="label-email"),
]
if app.media.width >= 768:
return Row(style=None, children=fields)
return Column(style=None, children=fields)
app.media carries width, height, device_pixel_ratio,
text_scale_factor, platform_dark_mode and orientation. A change requests a
rebuild like any state mutation — the view re-runs and the reconciler emits the
minimal patches.
height is the only way to bound a frame to the viewport
Style has no 100vh. A Scaffold(scroll=True) only really scrolls when
the column around it is bounded, and the only measure available to bound it
is app.media.height. Without it the whole page scrolls and the app_bar /
bottom_bar scroll away — exactly what a scaffold exists to prevent.
Before the first report, everything is zero
MediaQueryData defaults width and height to 0.0. The first report
arrives on mount, so the first paint is already responsive — but a view
that divides by width has to handle the zero.
Recap¶
view(app) -> Widgetis a pure function of state.- Widgets (
Column,Row,Text,Button) are typed core objects. keygives stable identity so reconciliation emits minimal patches.Styleis a typed object that becomes CSS in the client.
Now the buttons need to do something. Let's move on to state and handlers. 🚀