CSS tipado (StyleSheet e tokens)¶
CSS escrito em Python, conferido pelo type checker e servido pelo próprio
app. Sem arquivo .css solto, sem build de frontend, sem CDN.
Quando usar
- Você precisa de seletor, pseudo-classe ou media query — coisas que estilo inline não expressa.
- Você quer paleta, espaçamento e tipografia consistentes, com modo escuro, sem manter uma tabela de cores na mão.
- Você quer que um nome de classe errado falhe, em vez de renderizar um elemento sem estilo.
O Style do tempest_core continua sendo o certo para layout local
de um widget. Esta receita é sobre a folha.
Uma regra é um objeto¶
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())
Sai isto — primeiro o reset que o StyleSheet inclui por padrão
(reset=True; StyleSheet(..., reset=False) tira), depois as suas regras,
na ordem em que você as passou:
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif;
}
img, svg, video {
display: block;
max-width: 100%;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
.card {
padding: 16px 16px 16px 16px;
border-radius: 8px;
}
.card:hover {
cursor: pointer;
}
@media (min-width: 768px) {
.card {
padding: 24px;
}
}
Uma Rule recebe declarações de dois lugares, e a divisão é
intencional:
style=— umStyletipado, convertido pela mesma função que o renderizador usa nos widgets. Regra e estilo inline com os mesmos valores emitem declarações idênticas.declarations=— mapa cru, para o queStylenão modela:display: grid,cursor,content, e toda referência a token.
Vale também layout="column" / "row", que aplica display: flex +
flex-direction do mesmo jeito que Column e Row fazem — assim
gap, justify e align não ficam inertes.
Cor em Style só aceita hexadecimal
Style(color="var(--t-color-primary)") levanta invalid hex color
— medido contra o validador do tempest_core. Referência a token vai
em declarations: Rule(".btn", declarations={"color":
theme.color("primary")}).
Design tokens, do tempest_core para o CSS¶
A paleta não é reinventada aqui: ThemeTokens adapta o TokenSet do
tempest_core (o mesmo que o cliente usa) para 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"),
},
),
],
)
Isso emite três blocos: :root com o esquema claro e todas as escalas,
@media (prefers-color-scheme: dark) (protegido para não vencer uma
escolha explícita por claro) e :root[data-theme="dark"] para o botão de
alternar. Escrever theme.color("surface") uma vez resolve os dois
modos.
Grupos disponíveis: color (39 papéis — primary, on_surface,
error_container, …), space, radius, font-family, font-size,
line-height, font-weight, letter-spacing, duration, easing.
A família da fonte¶
O token set do tempest_core traz tamanho, peso e entrelinha, mas não a
família. Sem nenhum font-family na folha, o texto sai na fonte padrão do
browser. Por isso o ThemeTokens emite --t-font-family-body, e o reset
aplica no body:
O default é SYSTEM_FONT_STACK, a fonte de interface do sistema
operacional (system-ui, -apple-system, "Segoe UI", Roboto, …,
sans-serif) — nenhuma web font é baixada. Para trocar, troque o token:
from tempest_fastapi_sdk.ui import app_stylesheet
from tempest_fastapi_sdk.ui.css import StyleSheet, ThemeTokens
theme: ThemeTokens = ThemeTokens(font_family_body='"Inter", system-ui, sans-serif')
sheet: StyleSheet = app_stylesheet(theme=theme)
theme.font_family() devolve var(--t-font-family-body), para uma regra
sua referenciar a mesma família. Uma StyleSheet sem theme aplica a
pilha literal no body, sem var().
Breakpoint é a exceção, e por um motivo: media query não lê var().
Então ele volta como número:
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"})],
)
Além de min_width, há max_width, dark() e reduced_motion().
Servindo a folha¶
from fastapi import FastAPI
from fastapi.responses import Response
from tempest_core import Text
from tempest_fastapi_sdk.ssr import html_response
from tempest_fastapi_sdk.ui import app_stylesheet
from tempest_fastapi_sdk.ui.css import StyleSheet, make_css_router
CSS_PATH: str = "/static/app.css"
STYLESHEET: StyleSheet = app_stylesheet()
CSS_URL: str = STYLESHEET.url(CSS_PATH)
app: FastAPI = FastAPI()
app.include_router(make_css_router(STYLESHEET, path=CSS_PATH))
@app.get("/")
async def home() -> Response:
return html_response(
Text(content="Olá", tag="h1"),
title="Início",
stylesheets=[CSS_URL],
)
O CSS é renderizado uma vez, quando o router é construído — nenhuma
requisição paga o custo de percorrer as regras. A resposta leva um
ETag derivado do conteúdo, e um If-None-Match que bata recebe 304
sem corpo.
A página não aponta o <link> para o path fixo, e sim para
STYLESHEET.url(CSS_PATH): o mesmo path com a versão do conteúdo na
query — "/static/app.css?v=" seguido de 12 caracteres hexadecimais. A versão são os 12 primeiros
caracteres hexadecimais do SHA-256 do CSS renderizado — o mesmo hash do
ETag. Ela depende só do texto da folha, então duas réplicas com as mesmas
regras (e a mesma versão do tempest_core, de onde vêm os valores dos tokens)
chegam à mesma URL, e mudar uma declaração muda a URL.
Com isso o router escolhe a política de cache por requisição:
| A requisição traz | Cache-Control |
|---|---|
?v= igual à versão que este processo serve |
public, max-age=31536000, immutable |
path sem ?v=, ou ?v= de outra versão |
no-cache |
A URL versionada fica em cache por um ano, e o immutable (RFC 8246) diz ao
browser que ela não precisa ser revalidada. Um
deploy que muda o CSS muda a URL, e a página nova pede um arquivo que o
browser nunca viu — nada de HTML novo em cima de folha velha.
Por que versão errada recebe no-cache
Num deploy em rolagem, a página renderizada por uma réplica nova pode
pedir ?v=<nova> a uma réplica que ainda serve a folha antiga. Se a
réplica antiga respondesse immutable, o browser guardaria o CSS velho
sob a URL nova por um ano. Por isso o cache longo só sai quando a versão
pedida é exatamente a que o processo serve; qualquer outra revalida
pelo ETag.
As duas políticas são configuráveis: cache_control= (a de sem versão) e
versioned_cache_control= (a de versão atual).
Calcule a URL uma vez
url() renderiza a folha inteira para calcular o hash. Guarde numa
constante de módulo ao lado da folha, como CSS_URL acima — é o que o
scaffold do tempest generate faz em src/ui/styles.py.
A folha pronta, e a sua por cima¶
app_stylesheet() compõe o que quase todo serviço quer: tokens, reset
mínimo, regras de formulário e regras de componente.
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)
As suas regras entram por último, então vencem no empate de
especificidade — a cascata normal. merge() faz o mesmo entre duas
folhas quaisquer, e StyleSheet(reset=False) desliga o reset.
Nome de classe errado deve doer¶
Um typo em class="crad" não quebra nada: o elemento simplesmente
aparece sem estilo, e alguém descobre em produção. StyleSheet.cls()
transforma isso em erro na hora do render:
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="oi")])
Column(tag="section", attrs=cls("card", "card--wide"), children=[])
sheet.cls("crad") levanta KeyError listando as classes que existem.
A função solta cls() não valida — use quando a classe vier de outra
folha (a de um design system externo, por exemplo).
Dá para levar isso ao teste, sem depender de disciplina:
import re
from tempest_fastapi_sdk.ui import app_stylesheet
def test_page_uses_only_defined_classes(html: str) -> None:
"""Falha se a página usar uma classe que a folha não define."""
used = {
name
for attribute in re.findall(r'class="([^"]+)"', html)
for name in attribute.split()
}
assert used <= app_stylesheet().class_names()
Esse teste existe na suíte do próprio SDK, e achou duas classes sem regra na primeira vez que rodou.
Recap¶
Rule+Media+StyleSheetcobrem seletor, pseudo-classe e media query — o que estilo inline não alcança.style=para valores tipados,declarations=para o resto e para todo token (Stylesó aceita cor hexadecimal).ThemeTokenstraduz o token set dotempest_coreem custom properties, com claro e escuro de uma vez.ThemeTokenstambém emite--t-font-family-body(pilha de sistema por default), e o reset aplica nobody.make_css_routerserve a folha renderizada uma única vez, comETage304; a página linkasheet.url(path), que o router serve com cache de um ano comoimmutable, e o path sem versão recebeno-cache.app_stylesheet()já traz tokens, reset, formulários e componentes; suas regras entram porextra=.sheet.cls(...)transforma typo de classe emKeyError.
Veja também: Camada UI » e Formulários a partir de schemas Pydantic ».