Ir para o conteúdo

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= — um Style tipado, 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 que Style nã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:

body {
  margin: 0;
  font-family: var(--t-font-family-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 + StyleSheet cobrem 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 (Style só aceita cor hexadecimal).
  • ThemeTokens traduz o token set do tempest_core em custom properties, com claro e escuro de uma vez.
  • ThemeTokens também emite --t-font-family-body (pilha de sistema por default), e o reset aplica no body.
  • make_css_router serve a folha renderizada uma única vez, com ETag e 304; a página linka sheet.url(path), que o router serve com cache de um ano como immutable, e o path sem versão recebe no-cache.
  • app_stylesheet() já traz tokens, reset, formulários e componentes; suas regras entram por extra=.
  • sheet.cls(...) transforma typo de classe em KeyError.

Veja também: Camada UI » e Formulários a partir de schemas Pydantic ».