Devlog · Fase 0 · 6/7
Catppuccin e vidro fosco: o design system de aubia.dev
Dois temas Catppuccin, superfícies translúcidas e três fontes definem aubia.dev. Configurações compartilhadas dão coerência ao site, com limites no desfoque e no movimento.
- design-system
- catppuccin
- tailwind
- motion

O fundo escuro de aubia.dev não é preto. É um cinza azulado, #1e1e2e, sobre o qual quatro degradês radiais formam manchas de cor malva, lavanda, pêssego e azul. Essas manchas ficam fixas enquanto você rola a página. Os cartões e a barra de navegação deixam suas cores aparecerem através de um fundo translúcido.
Escolhi o Catppuccin para os temas do site. O malva aparece nos links dos artigos e nos contornos de foco pelo teclado; superfícies, títulos e animações têm suas próprias configurações compartilhadas. Um componente pode reutilizar um cartão ou uma animação de entrada sem redefinir cada cor e cada duração.
Mocha e Latte, os mesmos nomes para dois temas
A paleta oficial do Catppuccin inclui quatro temas. O site usa dois: Mocha, o mais escuro, e Latte, o claro. Cada um fornece vinte e seis cores com os mesmos nomes. text é a cor do texto, base é o fundo principal; mauve e peach são tonalidades cujo uso depende da interface.
Essas cores se tornam variáveis CSS com o prefixo --color-ctp-. Sua declaração no bloco @theme do Tailwind disponibiliza classes como text-ctp-mauve e bg-ctp-base. A classe no componente continua a mesma quando o tema muda. Sua variável passa, por exemplo, do malva claro do Mocha, #cba6f7, para o roxo mais intenso do Latte, #8839ef.
As vinte e seis cores não bastam para descrever o site inteiro. O vidro precisa de uma cor de fundo, um contorno e reflexos; os degradês dos títulos têm seus próprios pontos de cor. Essas configurações complementam a paleta no CSS. Os destaques mantêm o tom Catppuccin original nos dois temas, incluindo os títulos e o realce Phiki do blog. Sobre fundos claros, alguns ficam abaixo do limite de contraste WCAG AA; o site assume essa escolha visual.
O tema claro exige mais do que uma troca entre texto e fundo. No modo escuro, os cartões usam o cinza profundo mantle, com contornos brancos de baixa opacidade. No modo claro, usam base, quase branco, com um contorno escuro. As sombras externas das superfícies de vidro, invisíveis no Mocha, tornam-se cinza-azuladas no Latte. A luz separa as superfícies em um tema, a sombra no outro.
A escolha do tema antes de a página aparecer
Na primeira visita com JavaScript ativado, o site exibe Mocha, mesmo que o sistema prefira o modo claro. O seletor oferece "Claro", "Escuro" e "Sistema". Essa última opção remove o atributo data-theme da raiz HTML, permitindo que a media query prefers-color-scheme aplique o tema do sistema.
Um pequeno script no <head> lê a preferência salva antes do carregamento do React. Se não houver um valor válido, ou se o armazenamento local estiver inacessível, ele define data-theme="dark". O CSS contém os valores do Latte sob [data-theme='light'] e sob a media query do modo sistema. O navegador pode escolher as cores corretas sem esperar a hidratação da página.
Quando você muda de tema, o seletor aplica a escolha ao documento e tenta salvá-la. Se o armazenamento estiver bloqueado, a mudança funciona durante a visita, mas pode não ser preservada quando você voltar. Sem JavaScript, o script inicial não é executado: a ausência do atributo deixa o CSS seguir o sistema.
O efeito de vidro dos cartões
A transparência deixa o plano de fundo aparecer; o desfoque suaviza seus detalhes. Nos cartões, essas duas configurações se combinam com uma borda e um reflexo interno:
@utility glass-card {
background: color-mix(in oklch, var(--glass-bg-card) 62%, transparent);
backdrop-filter: blur(40px) saturate(1.6) brightness(1.02);
border: 1px solid color-mix(in oklch, var(--glass-stroke) 12%, transparent);
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--glass-highlight) 8%, transparent);
}
A mistura com transparent dá ao fundo uma opacidade de 62% neste caso. Ela colore o que aparece atrás do cartão sem esconder tudo. A propriedade backdrop-filter desfoca o plano de fundo, depois aumenta sua saturação e eleva um pouco seu brilho. O texto do cartão continua nítido. Com um fundo totalmente opaco, os pixels que estão atrás continuam existindo, mas o fundo esconde o resultado do filtro.
O reflexo interno clareia a borda superior e permanece branco com baixa opacidade nos dois temas. A borda passa do branco ao cinza-azulado no modo claro, independentemente do desfoque. Ela separa o cartão do fundo sem lhe dar um contorno luminoso.
Nem todas as superfícies usam a mesma mistura. Os botões internos do menu usam 58% de cor de fundo, a navegação usa 70% e a variante glass-strong, 80%. O raio de desfoque vai de 40 pixels nos cartões a 80 nessa variante densa, usada ao redor do formulário de inscrição. A superfície do formulário esconde mais da decoração do que os botões do menu.
O menu de idiomas mantém um fundo sólido
Sobrepor duas superfícies desfocadas não equivale a aplicar o mesmo efeito duas vezes ao fundo da página. Um ancestral que já tem um backdrop-filter define uma backdrop root, um limite além do qual o filtro de um descendente não consegue mais obter pixels. will-change: opacity pode criar o mesmo limite. O filho não herda o filtro; o que muda é a área disponível para seu próprio filtro.
O seletor de idioma encontra essa situação dentro da navegação. Seu painel usa, portanto, um fundo opaco, da cor dos cartões. Os rótulos não dependem de um segundo desfoque para continuar distintos do conteúdo que rola atrás deles.
O painel também é um elemento HTML <details>, cujos links são renderizados mesmo quando ele está fechado. Eles estão presentes no HTML inicial produzido pela renderização no servidor. Ele abre sem JavaScript; o React acrescenta o fechamento ao clicar fora ou pressionar Escape.
Um portal React poderia mover um painel para outro lugar no documento para tirá-lo de dentro de seus ancestrais. Esse deslocamento não remove os links do documento. Já um painel montado apenas no cliente não fornece esses links na resposta HTML inicial. O <details> usado aqui preserva tanto os links renderizados no servidor quanto a abertura nativa, sem um portal.
Chakra Petch para os títulos, Geist para a leitura
As formas angulosas da Chakra Petch aparecem nos títulos. A Geist é usada no corpo do texto e na interface, e a Geist Mono, no código. Em um artigo como este, essa divisão distingue subtítulos, parágrafos e trechos de código sem exigir uma mudança de cor a cada passagem.
As três fontes são hospedadas no próprio site. O navegador as baixa do site, sem fazer requisições ao Google Fonts ou a outro fornecedor de fontes. Os arquivos vêm dos pacotes Fontsource instalados; o plugin de fontes do laravel-vite-plugin prepara as declarações @font-face durante o build, e a diretiva Blade @fonts as inclui na página.
As três fontes usam o provider local(), diretamente de seus pacotes. A Chakra Petch carrega um único WOFF2 latino, com peso 600 e estilo normal, sem uma variante WOFF redundante. A Geist e a Geist Mono são fontes variáveis: seu arquivo WOFF2 cobre os pesos de 100 a 900.
A Chakra Petch e a Geist são pré-carregadas nas páginas públicas. A Geist Mono também é pré-carregada na página inicial e no blog, mas não nas páginas legais: três pré-carregamentos de fontes no primeiro caso e dois no segundo. O trecho de configuração da Chakra Petch especifica o que deve acontecer durante seu download:
local('Chakra Petch', {
variants: [{
src: 'node_modules/@fontsource/chakra-petch/files/chakra-petch-latin-600-normal.woff2',
weight: 600,
style: 'normal',
}],
variable: '--font-display',
display: 'swap',
preload: [{ weight: 600, style: 'normal' }],
fallbacks: ['system-ui', 'sans-serif'],
}),
Com swap, o navegador pode exibir o texto em uma fonte de fallback antes de receber a fonte prevista. O plugin ajusta as métricas dos fallbacks da Chakra Petch e da Geist para aproximar suas dimensões das fontes previstas. Essas aproximações reduzem possíveis deslocamentos na troca de fonte, sem garantir a ausência de mudanças no layout. A Geist Mono mantém seus fallbacks monospace: a geração de fallbacks ajustados está desativada para essa família.
Animações de entrada e sequências animadas
Os títulos permanecem visíveis no HTML do servidor. O H1 é estático; o título da FAQ usa a variante text de HeadingReveal, com initial={false}. Cada palavra se desloca 8 pixels em 700 milissegundos, sem redução de opacidade nem desfoque. Esses estados são definidos no componente:
const wordVariants = {
hidden: { opacity: 1, y: 0 },
visible: {
opacity: 1,
y: [8, 0],
transition: { duration: 0.7, ease: EASE_LUMA },
},
};
Cada palavra fica dentro de um m.span em inline-block que recebe wordVariants, com um intervalo padrão de 60 milissegundos entre as palavras. A animação de entrada ocorre uma única vez, quando metade do elemento observado está visível. A constante compartilhada EASE_LUMA vale [0.22, 0.61, 0.36, 1]: essa curva de Bézier controla a aceleração e a desaceleração. Os nós das ilustrações usam animações de entrada com molas, com rigidez e amortecimento.
O Motion não controla tudo. Loops CSS animam as ilustrações, e timers JavaScript alternam o texto da frase de abertura letra por letra. Os cartões da seção Visão têm uma animação de entrada CSS vinculada à rolagem, ativada apenas se o navegador suportar animation-timeline: view() e o visitante não tiver solicitado movimento reduzido. Caso contrário, mantêm seu estado visível padrão.
Reagir à preferência de movimento reduzido
A configuração de sistema "Reduzir movimento" pode mudar enquanto uma página está aberta. O hook local useSafeReducedMotion lê a media query correspondente com useSyncExternalStore e se inscreve no evento change de matchMedia. O React pode então renderizar novamente os componentes inscritos quando a preferência muda.
Essa implementação local evita uma limitação do Motion 13.2.0: seu hook useReducedMotion inicializa um estado React sem atualizá-lo, embora a documentação afirme que ele responde a mudanças.
O servidor não conhece a preferência do navegador. Por isso, o hook local retorna false na renderização no servidor e na hidratação inicial, depois lê o valor real no cliente. Esse uso do snapshot do servidor nos dois casos corresponde ao comportamento descrito pelo React para useSyncExternalStore.
O CSS atua sem esperar essa leitura: as regras de movimento reduzido param o cursor piscante, a animação de entrada fade-up e os loops das ilustrações aos quais se aplicam. Os confetes têm uma proteção própria. Essas regras não desativam todas as transições do site.
O texto animado exibe sua última frase na renderização do servidor. Enquanto o movimento reduzido está ativo, mantém essa frase e não inicia timers. Uma mudança de preferência durante a visita cancela o timer em andamento; desativar o movimento reduzido permite retomar a sequência. As frases sobrepostas, invisíveis e excluídas dos leitores de tela, reservam a altura necessária para a mais longa. O contador diferido também tem um espaço reservado abaixo do formulário.
Esta construção é contada aqui, artigo após artigo. O que vem a seguir depende do que você disser sobre ela.
Entrar na lista de espera