2. Styling¶
Styling is inline and typed — no stylesheets, no cascade, no specificity. A
Style is a Pydantic object each renderer translates to its target (CSS on the
web, Qt/Compose properties on native).
from tempest_core import Container, Style, Text, Widget
from tempest_core import Color, Edge
def card() -> Widget:
return Container(
key="card",
style=Style(
padding=Edge.all(16), # (1)!
gap=8.0,
background=Color.from_hex("#f5f5f5"), # (2)!
radius=12.0,
),
children=[Text(content="In a card", key="t")],
)
Edge.all(16)= 16px on all four sides. There's alsoEdge.symmetric( vertical=…, horizontal=…)andEdge(top=…, right=…, …).Color.from_hex("#f5f5f5")→Color(r, g, b, a). On the web it becomesrgba(...); the value crosses the boundary as{r, g, b, a}.
Every number is finite¶
A numeric field on Style — and on any widget — refuses nan, inf and
-inf:
from tempest_core import Style
Style(width=float("nan"))
# ValidationError: width — Input should be a finite number
That looks harsh for a value nobody types on purpose. But nobody types it: it arrives, from outside data.
metrics = await backend.get("/metrics") # {"load_pct": "NaN"}
Style(width=float(metrics["load_pct"])) # float("NaN") == nan
A division by zero, a sensor with no reading, a field the backend serialized as
the string "NaN" — and the nan enters the tree with no signal at all.
Why this cannot be allowed through
nan and inf have no token in JSON, and every renderer this core feeds
is reached through JSON. Python's encoder writes the bare words
NaN/Infinity, and no browser's JSON.parse accepts them.
The damage is not the wrong property: it is the whole batch carrying it.
One nan in a width takes down the patch batch it travelled with —
including changes to widgets that have nothing to do with it.
This was measured in tempestweb issue #160: a metric that arrived as "NaN"
killed the batch inside the client's decode — before the transport, before
the renderer, before any diagnostic — and the visible error showed up one
rebuild later as patch path out of range, on an app bar whose second
action had simply never been delivered. Three of seven reproductions logged
nothing at all.
A bound is not a finiteness check
Style.opacity (ge=0.0, le=1.0) already refused inf — but only because
inf <= 1.0 is false. A one-sided bound does not hold: text_scale and
aspect_ratio carry gt=0.0, and inf > 0.0 is true. That is why the guard
checks finiteness, not range.
Refusing at construction is the point: the ValidationError names the field on
the line that built the widget. Validate where the number enters:
import math
from tempest_core import Style
def bar_width(raw: str) -> Style:
"""Convert the backend's number, falling back to 0 when it is not finite."""
load = float(raw)
return Style(width=load if math.isfinite(load) else 0.0)
print(bar_width("42.5").width) # 42.5
print(bar_width("NaN").width) # 0.0
Implicit animation¶
Declare a Transition and a property change animates instead of snapping:
from tempest_core import Curve, Transition
Style(transition=Transition(duration_ms=300, curve=Curve.EASE_IN_OUT))
Theme = Style values
There's no magic theme engine: a theme is just a set of Color/Style your
view applies. Switching themes is the view producing different Styles.
HTML escape hatch (tag / attrs)¶
Every Widget carries two optional "renderer hint" fields — tag and attrs —
for the HTML/SSR renderer (tempestweb). They're ignored by the native renderers
(Qt/Compose), just like semantics/focusable already are.
from tempest_core import Container, Text, Widget
def navbar() -> Widget:
return Container(
key="nav",
tag="nav", # (1)!
attrs={"id": "top", "aria-label": "Primary"}, # (2)!
children=[
Text(
content="Home",
tag="a",
attrs={"href": "/", "hx-get": "/", "hx-target": "#main"}, # (3)!
),
],
)
tagoverrides the semantic HTML element emitted (<nav>instead of the default<div>);Nonelets the renderer pick its natural element.attrsis adict[str, str]of arbitrary HTML attributes (id,class,data-*,aria-*).- This is how HTMX attributes (
hx-*) reach the SSR output without a dedicated field on the core.
Why a typed escape hatch
The core is renderer-agnostic; it does not model every HTML tag/attribute.
tag/attrs flow through build() into the node's props (like any other
field), so the HTML renderer consumes them and the others ignore them — with
no special-casing.
Recap¶
Styleis typed and inline; no CSS cascade.Color.from_hex,Edge.all/symmetric,Transitioncover the basics.- Each renderer translates the same
Styleto its target. - Every number is finite:
nan/infare refused at construction, because JSON cannot represent them and one alone takes down a whole patch batch. - See the API reference for everything.