Tema (Material 3)¶
Seus widgets já nascem bonitos. Um Button cru vira um botão Material 3
preenchido — pílula, cor primária, state layer no hover, elevação. Um Input
vira um campo outlined com foco animado. Você não escreve nenhum CSS para
isso. ✨
Esse é o tema base sempre-ligado que chegou na 0.6.0: uma folha de estilo
Material 3 (client/theme.js) injetada uma vez, no mount, que dá tipografia,
espaçamento e controles acentuados sensatos a todo app — mesmo o que você nunca
estilizou. E quando você quer fugir do padrão, o Style inline do widget sempre
ganha.
De onde vem o estilo (tempest-core ≥ 0.8.1)
O visual em repouso de cada Button/Input — preenchimento, borda, forma
e cor — agora vem do sistema de variantes do tempest-core, resolvido inline
pelo próprio widget. O client/theme.js cuida só do que o inline não
consegue expressar: a state layer (::before) de hover/foco/clique, o anel
de foco e o tipo de fonte. Os ajudantes filled_button/tonal_button/… são
uma fachada com nomes MD3 sobre as variantes do core. Você continua ganhando o
visual Material 3 sem escrever nenhum CSS.
O mínimo: confie no tema base¶
Não há nada a configurar. Escreva o app normalmente; o tema base entra sozinho.
from dataclasses import dataclass
from tempest_core import App, Button, Column, Input, Text, Widget
@dataclass
class State:
name: str = ""
def make_state() -> State:
return State()
def view(app: App[State]) -> Widget:
def set_name(event) -> None:
app.set_state(lambda s: setattr(s, "name", event.value))
return Column(
children=[
Text(content="Como você se chama?"),
Input(value=app.state.name, on_change=set_name, key="name"),
Button(label=f"Olá, {app.state.name or 'mundo'}!", key="hello"),
],
)
Rode nos três modos — o visual é idêntico:
tempestweb dev --mode wasm # Python no browser (Pyodide)
tempestweb dev --mode server # Python no servidor (FastAPI + WebSocket)
tempestweb dev --mode transpile # app transcrito para JS nativo (bundle estático)
O que você acabou de ganhar de graça:
- Tipografia — a família
Roboto/system-uiem vez do Times New Roman do navegador, emText,ButtoneInput. - Botão — pílula preenchida com cor primária, state layer translúcido no hover/foco/clique e elevação animada.
- Campo —
Inputoutlined arredondado, cuja borda engrossa e recolore para a cor primária no foco. - Checkbox — caixa dimensionada e acentuada com a cor primária.
Por que uma folha de estilo, e não Style inline?
CSS inline não consegue expressar :hover, :focus-visible, :active ou
:disabled — justamente os estados que fazem um controle parecer moderno. Eles
moram na folha base, chaveados pelo atributo data-tw-type que o renderizador
DOM carimba em cada elemento.
Sobrescrevendo o tema: o Style inline ganha¶
A folha base é um piso, não uma jaula. Como ela não usa !important e o Style
do widget vira style="" inline no elemento, suas declarações vencem a cascata. Os
estados de interação (hover/foco) continuam funcionando por cima.
from tempest_core import Button, Style
from tempest_core import Color
# A pílula, a tipografia e o state layer continuam — só a cor muda.
Button(
label="Comprar agora",
style=Style(background=Color.from_hex("#0b57d0")),
key="buy",
)
Rebrand global por tokens
Os tokens do tema são custom properties CSS em :root (--tw-primary,
--tw-surface, --tw-outline, …). Para retematizar a UI inteira sem tocar em
nenhum widget, sobreponha-os de um <style> próprio na sua página host:
Elevação com Style(shadow=...)¶
Na 0.6.0, um Shadow no Style de um widget vira um box-shadow CSS de
verdade na web — a mesma elevação que os renderizadores nativos (Qt/Compose)
desenham. O mapeamento é direto: offset_x offset_y blur color.
from tempest_core import Column, Text, Widget
from tempest_core import Color, Edge, Shadow, Style
def card(content: str) -> Widget:
return Column(
children=[Text(content=content)],
style=Style(
background=Color.from_hex("#ffffff"),
radius=12.0,
padding=Edge.all(16.0),
shadow=Shadow(
color=Color(r=0, g=0, b=0, a=0.3),
blur=3.0,
offset_x=0.0,
offset_y=1.0,
),
),
key="card",
)
Isso emite box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3). Um Shadow sem color
explícito cai num preto translúcido neutro, então uma elevação ainda aparece sem
você escolher um tom.
Os mesmos níveis de elevação do MD3
A folha base define --tw-elevation-1 e --tw-elevation-2 (umbra + penumbra)
e os aplica ao botão preenchido no hover/clique. Quando você quer um cartão ou
botão com elevação própria, use Style(shadow=...) — o número que vimos acima
(blur=3, offset_y=1) é exatamente o repouso do elevated_button.
Variantes de botão Material 3¶
Você não precisa lembrar de quais cores compõem um botão tonal ou outlined. O
tempestweb.components traz as cinco variantes MD3 como helpers de uma linha:
from tempest_core import App, Row, Widget
from tempestweb.components import (
elevated_button,
filled_button,
outlined_button,
text_button,
tonal_button,
)
def view(app: App[State]) -> Widget:
def save() -> None:
app.set_state(lambda s: s)
return Row(
children=[
filled_button("Salvar", on_click=save, key="save"),
tonal_button("Duplicar", key="dup"),
elevated_button("Exportar", key="export"),
outlined_button("Editar", key="edit"),
text_button("Cancelar", key="cancel"),
],
)
| Helper | Ênfase | Como é construído |
|---|---|---|
filled_button |
Alta (padrão) | Botão cru — o tema base dá o look preenchido inteiro |
tonal_button |
Média | Fundo secondary container + texto on-container, plano |
elevated_button |
Média | Superfície clara + texto primário + sombra de repouso |
outlined_button |
Média | Contorno + rótulo primário, fundo transparente |
text_button |
Baixa | Só o rótulo primário, sem fundo nem contorno |
Como as variantes se distinguem do preenchido
O filled_button é um Button sem Style inline, então o tema base
fornece tudo. As outras variantes recebem um Style pequeno (fundo / cor /
borda / sombra). Definir um background inline é também o sinal que a folha
base usa para tirar a variante da elevação automática do botão preenchido —
por isso tonal/outlined/text ficam planos enquanto o elevated_button carrega
a própria sombra.
Campos temáticos¶
Os campos nativos do tempestweb — TextField, EmailField, PasswordField — usam
um Input cru sem Style inline de propósito, exatamente para que a folha base
os renderize como campos claros e outlined, consistentes com o resto da UI. Um
rótulo discreto fica acima e uma linha de erro vermelha aparece quando você passa
error.
from tempest_core import App, Column, Widget
from tempestweb.components import EmailField, PasswordField, validate_email
def view(app: App[State]) -> Widget:
def set_email(value: str) -> None:
app.set_state(lambda s: setattr(s, "email", value))
def set_password(value: str) -> None:
app.set_state(lambda s: setattr(s, "password", value))
return Column(
children=[
EmailField(
value=app.state.email,
on_change=set_email,
error=validate_email(app.state.email) or "",
key="email",
),
PasswordField(
value=app.state.password,
on_change=set_password,
key="password",
),
],
)
Mais sobre campos e formulários
Os campos e os formulários prontos (LoginForm, SignupForm, os campos BR)
têm página própria em Componentes prontos. Aqui o foco é só
como o tema os deixa bonitos sem você estilizar nada.
Rebrand por tokens, do Python¶
A folha base pinta tudo a partir de custom properties --tw-* no :root,
e é por elas que um app troca de cara — sem tocar em widget nenhum. O que
faltava era o meio-campo: você monta a paleta em Python e precisa dela na
página.
from tempest_core import Theme, ThemeMode
from tempest_core import Color
from tempestweb.html import theme_css
def head() -> str:
"""Monta o markup de head que retematiza a interface inteira.
Returns:
str: Um elemento de estilo com a paleta do app.
"""
theme = Theme.from_seed(Color(r=39, g=58, b=79), mode=ThemeMode.SYSTEM)
return f"<style>{theme_css(theme)}</style>"
Theme.from_seed gera os 39 papéis do Material 3 a partir de uma cor
semente — claro e escuro — e theme_css emite os que a folha de fato lê. O
bloco vai no <head>, antes de a folha base ser instalada no mount; como
ela declara os mesmos nomes com a mesma especificidade, o seu vence por vir
depois.
Modo escuro sai de graça, pelo mesmo interruptor da folha
Tema em SYSTEM emite o esquema claro no :root e o escuro sob
:root[data-tw-theme="dark"] — o mesmo seletor que a folha base usa,
então a paleta da app e os tokens da folha viram juntos, no modo que a app
resolveu. Tema fixado em DARK emite o esquema escuro nos dois
seletores: pinta antes de qualquer atributo chegar e continua ganhando da
folha depois que ele chega.
Nunca sai media query. Ver "Por que não prefers-color-scheme" adiante:
um widget não vê o SO, então escurecer a página pelo SO deixava árvore
clara em fundo escuro — e o lado inline é o que ganha.
Só o que a folha consome
theme_css emite as variáveis que a folha base lê, não os 39 papéis.
Variável que ninguém consome parece zelo e é dívida: o próximo leitor
tem que ir ao CSS descobrir se ela faz algo.
Declare o tema, e o host o entrega¶
O trecho acima monta o CSS à mão porque era o único caminho. Hoje há um mais
curto, e ele cobre as duas metades: declare THEME ao lado da sua view.
# app.py
from dataclasses import dataclass
from tempest_core import App, Theme, Widget
from tempest_core import Color
@dataclass
class State:
"""The app's state."""
def make_state() -> State:
"""Build the initial state."""
return State()
def view(app: App[State]) -> Widget:
"""Build the screen."""
...
#: A paleta da marca. O artefato a lê e entrega às duas pontas.
THEME: Theme = Theme.from_seed(seed=Color(r=39, g=58, b=79))
O artefato gerado — tanto o do Modo B quanto o do Modo A — passa esse THEME
para o app quando o constrói, e isso importa porque componente resolve cor em
Python: um botão preenchido carrega o próprio fill como estilo inline. Tema que
não chega na árvore é tema que não pinta, por mais tokens que a página tenha.
As duas pontas que o host cobre:
- A árvore — o tema vai para o
App, então cada componente nasce com a sua paleta. - A página — os tokens
--tw-*que a folha base lê. No Modo B eles são escritos no<head>na renderização; no Modo A a página é estática e o app só existe depois do Pyodide subir, então o CSS é injetado no boot, antes do primeiro mount.
Theme(primary=...) não é a mesma coisa que Theme.from_seed(...)
Um Theme carrega um conjunto de tokens (tokens) e alguns campos soltos
de conveniência (primary, background, …). Os componentes leem os
tokens. Montar um tema preenchendo só os campos soltos deixa a árvore
inteira na paleta baseline — foi exatamente esse o bug do exemplo
theme-switcher, cujos botões ficavam roxos enquanto o swatch dizia teal.
Use Theme.from_seed, ou construa o TokenSet explicitamente.
Tema dinâmico repinta os componentes, não os tokens da página
app.set_theme(...) reconstrói a árvore, então tudo que resolve cor em Python
acompanha na hora. Os tokens --tw-*, porém, são escritos uma vez — eles
seguem o THEME declarado. Na prática: as cores dos widgets trocam, e os
estados que só a folha expressa (hover, foco) continuam na paleta declarada.
Se a troca em runtime é o coração do seu app, declare THEME com a paleta que
ele abre.
Modo escuro: passe o tema ao widget¶
Um widget estilizado resolve as próprias cores do tema que ele carrega — do
campo theme dele, não de um tema ambiente. É por isso que o idioma é uma linha:
Passe app.theme e a árvore inteira segue o app.set_theme(...); deixe de fora
e o widget resolve a paleta clara, mesmo que o app esteja em modo escuro.
Vale igual nos três modos.
from tempest_core import App, Card, Column, Text, Theme, ThemeMode, Widget
def view(app: App[State]) -> Widget:
"""Desenha um cartão que acompanha o tema do app."""
theme: Theme = app.theme
return Column(
key="body",
children=[
Card(
key="card",
theme=theme,
children=[Text(content="Segue o tema", key="label")],
),
],
)
def escurecer(app: App[State]) -> None:
"""Troca o tema do app, o que re-resolve todo widget que o recebeu."""
app.set_theme(Theme(mode=ThemeMode.DARK))
Widget de layout não tem theme
Row, Column e Text não carregam cor própria, então o core não lhes dá o
campo — passar theme= levanta ValidationError com o nome do campo. A cor
que eles mostram é a que herdam da caixa estilizada em volta.
Componente propaga como o core propaga
Um EmailInput é o campo: ele repassa o tema para o Input que constrói.
Um SearchBar compõe um campo e sobrepõe o estilo que resolveu, então o
campo interno mantém a paleta default — e o Modo C reproduz essa distinção
componente por componente, fixada por matriz de paridade nos dois modos.
Os componentes do tempestweb seguem o mesmo idioma¶
TextField, EmailField, PasswordField, LoginForm e SignupForm (de
tempestweb.components) recebem theme como qualquer widget, e repassam para
tudo que constroem — o Input de cada campo, os campos de cada form, o botão de
submit, e a cor do label e da linha de erro:
from tempestweb.components import LoginForm
LoginForm(
email=app.state.email,
password=app.state.password,
on_email_change=set_email,
on_password_change=set_password,
on_submit=entrar,
theme=app.theme,
key="login",
)
Antes da 0.101.0 eles eram claros por construção
Os cinco não declaravam theme e não repassavam nenhum, então um app escuro
recebia campo claro sem nenhum aviso — e o pior caso é fundo escuro (da
folha base) com texto escuro (inline), ou seja, ilegível. Se você seguia o
idioma theme=app.theme em cada widget, esses cinco eram os que ignoravam.
Stepper também aceita tema, desde tempest-core 0.16.0
Ele era o sexto da #158
e o único que morava no tempest-core, então dependia de um release lá.
Agora declara theme, variant, color_scheme e size como o resto do kit,
e o valor lê o papel ON_SURFACE do tema. Nenhum componente da matriz de
paridade fica fora do eixo escuro — a lista LIGHT_ONLY_COMPONENTS ficou
vazia, e o guard continua lá para o próximo componente que nascer sem tema.
Um SearchBar ou um TextField compõe um campo e sobrepõe o estilo que
resolveu, então o campo interno mantém a paleta default — e o Modo C reproduz
essa distinção componente por componente, fixada por matriz de paridade nos
dois modos.
Modo C: as tabelas geradas têm eixo de modo desde a 0.99.0
O Modo C não tem Python, então o estilo resolvido de cada widget viaja em
tabela gerada. Até a 0.98.0 essas tabelas eram geradas com o tema default:
todo widget e todo componente transpilado renderizava claro, e como o
estilo inline ganha do stylesheet, era a metade com precedência que falhava.
Agora a tabela carrega os dois modos e o builder escolhe por
theme.is_dark().
A folha base segue o modo que você declara¶
O Style que o core resolve viaja inline em cada widget, e inline ganha do
stylesheet. Mas metade da tela não é inline: o fundo da página, a superfície de um
campo, o ::placeholder, todo estado de :hover/:focus, a superfície de um
overlay. Isso é CSS — e o CSS não tinha eixo de modo, então um app escuro mostrava
campo branco dentro de cartão escuro.
Agora tem. O renderizador marca o documento com o modo resolvido:
e a folha base redefine seus tokens sob esse seletor. Você não escreve nada para
isso acontecer: no Modo B (e SSE) o servidor manda um envelope theme; no Modo A
o runtime chama o callback direto; no Modo C o set_theme marca o documento em
processo.
Declarou escuro? Repasse app.theme aos widgets
A marcação segue o tema do app; a cor de cada widget segue o theme que
aquele widget recebeu. Se você chama app.set_theme(Theme(mode=DARK)) e
não repassa theme=app.theme aos widgets, a folha escurece e os widgets
continuam claros — medido: um Input sem theme fica com fundo escuro (folha)
e texto escuro (inline), ou seja, ilegível. Passe o tema; é a mesma regra do
core.
Por que não prefers-color-scheme
Seria a resposta óbvia — e estaria errada. Um widget construído com
Theme(mode=SYSTEM) resolve claro no core: ele não vê o SO. Escurecer a
folha por causa do SO colocaria uma árvore clara numa página escura. Se você
quer seguir o SO, leia app.media.platform_dark_mode no seu view e chame
set_theme — aí as duas metades andam juntas.
Os pares da paleta têm gate de contraste
A regra color-contrast do axe precisa de layout real, então o gate de a11y a
desliga e o job Lighthouse é sinal fraco — o que deixava uma paleta escura
ilegível passar sem ninguém notar. O que não precisa de layout é o par de
papéis: --tw-on-surface é, por definição, o que vai sobre --tw-surface.
tests/client/theme-contrast.test.js calcula os 12 pares que a folha promete,
nos dois modos, e reprova abaixo de AA. Par mais apertado hoje: warning sobre
surface no claro, 6,02:1 para um mínimo de 4,5.
O primeiro light não é enviado
Os tokens da folha são a paleta clara, então marcar claro no mount gastaria um frame para dizer o que o CSS já diz. Toda mudança posterior é enviada, inclusive a volta ao claro.
Indicadores de progresso¶
ProgressBar e Spinner não têm tamanho próprio: sem folha de estilo, os dois
renderizam como div vazia de altura zero — presentes na árvore, invisíveis na
tela, que é pior que ausentes, porque o app diz que está mostrando progresso e o
usuário não vê nada. O tema base os desenha, e o color_scheme escolhe o acento
entre as famílias que o core nomeia (primary, secondary, tertiary, error,
success, warning, info, neutral).
from tempest_core import App, Column, Widget
from tempest_core import ProgressBar, Spinner
def view(app: App[State]) -> Widget:
return Column(
children=[
ProgressBar(value=0.42, key="leitura"),
ProgressBar(indeterminate=True, key="na-fila"),
ProgressBar(value=1.0, color_scheme="success", key="pronto"),
Spinner(size=24.0, key="girando"),
],
)
Uma barra determinada é um trilho com um preenchimento em porcentagem, e a
transição do tema faz a largura andar suave a cada valor novo. Uma barra
indeterminada não declara valor — nem no CSS nem para o leitor de tela, que
recebe role="progressbar" sem aria-valuenow, porque um número sobre trabalho
que ninguém está medindo seria lido como fato.
Rebrand igual ao resto
O acento sai de --tw-indicator, que por sua vez vem do token da família.
Sobrescreva --tw-success (ou qualquer outro) e as barras daquela família
acompanham, sem tocar em widget nenhum.
Movimento é decoração; o estado não é
Sob prefers-reduced-motion: reduce a animação para e a barra
indeterminada fica como uma faixa estática — quem pediu menos movimento
continua vendo que há algo rodando.
No SSR o desenho é inline
render_to_html não embarca a folha base, só um reset, então lá os dois
saem com estilo inline autossuficiente: trilho translúcido e preenchimento
em currentColor, ou seja, a barra assume a cor do texto ao redor. É a
escolha que faz uma página estática mostrar progresso sem depender de
nenhum CSS seu.
Recapitulando¶
- O tema base Material 3 está sempre ligado — tipografia, espaçamento e controles acentuados saem prontos, sem estilizar widget por widget.
- O
Styleinline do widget sempre ganha da folha base (sem!important); os estados de hover/foco continuam funcionando por cima. - Retematize a UI inteira sobrepondo os tokens
--tw-*de um<style>na página —theme_css(Theme.from_seed(...))monta esse bloco, com modo escuro junto. Style(shadow=...)vira umbox-shadowCSS na web, igual aos renderizadores nativos.filled_button/tonal_button/elevated_button/outlined_button/text_buttonsão as cinco variantes MD3 em uma linha cada.TextField/EmailField/PasswordFieldherdam o campo outlined do tema.ProgressBareSpinnersó existem na tela porque o tema os desenha; ocolor_schemeescolhe o acento e o SSR os emite com estilo inline próprio.- Tudo renderiza igual no Modo A (WASM) e no Modo B (servidor).