Typed CSS (stylesheet and tokens)¶
CSS written in Python, checked by the type checker, served by the app
itself. No loose .css file, no frontend build, no CDN.
When to use
- You need a selector, a pseudo-class or a media query — things an inline style cannot express.
- You want a consistent palette, spacing and typography, dark mode included, without hand-maintaining a colour table.
- You want a wrong class name to fail, instead of rendering an unstyled element.
tempest_core's Style is still the right tool for a widget's local
layout. This recipe is about the sheet.
A rule is an object¶
from tempest_core import Style
from tempest_core.style import Edge
from tempest_fastapi_sdk.ui.css import Media, Rule, StyleSheet
sheet: StyleSheet = StyleSheet(
rules=[
Rule(".card", style=Style(padding=Edge.all(16), radius=8.0)),
Rule(".card:hover", declarations={"cursor": "pointer"}),
Media.min_width(768, [Rule(".card", declarations={"padding": "24px"})]),
],
)
print(sheet.to_css())
Out comes exactly what you expect:
.card {
padding: 16px 16px 16px 16px;
border-radius: 8px;
}
.card:hover {
cursor: pointer;
}
@media (min-width: 768px) {
.card {
padding: 24px;
}
}
A Rule takes declarations from two places, and the split is
deliberate:
style=— a typedStyle, converted by the same function the renderer uses for widgets. A rule and an inline style with the same values emit identical declarations.declarations=— a plain mapping, for whatStyledoes not model:display: grid,cursor,content, and every token reference.
There is also layout="column" / "row", which applies display: flex
plus flex-direction the way Column and Row do — so gap,
justify and align are not silently inert.
Colours in Style are hex only
Style(color="var(--t-color-primary)") raises invalid hex color —
measured against tempest_core's validator. Token references go in
declarations: Rule(".btn", declarations={"color":
theme.color("primary")}).
Design tokens, from tempest_core into CSS¶
The palette is not reinvented here: ThemeTokens adapts
tempest_core's TokenSet (the same one the client uses) into custom
properties.
from tempest_fastapi_sdk.ui.css import Rule, StyleSheet, ThemeTokens
theme: ThemeTokens = ThemeTokens()
sheet: StyleSheet = StyleSheet(
theme=theme,
rules=[
Rule(
".card",
declarations={
"padding": theme.space("md"),
"border-radius": theme.radius("md"),
"background": theme.color("surface"),
"color": theme.color("on_surface"),
"font-size": theme.font_size("body_medium"),
},
),
],
)
That emits three blocks: :root with the light scheme and every scale,
@media (prefers-color-scheme: dark) (guarded so an explicit light
choice wins) and :root[data-theme="dark"] for a toggle. Writing
theme.color("surface") once settles both modes.
Available groups: color (39 roles — primary, on_surface,
error_container, …), space, radius, font-size, line-height,
font-weight, letter-spacing, duration, easing.
Breakpoints are the exception, for a reason: a media query cannot read
var(). So they come back as numbers:
from tempest_fastapi_sdk.ui.css import Media, Rule, ThemeTokens
theme: ThemeTokens = ThemeTokens()
wide = Media.min_width(
theme.breakpoint("lg"),
[Rule(".sidebar", declarations={"display": "block"})],
)
Besides min_width there are max_width, dark() and
reduced_motion().
Serving the sheet¶
from fastapi import FastAPI
from tempest_fastapi_sdk.ui import app_stylesheet
from tempest_fastapi_sdk.ui.css import make_css_router
app: FastAPI = FastAPI()
app.include_router(make_css_router(app_stylesheet(), path="/static/app.css"))
The CSS is rendered once, when the router is built — no request pays
for walking the rules. The response carries a content-derived ETag, and
a matching If-None-Match gets a bodyless 304.
On the page side, point the <link>:
from tempest_core import Text
from tempest_fastapi_sdk.ssr import html_response
response = html_response(
Text(content="Hello", tag="h1"),
title="Home",
stylesheets=["/static/app.css"],
)
The ready sheet, and yours on top¶
app_stylesheet() composes what nearly every service wants: tokens, a
minimal reset, form rules and component rules.
from tempest_fastapi_sdk.ui import app_stylesheet
from tempest_fastapi_sdk.ui.css import Rule, StyleSheet, ThemeTokens
theme: ThemeTokens = ThemeTokens()
own: StyleSheet = StyleSheet(
reset=False,
rules=[
Rule(
".page-title",
declarations={
"margin": "0",
"font-size": theme.font_size("headline_small"),
"color": theme.color("on_background"),
},
),
],
)
sheet: StyleSheet = app_stylesheet(theme=theme, extra=own)
Your rules come last, so they win on equal specificity — the ordinary
cascade. merge() does the same between any two sheets, and
StyleSheet(reset=False) turns the reset off.
A wrong class name should hurt¶
A typo in class="crad" breaks nothing: the element simply renders
unstyled and someone finds out in production. StyleSheet.cls() turns
that into an error at render time:
from tempest_core import Column, Text
from tempest_fastapi_sdk.ui.css import Rule, StyleSheet, cls
sheet: StyleSheet = StyleSheet(rules=[Rule(".card", declarations={"padding": "16px"})])
Column(tag="section", attrs=sheet.cls("card"), children=[Text(content="hi")])
Column(tag="section", attrs=cls("card", "card--wide"), children=[])
sheet.cls("crad") raises KeyError listing the classes that do exist.
The free cls() does not validate — reach for it when the class comes
from another sheet (an external design system, say).
You can push that into a test instead of relying on discipline:
import re
from tempest_fastapi_sdk.ui import app_stylesheet
def test_page_uses_only_defined_classes(html: str) -> None:
"""Fail when the page uses a class the sheet does not define."""
used = {
name
for attribute in re.findall(r'class="([^"]+)"', html)
for name in attribute.split()
}
assert used <= app_stylesheet().class_names()
That test exists in the SDK's own suite, and found two unstyled classes the first time it ran.
Recap¶
Rule+Media+StyleSheetcover selectors, pseudo-classes and media queries — what an inline style cannot reach.style=for typed values,declarations=for the rest and for every token (Styleonly accepts hex colours).ThemeTokenstranslatestempest_core's token set into custom properties, light and dark at once.make_css_routerserves the sheet rendered once, withETagand304.app_stylesheet()already brings tokens, reset, forms and components; your rules go in throughextra=.sheet.cls(...)turns a class typo into aKeyError.
See also: UI layer » and Forms from Pydantic schemas ».