Zen · v1.0.0
O sistema por trás de tudo que construímos.
Uma fonte, dez cinzas, raio zero. Este documento não é aspiracional — cada regra foi medida na frequência real de uso em 28 componentes e 7 primitivas do @ciromaciel/components-kit. Onde o código diverge da regra, a divergência está registrada.
Família tipográfica
Montserrat, 4 pesos
Degraus de cinza
Nenhuma cor de marca
Border radius
57 ocorrências, 0 exceções
Papéis de texto
Label, dado, corpo, display
Fundamento
As seis leis
Tudo o resto é derivado. Se uma decisão de design não puder ser justificada por uma destas leis, a decisão está errada — ou a lei precisa mudar aqui primeiro.
Conteúdo sobre cromo
Cada elemento de cromo precisa responder "que informação eu comunico?". Se a resposta for "nenhuma, é estética", ele sai.
Sobreviveram ao corte: o filete de 1px (comunica separação), o numeral fantasma de Steps (comunica ordem), o badge dot (categoria com mínimo de tinta). Foram cortados: sombras, bordas de Card, raio.
Quando a página parece vazia demais, o erro está no conteúdo, não no design. A resposta é escrever melhor, não adicionar moldura.
Hierarquia massiva
Não existe cor de destaque. A ênfase vem de contraste de escala, e o contraste precisa ser violento: salto de 3× a 6× entre níveis, nunca 1,2×.
O par canônico é o cabeçalho de palco: label minúsculo, leve em tinta e disperso em tracking, imediatamente acima de um título enorme, pesado e apertado. Opostos em cinco dimensões ao mesmo tempo — é isso que cria a tensão.
Implementado em SectionHeader. Toda seção do kit passa por ele.
O Palco
Toda seção é um palco: fundo próprio, respiro vertical generoso, e um anúncio antes da performance.
Não é opcional nem reimplementado por seção — é a primitiva Section. As únicas exceções (Header, Hero, SocialProof, Footer) existem porque têm geometria própria.
Palcos consecutivos alternam white ↔ gray.1. É a única marcação de ritmo da página.
Config-driven, não JSX
O consumidor passa dados, nunca marcação.
Não é preferência de API — é o que mantém as leis 1–3 aplicáveis. Se o consumidor puder injetar JSX, ele injeta cromo, e o sistema erode em três sprints.
Contrato padrão: id, badge, subtitle, title, background, items, cta, note.
Honestidade verificável
Cada afirmação forte carrega o que a torna checável.
Por isso StatsGrid tem description sob cada número e note sob a grade; Callout tem rodapé de honestidade; Hero tem note com o que custa experimentar. A nota é sempre o degrau mais baixo: 13px, gray.6, lh 1.5.
Sem esta lei, hierarquia massiva vira só um número grande sem lastro. A nota é o que separa design de propaganda.
Um lugar por item
No cabeçalho, cada item mora num menu só. O rodapé é o mapa do site, não a cópia do cabeçalho.
O cabeçalho mostra tipos de coisa, não o catálogo: no máximo três menus, até quatro colunas, cerca de oito itens por coluna, e a lista completa na página índice (/books, /tools). O rodapé leva às páginas índice, a quem conduz, ao contato e às páginas legais: quatro colunas, até sete links cada, nunca uma linha por livro ou por ferramenta. Em 30/10/2026 o cabeçalho tinha 58 itens, 17 deles duas vezes, e o rodapé 56 links; ficaram 28 e 23.
Coisa nova entra no seu lugar único: um livro novo, em "Para quem"; uma ferramenta nova, em /tools. Só entra no menu se for um tipo novo.
Token
Tipografia
Uma família, quatro pesos, três papéis. Como não há cor de marca, a tipografia carrega sozinha toda a hierarquia — por isso é a parte mais rígida do sistema.
A família
Geométrica, altura-x alta, e — decisivo — o peso 900 é genuinamente pesado. Metade do sistema depende de o Black existir de verdade.
Montserrat
Pilha de fallback sistema-primeiro: se o Google Fonts falhar, a página degrada para a fonte nativa da plataforma, não para Times.
CSS
font-family: Montserrat, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Helvetica, Arial, sans-serif;Os quatro pesos
Nenhum outro valor é permitido. Não existe 400 declarado neste sistema: quando o peso é escrito, é para subir, nunca para descer.
Black 900
44× no kit
fw={900}Títulos, métricas, wordmark
ExtraBold 800
20× no kit
fw={800}Labels uppercase, botões, badges
Bold 700
10× no kit
fw={700}Ênfase em corpo, rótulo de métrica
Medium 500
5× no kit
fw={500}Legenda técnica, letra miúda
Regra
900 e 800 nunca se tocam no mesmo bloco. Se um título 900 e um label 800 estão adjacentes, é porque o tamanho os separa em 4×. Sem o salto de tamanho, os dois pesos brigam.
Os quatro papéis
Todo texto do sistema é um dos quatro. Texto que se lê em sequência é corpo; texto que se compara em coluna é dado.
Prova verificável
Label
o anúncio
Tamanho
10–13px
Peso
800
Tracking
1.5px
Cor
gray.4
Line-height
1.4
O número carrega a hierarquia e a nota o sustenta.
Corpo
a performance
Tamanho
15–20px
Peso
400
Tracking
normal
Cor
gray.6
Line-height
1.7
ana@acme.com.br · Ativo · 2h
Dado
a evidência
Tamanho
12–14px
Peso
500
Tracking
normal
Cor
gray.7
Line-height
1.4
Massivo
Display
a declaração
Tamanho
32–120px
Peso
900
Tracking
-0.04em
Cor
gray.9
Line-height
1.2
O par canônico
O padrão que define visualmente a marca. Label e título são opostos em cinco dimensões ao mesmo tempo — é isso que cria a tensão.
Prova verificável
Números que você pode conferir.
Label | Título | |
|---|---|---|
Tamanho | 11px | 44px |
Peso | 800 | 900 |
Caixa | Alta | Sentença |
Tracking | +1.5px | −0.04em |
Cor | gray.4 | gray.9 |
JSX — nunca reimplemente à mão, use SectionHeader
<Stack gap="xs">
<Text size="xs" fw={800} tt="uppercase" lts="1.5px" c="gray.4">
Prova verificável
</Text>
<Title order={2} fz="var(--fz-section-title)" maw={800}>
Números que você pode conferir.
</Title>
</Stack>A escala fluida
Nenhum título tem tamanho fixo. Redimensione a janela — os exemplos abaixo reescalam de verdade, porque são o mesmo clamp() que roda em produção.
--fz-hero
clamp(2.5rem, 8vw + 1rem, 6rem)40 → 96px · Primeira dobra
--fz-section-title
clamp(2rem, 5vw + 1rem, 3.5rem)32 → 56px · Título de seção — o mais frequente
--fz-big-metric
clamp(3rem, 10vw + 1rem, 7.5rem)48 → 120px · Métrica — a mais dramática
--fz-big-metric-sm
clamp(3rem, 5vw, 4.5rem)48 → 72px · Métrica em grade de 4 colunas
A fórmula
Sempre clamp(mínimo, Nvw + 1rem, máximo). O + 1rem garante que o texto ainda escale se o usuário aumentar a fonte base — vw puro ignora a preferência de acessibilidade. O coeficiente vw codifica a agressividade: hero cresce a 8vw, métrica a 10vw (a mais dramática), título de seção a 5vw (a mais contida, porque aparece dezenas de vezes por página).
Letter-spacing
O código tem 8 valores distintos; a escala real é de três. Os demais são drift a consolidar.
tight
2× no kit
lts="-0.04em"Todo display e título
control
5× no kit
lts="1px"Botão, link de navegação
label
10× no kit
lts="1.5px"Todo label uppercase
wide
19× no kit
lts="2px"Label de destaque, wordmark
Por que um é relativo e outro absoluto
-0.04em é relativo porque precisa escalar junto com a fonte fluida. 1.5px é absoluto porque label é sempre aplicado em 10–13px, então o valor fixo é previsível e mais fácil de acertar. A inversão de sinal é o ponto: em tamanho grande o espaçamento natural parece frouxo e precisa reagrupar; em caixa alta e peso 800, as letras colidem e precisam respirar.
Line-height
Cresce com o comprimento do texto e diminui com o tamanho da fonte.
1
Métrica
7× no kit
1.1
Hero
1× no kit
1.2
Título de seção
3× no kit
1.4
Rótulo compacto
6× no kit
1.5
Nota
3× no kit
1.6
Corpo curto
16× no kit
1.7
Corpo padrão
25× no kit
1.8
Bloco longo (legal)
6× no kit
Certo e errado
Faça
— salto de 4×
Infraestrutura
Login em minutos.
O contraste violento entre label e título é o que produz hierarquia sem precisar de cor.
Não faça
— salto de 1,3×
Infraestrutura
Login em minutos.
Sem salto de escala, os dois pesos brigam e nenhum dos dois lidera. A hierarquia some.
Faça
— corpo em gray.6
Fatura em real
Suporte no seu fuso, nota fiscal brasileira e cobrança sem conversão de moeda.
O preto absoluto é reservado a títulos e números. Corpo em gray.6 deixa o título liderar.
Não faça
— corpo em gray.9
Fatura em real
Suporte no seu fuso, nota fiscal brasileira e cobrança sem conversão de moeda.
Título e corpo com a mesma tinta achatam a hierarquia — a página vira um bloco só.
Token
Cor
Uma rampa de dez cinzas. Zero cor de marca. Cada degrau tem papel semântico fixo — a rampa não é uma paleta de onde se escolhe, é um mapa de funções.
A rampa
Os degraus 0–3 são superfície e estrutura. Os degraus 4–9 são tinta. Nenhum degrau atravessa a fronteira. Clique para copiar o hex.
gray.0
#FFFFFF
App base
Fundo branco puro
gray.1
#F9FAFB
Superfície alternada
Palco alternado, numeral fantasma
gray.2
10×
#F3F4F6
Filete padrão
Toda borda do sistema
gray.3
#E5E7EB
Borda muda
Citação, estado desabilitado
gray.4
7×
#9CA3AF
Texto quaternário
Label uppercase apenas
gray.5
20×
#6B7280
Texto terciário
Rótulos, legendas
gray.6
50×
#4B5563
Texto secundário
Corpo — a cor mais usada
gray.7
6×
#374151
Corpo enfático
Descrição de destaque
gray.8
4×
#1F2937
Texto primário escuro
Raro
gray.9
36×
#11181C
Preto da marca
Títulos, métricas, botão preenchido
Distribuição real
Duas cores dominam: gray.9 para o que grita, gray.6 para o que fala. Todo o resto é ajuste fino.
gray.6
50×
gray.9
36×
gray.5
20×
gray.2
10×
gray.4
7×
gray.7
6×
gray.8
4×
Os três pretos
Confundir os três é o erro mais comum do sistema.
gray.9
#11181C
Títulos, métricas, botão
Não é #000: o preto puro em tela grande vibra e cansa. Tem um toque de azul-esverdeado que assenta.
gray.7
#374151
Corpo com presença
Quando o parágrafo é curto e central, como na descrição do CTABanner.
gray.6
#4B5563
Corpo padrão
Se você está em dúvida sobre a cor de um parágrafo, é esta.
Os dois fundos
Só existem dois fundos de seção. É o ritmo inteiro da página.
Palco 01
SECTION_BACKGROUNDS.white · gray.0 · #FFFFFF / #0B0F12
Palco 02
SECTION_BACKGROUNDS.gray · gray.1 · #F9FAFB / #12181D
Palco 03
SECTION_BACKGROUNDS.white · gray.0 · #FFFFFF / #0B0F12
Por que o contraste é quase imperceptível
Apenas ~2% de luminância separa os dois fundos, deliberadamente. Ele não separa as seções — apenas dá textura ao scroll. A separação real vem do py de 80–120px. Um card branco sobre fundo branco não é card: é conteúdo com borda.
Regra · palco não é cartão
Uma página tem exatamente dois tons de palco, no claro e no escuro: SECTION_BACKGROUNDS.white (gray.0, #FFFFFF / #0B0F12, o mesmo do body) e SECTION_BACKGROUNDS.gray (gray.1, #F9FAFB / #12181D). Palco é tudo que ocupa a largura: faixa, seção, body, a barra do cabeçalho e o painel do menu, o rodapé e as páginas de documento. O white do Mantine não é palco: no escuro ele vale #1A2127, a superfície elevada, e só serve para cartão, modal, gaveta, menu suspenso e formulário em caixa sobre uma faixa. No claro os dois brancos coincidem, e o erro só aparece no escuro: em 21/10/2026 o cabeçalho e a faixa da newsletter usavam white, e a home escura mostrou três tons.
Papéis semânticos
Papel | Token | Uso |
|---|---|---|
textHeading | gray.9 | Títulos e métricas |
textBody | gray.6 | Corpo padrão |
textEmphasis | gray.7 | Corpo com presença |
textLabel | gray.4 | Label uppercase |
border | gray.2 | Toda borda |
surface | gray.0 | Palco padrão |
surfaceAlt | gray.1 | Palco alternado |
A única exceção cromática
red.6 (#E03131) aparece uma única vez no kit inteiro, em Comparison.jsx: a nota sob o custo total da coluna "modo tradicional". É semântica de perda, não de erro — o vermelho ali diz "este é o número que dói", que nenhum grau de cinza consegue dizer.
Contraste
Sobre fundo branco. gray.4 falha AA em corpo e por isso é restrito a label.
Cor | Ratio | Status |
|---|---|---|
gray.9 | 16,8:1 | AAA |
gray.7 | 10,4:1 | AAA |
gray.6 | 7,5:1 | AAA |
gray.5 | 4,9:1 | AA em corpo |
gray.4 | 2,6:1 | Falha AA em corpo |
Quando gray.4 é legítimo
Só em label uppercase, peso 800 — onde o peso compensa parcialmente e o texto é curto e não-essencial (é rótulo de categoria, redundante com o título logo abaixo). Nunca em parágrafo.
Modo escuro
Os papéis não mudam; muda o valor. A base recua e o que sobe clareia — página, cartão, menu. O padrão é Automático, e um botão no cabeçalho do site e no pé da coluna do painel alterna Automático, Claro e Escuro.
Papel | Claro | Escuro | Uso |
|---|---|---|---|
gray.0 | #FFFFFF | #0B0F12 | Base da página, palco padrão (SECTION_BACKGROUNDS.white) |
gray.1 | #F9FAFB | #12181D | Palco alternado (SECTION_BACKGROUNDS.gray), hover |
white | #FFFFFF | #1A2127 | Cartão, Paper, modal — a superfície elevada. Nunca palco |
elevated-2 | #FFFFFF | #232B32 | Menu, popover, lista suspensa, notificação |
gray.2 | #F3F4F6 | #222A31 | Filete |
gray.3 | #E5E7EB | #2F3840 | Borda forte, desabilitado |
gray.4 | #9CA3AF | #8A94A0 | Label caixa alta (4,7:1 até no menu) |
gray.5 | #6B7280 | #98A1AC | Legenda, nota |
gray.6 | #4B5563 | #B6BEC8 | Corpo |
gray.7 | #374151 | #D3D8DE | Dado de tabela |
gray.9 | #11181C | #F3F4F6 | Título, botão |
Token
Espaço
O principal instrumento de agrupamento. Como não há moldura nem sombra, é a distância que diz o que pertence a quê.
A escala
xs e xl dominam com 18 usos cada. É a assinatura do sistema: ou os elementos estão colados (é um só objeto), ou muito separados (são objetos distintos). O meio-termo é raro por design — ambiguidade de agrupamento é ruído.
xs · 10px
18× no kit
gap="xs"Dentro de um par (label ↔ título)
sm · 12px
12× no kit
gap="sm"Itens de uma lista
md · 16px
7× no kit
gap="md"Padding de controle
lg · 20px
5× no kit
gap="lg"Padding de card compacto
xl · 32px
18× no kit
gap="xl"Entre blocos de uma seção
Ritmo vertical
O respiro entre palcos, sempre responsivo. A proporção mobile→desktop é ~1,5×: em telas pequenas o espaço vertical é caro, em desktop o vazio é o que cria a calma.
Padrão
{ base: 80, md: 120 }
Toda Section
Hero
{ base: 80, sm: 100, md: 140 }
Primeira dobra respira mais
Denso
{ base: 40, md: 60 }
SocialProof, Footer
O Palco
Três camadas de espaço, cada uma com um trabalho distinto.
py 80–120 · separa palcos
Container lg · 1180px
gap xs cola o par
gap xl (32px) separa o anúncio da performance
py 80–120
JSX — a primitiva Section
<Box component="section" id={id} bg={bg} py={{ base: 80, md: 120 }}>
<Container size="lg">
<Stack gap="xl">
<SectionHeader badge subtitle title maw={800} />
<Box>{children}</Box>
</Stack>
</Container>
</Box>Larguras de container
O código oscila entre md, lg e xl sem regra declarada. Esta é a regra proposta, derivada do uso majoritário.
size="md"
980pxLeitura contínua: legal, FAQ, artigo
size="lg"
1180pxPadrão de toda seção de landing
size="xl"
1320pxFaixas full-width: Header, Footer
Divergência
O zen_manifesto.md prescreve "containers XL". O código convergiu para lg e ficou melhor — 1180px mantém a linha de leitura confortável. Este documento segue o código.
Larguras de leitura
maw não é decorativo: define quantos caracteres cabem por linha, e isso decide se o texto é legível.
maw={900}
Título centralizado
maw={800}
Título padrão (default de Section)
maw={780}
Parágrafo de introdução
maw={700}
Título de CTABanner
maw={620}
Corpo longo — 60–75 caracteres
maw={550}
Descrição centralizada de CTA
maw={220}
Nota sob métrica
Regra
Título até 900. Corpo nunca acima de 780 — a faixa ideal de 60–75 caracteres fica em torno de 620. Nota sempre estreita.
Breakpoints
Quase todo responsivo do kit é { base, md }. sm e lg só entram quando a grade tem passo intermediário.
Token | em | px | Papel |
|---|---|---|---|
xs | 36em | 576 | Raro |
sm | 48em | 768 | 1 col → 2 col |
md | 62em | 992 | O salto principal: mobile → desktop |
lg | 75em | 1200 | 2 col → 4 col |
xl | 88em | 1408 | Raro |
Certo e errado
Faça
— xs cola, xl separa
Confiança
Onde os dados moram
Servidores em São Paulo, backup diário.
O agrupamento é inequívoco: label e título são um objeto, o conteúdo é outro.
Não faça
— tudo com o mesmo gap
Confiança
Onde os dados moram
Servidores em São Paulo, backup diário.
Com espaço uniforme, não dá para saber se o label pertence ao título ou ao parágrafo.
Token
Forma
Raio zero, sem sombra, borda de 1px. O capítulo mais curto porque é o mais absoluto.
Border radius
57 ocorrências de radius="0" no kit, 0 de qualquer outro valor. Não é preferência forte — é invariante. A única forma curva do sistema é o ponto do Badge variant="dot", e ele é conteúdo, não moldura.
Por que zero
Canto arredondado é cromo puro — não comunica nada, apenas suaviza. Suavizar é o oposto do que a hierarquia massiva precisa. O canto reto também alinha bordas adjacentes ao pixel, o que faz filetes e grades encaixarem sem correção óptica.
Paper vs Card
Não há elevação por sombra em lugar nenhum. A profundidade vem de contraste de superfície, filete, ou camada real via z-index.
Faça
— Paper — superfície declarada
withBorder · bg white · shadow none
Tem borda e fundo. Use quando o bloco precisa se destacar do palco.
Faça
— Card — agrupamento invisível
withBorder false · bg transparent
Só padding. Use quando o espaço já basta para agrupar. Na dúvida, este.
A borda
Uma única receita, com duas variações justificadas.
Padrão
1px solid gray.2Delimitação neutra
Acento
4px solid gray.2Topo do CTABanner: "aja aqui"
Citação
2px solid gray.3Compete com texto, não com fundo
Input
1px solid gray.2Borda inteira, radius 0
Inputs
O input é uma caixa de borda inteira — o campo se declara antes de ser tocado.
Seu e-mail
theme.js
TextInput: {
defaultProps: { size: 'lg', radius: '0' },
styles: { input: {
border: '1px solid var(--mantine-color-gray-2)',
paddingLeft: 16,
paddingRight: 16,
}},
}Por que size lg
A caixa de 1px declara a área clicável antes do toque — e o alvo continua generoso. paddingLeft: 16 afasta o texto digitado da borda, para que a letra nunca encoste no filete.
Botões
Um botão é um label num retângulo preto. Sem canto, sem sombra, sem cor.
A única animação do sistema
translateY(-1px) e brightness(1.1). 1px de subida e 10% de brilho. Sem escala, sem troca de cor, sem sombra crescendo. Perceptível, não performático. Passe o mouse nos botões acima.
Arquitetura
Composição
Como os tokens viram uma seção, e como as seções viram uma página.
As quatro camadas
Cada camada só conhece a de baixo. Uma seção nunca lê um hex; ela usa c="gray.6". Uma página nunca declara py; ela instancia uma seção.
Tokens
Os valores
theme.js · global.css · tokens.js
Primitivas
Átomos reutilizáveis
Section · SectionHeader · CTAButton · MediaBlock · Wordmark · BrandLogo
Seções
Blocos de página
Hero · FeaturesGrid · StatsGrid · Pricing · FAQ · CTABanner …
Páginas
Instâncias
website · auth · hoster · storage · payments
Regra de promoção
Um padrão visual que aparece em 3+ componentes vira primitiva. Um token descoberto direto num componente é dívida — ele deveria ter nascido na camada de tokens.
Anatomia de uma seção
Cinco obrigações: Section como casca, contrato de props padrão, dados em vez de JSX, primitivas antes de reimplementar, key estável.
JSX — template para um componente novo
export function NovaSecao({ id, badge, subtitle, title, items = [], cta, note, background = 'white' }) {
return (
<Section id={id} badge={badge} subtitle={subtitle} title={title} background={background}>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="xl">
{items.map((item, i) => (
<Box key={item.title || i}>
<Text fz="lg" fw={800} c="gray.9" mb="xs">{item.title}</Text>
<Text c="gray.6" lh={1.7}>{item.description}</Text>
</Box>
))}
</SimpleGrid>
{cta && <CTAButton {...cta} />}
{note && <Text size="sm" c="gray.6" mt={40}>{note}</Text>}
</Section>
)
}Contrato universal de props
O mesmo shape em todo o kit — é o que permite mover um CTA de seção sem reescrevê-lo.
Prop | Tipo | Papel |
|---|---|---|
id | string | Âncora de scroll |
badge | string | Selo pontilhado acima do label |
subtitle | string | Label uppercase — o anúncio |
title | string | node | Título massivo |
description | string | Parágrafo de introdução |
background | 'white' | 'gray' | Fundo do palco |
ta | 'left' | 'center' | Alinhamento |
items | object[] | Os dados |
cta | { label, href, icon, variant, target } | Ação |
note | string | Nota de verificação (Lei 5) |
Certo e errado
Faça
— config-driven
<StatsGrid
items={[
{ value: '99,9%',
label: 'Uptime',
description: 'Medido…' }
]}
/>O componente controla a forma, o consumidor controla o conteúdo. O sistema sobrevive.
Não faça
— JSX em prop
<StatsGrid>
{stats.map(s => (
<MeuCard {...s} />
))}
</StatsGrid>Se o consumidor puder injetar JSX, ele injeta cromo — e o sistema erode em três sprints.
Vocabulário de seções
24 componentes, agrupados pelo trabalho que fazem numa página.
Abertura
Header
Hero
SocialProof
Prova
StatsGrid
Testimonials
TrustBadge
FounderCard
Explicação
FeatureSplit
FeaturesGrid
Steps
Callout
Decisão
Comparison
CriteriaList
Pricing
FAQ
Conversão
CTABanner
LeadForm
ProductGrid
Fechamento
Footer
LegalPrivacy
LegalTerms
LegalSecurity
Estrutura
Layout
Section
Montando uma landing
Sequência canônica, alternando fundo a cada palco.
JSX — a ordem que converte
<Layout>
<Header logo={<Wordmark>Seu nome</Wordmark>} navLinks={…} loginCta={…} />
<main style={{ flex: 1 }}>
<Hero background="white" /> {/* promessa + mídia + nota */}
<SocialProof /> {/* faixa densa, sem palco */}
<StatsGrid background="gray" /> {/* prova numérica + note */}
<FeatureSplit background="white" /> {/* o quê, com mídia */}
<Steps background="gray" /> {/* como funciona, 01/02/03 */}
<FeaturesGrid background="white" /> {/* o detalhe */}
<Comparison background="gray" /> {/* contra a alternativa */}
<Pricing background="white" /> {/* quanto custa */}
<Testimonials background="gray" /> {/* quem já fez */}
<FAQ background="white" /> {/* objeções finais */}
<CTABanner background="gray" /> {/* a ação — borda-topo 4px */}
</main>
<Footer />
</Layout>01
Fundo alterna sempre
Dois palcos brancos seguidos apagam o ritmo do scroll.
02
Prova antes de preço
StatsGrid e Testimonials sustentam o número que Pricing vai pedir.
03
CTABanner fecha
A borda superior de 4px é o único acento estrutural e marca esse ponto.
Densidade
Palco e painel
Este é um design system só. As leis, os tokens e a rampa valem em qualquer tela. O que muda é a densidade — e quem decide é o trabalho que a tela faz, não o rótulo de marketing ou app.
Palco
Convencer
Onde
Landing, marketing, legal, 404
Ritmo
py 80–120 · Container lg · gap xl
Tipografia
Corpo 15–20px · display 32–120px
Generosa — o vazio é o que cria calma
Painel
Operar
Onde
Dashboard, listas, formulários, configurações
Ritmo
px 48 · pt 40 · maw 1280 · gap 16–32
Tipografia
Dado 12–14px · título de página 36px
Compacta — a tela cabe mais linhas
A regra que separa corpo de dado
Texto que se lê em sequência é corpo (15–20px). Texto que se compara em coluna é dado (12–14px). Um parágrafo em 16px é confortável; uma tabela de 200 linhas em 16px é impossível de varrer. Não são dois sistemas — é o mesmo instrumento resolvendo tarefas diferentes.
Geometria do painel
O palco usa py 80–120 porque separa seções de uma narrativa. O painel não tem seções: tem uma tela só, com barra lateral fixa.
Layout
Sidebar 300px (colapsada 64px)
bg white · borderRight 1px solid gray.2
Conteúdo px 48 · pt 40 · pb 80 · maw 1280
Cabeçalho mb 32
Blocos gap 16Cabeçalho de página
O par canônico em versão de painel. O título é 36px fixo — o painel tem largura controlada e não precisa escalar com a viewport.
+ Acme · Produção · Usuários
Membros
Quem tem acesso a esta aplicação.
Parte | Spec | Papel |
|---|---|---|
crumb | fz 10 · fw 800 · gray.4 · uppercase · ls 1.5px | Onde você está |
title | fz 36 · fw 900 · lh 1 · ls -0.04em | O que é esta tela |
subtitle | fz 15 · fw 500 · gray.6 · lh 1.5 | O que dá para fazer aqui |
actions | Group gap md, à direita | Um botão primário; busca opcional |
Navegação lateral
O item ativo é marcado por preenchimento e peso — a lei 2 aplicada à navegação, sem cor de destaque.
Acme · Produção
Visão geral
Usuários
Organizações
Configurações
largura
300px · a coluna não recolhe
componente
SidebarShell do dashboard-kit — coluna nova não é desenhada à mão
superfície
bg gray.0 · borderRight 1px gray.2
marca
Wordmark 28px no topo · a linha do painel embaixo (ColumnBrand)
contexto
Em /, a lista dos recursos; dentro de um, o nome dele e as seções
busca
36px · bg gray.1 · borda 1px gray.2 · ícone 16px · fz 13
criar
CreateButton: quadrado de 36px, bg gray.9, "+" 16px, colado à direita da busca · nome da ação na dica e no rótulo acessível · no limite do plano, contorno gray.4 e ícone gray.5 · sem busca, volta a ser um item escrito
label de seção
fz 10 · fw 800 · gray.4 · uppercase · ls 1.5px
item
fz 13 · px 12 · py 9 · ícone 18px stroke 1.5 · radius 0
item ativo
bg gray.9 · texto gray.0 · fw 800
item inativo
transparente · gray.7 · fw 500
rodapé
Cartão do usuário (Docs, suporte e conta no menu dele) e o botão de tema à direita, separados por borderTop 1px gray.2
Tabela
CSS Grid, não <Table>: as colunas precisam de proporções e a linha inteira precisa ser clicável.
componente
DataTable do dashboard-kit — a única tabela · getRowTone · renderDetail · stackOnPhone · bare
selo de status
Tag do dashboard-kit · 10px · fw 800 · uppercase · ls 1.5px · filete 1px · neutral (gray.3/gray.6) · dark preenchido só para o que está no ar · up com quadrado verde · no em red · ícone de 12px para o que pede atenção
container
Card p={0} withBorder · overflow hidden
header
bg gray.1 · padding 12px 24px · borderBottom 1px gray.2
header cell
fz 10 · fw 800 · gray.5 · uppercase · ls 1.5px
row
padding 14px 24px · borderBottom 1px gray.2 (exceto a última)
cell
fz 13 · gray.7 · lh 1.4 — o papel "dado"
hover
background gray.1 · transition 120ms
destaque
background gray.1 + boxShadow inset 2px 0 0 gray.9
grade
gridTemplateColumns com fr + coluna fixa 40px para ações
O que ainda não existe
Não há paginação nem seleção múltipla no padrão atual — as listas renderizam o array inteiro. Ao introduzir qualquer um dos dois, documente aqui antes de implementar.
Busca
Um padrão, dois alcances: global (⌘K, cruza entidades) e local (filtra a lista da tela).
⌕
Buscar membros…
⌘K
shortcut
⌘K / Ctrl+K
minLength
2
debounceMs
250
iconSize
16
escape
Limpa o termo e devolve o foco
abort
AbortController cancela a requisição anterior a cada tecla
dropdown
Nunca abre vazio: ou tem resultado, ou tem o aviso de nenhum
rightSection
Kbd com o atalho quando vazio; Loader 14px durante a busca
JSX — o núcleo da busca
const [debounced] = useDebouncedValue(query, 250)
useEffect(() => {
const term = debounced.trim()
requestRef.current?.abort() // cancela a busca anterior
if (term.length < 2) return setResults([])
const controller = new AbortController()
requestRef.current = controller
search(term, { signal: controller.signal })
.then(r => !controller.signal.aborted && setResults(r.data))
.catch(() => {})
return () => controller.abort()
}, [debounced])Estados
Skeleton domina spinner: ele reproduz a grade real, então o layout não salta quando o dado chega.
Carregando (primeira vez)
Skeleton com a grade exata do conteúdo
Mesmo gridTemplateColumns, mesmo padding. radius 0.
Carregando (refetch)
Nada, ou LoadingOverlay sutil
O dado antigo continua legível — não pisca skeleton por cima.
Vazio (sem dado)
Label uppercase + subtítulo + ação
Stack align center py 56 · título fz 10 fw 800 gray.4 uppercase ls 1.5px
Vazio (busca sem resultado)
Mesma anatomia, citando o termo
"Nenhum resultado para «termo»" + botão de limpar busca
Erro
Notificação vermelha, nunca cinza
red.6 com IconAlertCircle. Sucesso e erro precisam ser distinguíveis sem ler.
Bloqueado (falta contexto)
Gate com skeleton
Sem aplicação selecionada, a tela mostra a forma, não um erro.
Ação destrutiva
Confirmação por permanência, não por diálogo. Um 'tem certeza?' é respondido no automático.
holdMs
3000
tickMs
50
feedback
RingProgress thickness 2 sobre o ícone
cancel
onMouseUp · onMouseLeave · onTouchEnd · soltar Espaço/Enter
variants
ícone (tabela) · texto com contagem regressiva (formulário)
undo
Exclusão frequente (campo, bloco, nota, resposta) → undoable: sai na hora, Desfazer e ⌘Z por 6s, envia depois
holdOnlyFor
Chave, webhook, domínio, integração, agenda: revogar tem que valer na hora
escalation
Alvo que pode ser confundido → DeleteModal, digitar o identificador
modalFooter
Cancelar primeiro; destrutivo por último, contorno red.7
Exigência aberta
O padrão atual responde só a mouse e toque. Sem equivalente por teclado, a exclusão fica inacessível — resolva isso antes de replicar o padrão em telas novas.
Formulários
Modal, nunca drawer. Medido no dashboard: 9 Modal, 0 Drawer.
Modal
size md · centered · padding xl
Título
ThemeIcon 32 + título h4 + descrição em gray.6
Validação
@mantine/form, síncrona, mensagem inline em português
Botões
Submit à direita; destrutivo à esquerda. Sem "Cancelar" — o X do modal basta. No diálogo cujo ato é excluir, o contrário: Cancelar e o destrutivo por último
Feedback
notifications.show no sucesso e no erro, com cores distintas
Loading
Botão com loading + disabled; overlay no corpo se a carga for longa
Referência
API dos componentes
O shape exato de props de cada primitiva e componente, com um exemplo que compila. É o que permite escrever uma página inteira sem abrir o repositório.
Instalação
Terminal
bun add @ciromaciel/components-kit @mantine/core @mantine/hooks @tabler/icons-react
Entry point
import '@mantine/core/styles.css'
import '@ciromaciel/components-kit/style.css'
import { MantineProvider } from '@mantine/core'
import { theme } from '@ciromaciel/components-kit'
export default function App() {
return (
<MantineProvider theme={theme} defaultColorScheme="auto">
{/* suas rotas */}
</MantineProvider>
)
}Não redefina o theme
O theme exportado pelo kit já carrega toda a paleta, tipografia e defaults de componente. Não redefina tokens no consumidor — se um valor precisa mudar, ele muda no kit.
Primitivas
Blocos atômicos. Um padrão que aparece em 3+ componentes deve virar uma destas.
Section
O palco. Envolve qualquer seção com fundo, respiro e cabeçalho.
Prop | Tipo | Nota |
|---|---|---|
children | ReactNode | Conteúdo — a performance |
badge | string | Selo pontilhado acima do label |
title | string | ReactNode | Título massivo |
subtitle | string | ReactNode | Label uppercase — o anúncio |
id | string | Âncora de scroll |
background | 'white' | 'gray' | Default: 'white' |
py | number | object | Default: { base: 80, md: 120 } |
maw | number | Max-width do título. Default: 800 |
ta | 'left' | 'center' | Default: 'left' |
stackGap | string | Default: 'xl' |
Exemplo
<Section
id="beneficios"
badge="No ar agora"
subtitle="Por que usar"
title="O essencial para publicar."
background="gray"
>
{/* conteúdo */}
</Section>SectionHeader
O par canônico: label leve + título pesado.
Prop | Tipo | Nota |
|---|---|---|
badge | string | Selo pontilhado |
subtitle | string | ReactNode | Label uppercase |
title | string | ReactNode | Título |
titleOrder | number | Nível do heading. Default: 2 |
titleFz | string | Default: 'var(--fz-section-title)' |
ta | 'left' | 'center' | Default: 'left' |
maw | number | Max-width do título |
Exemplo
<SectionHeader
subtitle="Prova verificável"
title="Números que você pode conferir."
maw={800}
/>CTAButton
Botão padronizado. Vira <a> com href, <button> com onClick.
Prop | Tipo | Nota |
|---|---|---|
label | string | Texto do botão |
href | string | URL — renderiza como <a> |
onClick | function | Handler — renderiza como <button> |
icon | ReactNode | Ícone à direita (rightSection) |
leftSection | ReactNode | Ícone à esquerda |
size | string | Default: 'lg' |
variant | string | Default: 'filled' |
color | string | Default: gray.9 (via theme) |
target | string | '_blank' para link externo |
px | number | object | Padding horizontal |
fullWidth | boolean | Ocupa a largura do container |
Exemplo
<CTAButton
label="Começar agora"
href="/signup"
icon={<IconArrowRight size={18} stroke={1.5} />}
/>MediaBlock
Vídeo ou imagem com a11y consistente.
Prop | Tipo | Nota |
|---|---|---|
video | { src, width, filter } | Autoplay, loop, muted, playsInline |
image | string | src da imagem (fallback quando não há vídeo) |
width | string | Default: '100%' |
maw | string | number | Max-width |
alt | string | Alt da imagem / aria-label do vídeo |
Exemplo
<MediaBlock
image="https://images.exemplo.com/dashboard.webp"
alt="Painel de controle"
maw={500}
/>Logotype
O logotipo das linhas: raio, palavra e "by Ciro Cesar Maciel". A assinatura tem tamanho fixo (11px no cabeçalho de 40px, 0,42 da palavra no grande) e começa onde a palavra começa; nunca estica até a largura da palavra.
Prop | Tipo | Nota |
|---|---|---|
word | string | A linha: "Infrastructure", "Cleaner" |
height | number | Altura do conjunto. Default: 40 (cabeçalho e rodapé) |
signature | string | null | Default: 'by Ciro Cesar Maciel'. `null` no logotipo da raiz, o nome do Ciro: quem assina é o nome |
Exemplo
<Logotype word="Infrastructure" />
Wordmark
Logo em texto, para produtos sem logotipo em imagem.
Prop | Tipo | Nota |
|---|---|---|
children | ReactNode | O nome da marca |
fz | number | Default: 28 |
c | string | Default: 'gray.9' |
Exemplo
<Header logo={<Wordmark>Auth</Wordmark>} />BrandLogo
Resolve a prop `logo` de Header/Footer: aceita objeto ou nó React.
Prop | Tipo | Nota |
|---|---|---|
logo | { src, alt, height } | ReactNode | Imagem ou wordmark |
objectPosition | string | Default: 'left' |
Exemplo
<BrandLogo logo={{ src: '/logo.webp', alt: 'Infrastructure', height: 32 }} />Componentes de seção
Componentes de nível de página. Cada um é uma seção completa.
Layout
Estrutura
Wrapper raiz. Flex column com minHeight 100vh — um <main style={{flex:1}}> empurra o rodapé para o fim.
Prop | Tipo | Nota |
|---|---|---|
children | ReactNode | |
style | object | Mesclado ao style interno |
...props | any | Encaminhado ao Box (bg, etc.) |
Exemplo
<Layout>
<Header ... />
<main style={{ flex: 1 }}>{/* seções */}</main>
<Footer ... />
</Layout>Header
Abertura
Barra de navegação. Alias legado: Navbar.
Prop | Tipo | Nota |
|---|---|---|
logo | { src, alt, height } | ReactNode | Imagem ou <Wordmark> |
navLinks | { label, href, component?, to? }[] | Aceita Link do react-router via `component` |
loginCta | { label, href } | CTA secundário |
signupCta | { label, href } | CTA primário |
background | 'white' | 'gray' | Default: 'white' |
Exemplo
<Header
logo={<Wordmark>Infrastructure</Wordmark>}
navLinks={[
{ label: 'Produtos', href: '#produtos' },
{ label: 'Preço', href: '#preco' },
]}
loginCta={{ label: 'Entrar', href: '/login' }}
signupCta={{ label: 'Criar conta', href: '/signup' }}
/>Hero
Abertura
Primeira dobra. Texto à esquerda, mídia à direita (ou centralizado sem mídia).
Prop | Tipo | Nota |
|---|---|---|
subtitle | string | Label uppercase |
title | string | ReactNode | Aceita <br /> — envolva em .hide-mobile-br |
description | string | Parágrafo de abertura |
cta | { label, href, icon, size, variant, color, px } | CTA principal |
secondaryCta | mesmo shape de cta | Renderizado ao lado |
note | string | Letra miúda sob os botões — o que custa experimentar |
video | { src, filter, width } | |
image | string | Alternativa ao vídeo |
mediaMaw | string | number | Max-width da mídia |
background | 'white' | 'gray' | Default: 'white' |
titleFz | string | Default: 'var(--fz-hero)' |
descriptionFz | string | Default: 'xl' |
Exemplo
<Hero
subtitle="Infraestrutura em português"
title={<>Login pronto<br />em uma tarde.</>}
description="Autenticação, sessões e permissões sem três semanas de trabalho."
cta={{ label: 'Começar grátis', href: '/signup', icon: <IconArrowRight size={18} /> }}
secondaryCta={{ label: 'Ver a documentação', href: '/docs', variant: 'outline' }}
note="Grátis até 1.000 usuários ativos. Sem cartão."
image="https://images.exemplo.com/auth.webp"
mediaMaw={500}
/>SocialProof
Abertura
Faixa densa de logos. Não usa palco — retorna null sem logos.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
title | string | Label acima dos logos |
logos | { src, alt }[] | Obrigatório |
background | 'white' | 'gray' | Default: 'gray' |
Exemplo
<SocialProof
title="Quem já usa"
logos={[
{ src: '/logos/a.webp', alt: 'Empresa A' },
{ src: '/logos/b.webp', alt: 'Empresa B' },
]}
/>StatsGrid
Prova
Big Type Metrics em grade dividida por filete. Cada número carrega sua nota.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
items | { value, label, description }[] | description é a nota de verificação (Lei 5) |
cols | number | Colunas a partir de lg. Default: 4 |
background | 'white' | 'gray' | Default: 'white' |
ta | 'left' | 'center' | Default: 'center' |
note | string | Nota de rodapé sob a grade |
Exemplo
<StatsGrid
subtitle="No ar agora"
title="O que você confere sem falar com ninguém"
items={[
{ value: '4', label: 'Produtos no ar', description: 'Abra os quatro e teste' },
{ value: '99,9%', label: 'Uptime', description: 'Medido nos últimos 90 dias' },
]}
note="Todo número aqui é verificável em dois minutos."
/>Testimonials
Prova
Prova social com depoimentos e iniciais.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
title | string | |
subtitle | string | |
items | { quote, name, role, company }[] | |
background | 'white' | 'gray' | Default: 'gray' |
Exemplo
<Testimonials
subtitle="Quem já fez"
title="O que dizem"
items={[
{ quote: 'Subimos em uma tarde.', name: 'Ana Souza', role: 'CTO', company: 'Acme' },
]}
/>TrustBadge
Prova
Selo de confiança. Alias legado: SeloExclusivo.
Prop | Tipo | Nota |
|---|---|---|
text | string | Default: 'Selo Exclusivo' |
color | string | Default: 'green.6' |
background | 'white' | 'gray' | Default: 'gray' |
Exemplo
<TrustBadge text="Dados no Brasil" />
FounderCard
Prova
Citação editorial do fundador, com credenciais como métricas.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
description | string | |
avatar | { src, alt } | |
name | string | |
role | string | |
quote | string | A citação |
credentials | { value, label }[] | Credenciais em Big Type |
background | 'white' | 'gray' | Default: 'white' |
ta | 'left' | 'center' | Default: 'center' |
Exemplo
<FounderCard
subtitle="Quem responde"
title="O suporte é quem escreveu o código"
avatar={{ src: '/foto.webp', alt: 'Ciro Maciel' }}
name="Ciro Cesar Maciel"
role="Fundador"
quote="Se quebrar às duas da manhã, é o meu telefone que toca."
credentials={[{ value: '20+', label: 'Anos em sistemas críticos' }]}
/>FeatureSplit
Explicação
Texto de um lado, mídia do outro. `reverse` inverte.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
description | string | |
featuresList | string[] | Lista com ícone de check |
cta | { label, href, icon } | |
video | { src, filter, width } | |
image | string | |
mediaMaw | string | number | |
reverse | boolean | Inverte a ordem. Default: false |
background | 'white' | 'gray' | Default: 'white' |
Exemplo
<FeatureSplit
subtitle="Como funciona"
title="Uma linha para proteger uma rota."
description="O middleware resolve sessão, refresh e permissão."
featuresList={['Sessões com refresh automático', 'RBAC por rota']}
image="https://images.exemplo.com/codigo.webp"
reverse
/>FeaturesGrid
Explicação
Grade assimétrica de recursos com visual dinâmico por item.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
badge | string | |
subtitle | string | |
title | string | |
description | string | |
items | { label, title, description, visual, span }[] | Ver shapes de `visual` abaixo |
note | string | |
background | 'white' | 'gray' | Default: 'gray' |
ta | 'left' | 'center' | Default: 'left' |
Shapes de `visual` (5 tipos)
type: 'metric' | { value, label, badge? } | Número grande com rótulo |
type: 'ring' | { value, label, sublabel?, size?, thickness? } | Anel de progresso |
type: 'bars' | { data: number[], highlight?, height? } | Barras; `highlight` é o índice destacado |
type: 'typography' | { text?, fontSize?, height? } | Amostra tipográfica. Default: 'Aa' |
type: 'icon-large' | { icon, size?, stroke? } | Ícone Tabler grande |
Exemplo
<FeaturesGrid
subtitle="Por que usar"
title="O essencial para publicar."
items={[
{
label: 'Config-driven',
title: 'Passe dados, não JSX',
description: 'Cinco linhas para uma seção inteira.',
visual: { type: 'metric', value: '5 linhas', label: 'por seção' },
span: { base: 12, md: 7 },
},
{
label: 'Performance',
title: 'Sem runtime extra',
description: 'Só Mantine e React.',
visual: { type: 'ring', value: 98, label: '98', sublabel: 'Lighthouse' },
span: { base: 12, md: 5 },
},
]}
/>Steps
Explicação
Processo numerado. O ordinal vira numeral fantasma de 120px atrás do card.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
badge | string | |
subtitle | string | |
title | string | |
description | string | |
items | { number?, icon?, title, description }[] | `number` cai para o índice se ausente |
background | 'white' | 'gray' | Default: 'gray' |
ta | 'left' | 'center' | Default: 'center' |
Exemplo
<Steps
badge="Como funciona"
title="Três passos. Zero atrito."
items={[
{ number: '01', icon: IconCloudUpload, title: 'Você envia', description: 'Suba o projeto.' },
{ number: '02', icon: IconSettings, title: 'A gente configura', description: 'Domínio e SSL.' },
{ number: '03', icon: IconRocket, title: 'Está no ar', description: 'Em menos de dois minutos.' },
]}
/>Callout
Explicação
Card de declaração com leads em negrito e rodapé de honestidade.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
description | string | |
cardLabel | string | Label acima da lista |
items | { lead, text }[] | `lead` sai em negrito |
footnote | { lead, text } | O que ainda falta — a nota de honestidade |
background | 'white' | 'gray' | Default: 'gray' |
ta | 'left' | 'center' | Default: 'center' |
Exemplo
<Callout
badge="Antes de qualquer coisa"
title="Se a gente sumir amanhã, seu app continua de pé"
cardLabel="O que isso quer dizer na prática"
items={[{ lead: 'Padrões abertos.', text: 'Seus dados saem em formatos que outra ferramenta lê.' }]}
footnote={{ lead: 'O que ainda falta:', text: 'o export ainda não é um comando único.' }}
/>Comparison
Decisão
Duas colunas: o jeito tradicional (com custo em red.6) contra o novo.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
traditionalCol | { title, subtitle, items, totalLabel?, totalValue, footerText? } | `items`: { label, value }[] |
newCol | { title, badge?, description, items, pricingBox? } | `items`: string[] |
background | 'white' | 'gray' | Default: 'white' |
Exemplo
<Comparison
subtitle="ROI do seu tempo"
title="Construir do zero ou usar pronto"
traditionalCol={{
title: 'Do zero',
subtitle: 'O caminho longo',
items: [{ label: 'Autenticação', value: '3 semanas' }],
totalValue: 'R$ 42.000',
footerText: 'Sem contar manutenção',
}}
newCol={{
title: 'Com o kit',
badge: 'RECOMENDADO',
description: 'Pronto para produção.',
items: ['Login em uma tarde', 'Suporte de quem escreveu'],
pricingBox: { label: 'Tempo até o ar', price: '30 min' },
}}
/>CriteriaList
Decisão
Régua de comparação em coluna única: critério, resposta e a nota que a torna verificável.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
description | string | |
items | { criterion, value, note, muted? }[] | `muted: true` para "ainda não medimos" |
note | string | |
background | 'white' | 'gray' | Default: 'gray' |
ta | 'left' | 'center' | Default: 'center' |
Exemplo
<CriteriaList
badge="A régua"
title="Compare pelo que muda o seu dia"
items={[
{ criterion: 'Em que moeda você paga', value: 'Real', note: 'Sua fatura não muda porque o dólar mudou.' },
{ criterion: 'Tempo de resposta', value: 'Ainda não medimos', note: 'Sem histórico suficiente.', muted: true },
]}
note="Onde está escrito 'ainda não medimos', é porque ainda não medimos."
/>Pricing
Decisão
Cards de planos com alternância mensal/anual controlada pelo consumidor.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
badge | string | |
subtitle | string | |
title | string | |
description | string | |
billingOptions | { label, value }[] | Default: mensal/anual. `[]` remove o seletor |
billing | string | Valor atual — estado do consumidor |
setBilling | function | Setter do estado |
promoBadge | string | Badge exibido quando billing === 'annual' |
plans | object[] | Ver shape abaixo |
background | 'white' | 'gray' | Default: 'white' |
Shape de `plans[]`
name | string | Nome do plano |
description | string | Uma linha |
priceDisplay | { currency, whole, fraction } | Preço numérico |
priceText | string | Escape hatch: "Sob consulta" (ignora priceDisplay) |
pricePrefix | string | null | Default: 'A partir de'. `null` remove |
period | string | Ex.: '/mês' |
priceNote | string | Nota sob o preço |
features | string[] | Lista com check |
highlight | boolean | Borda 2px gray.9 |
highlightBadge | string | Default: 'RECOMENDADO' |
cta | { label, href, target, icon, variant, color, size } | |
note | string | Nota no rodapé do card |
Exemplo
const [billing, setBilling] = useState('monthly')
<Pricing
subtitle="Preço"
title="Escolha o tamanho"
billing={billing}
setBilling={setBilling}
promoBadge="2 MESES GRÁTIS"
plans={[
{
name: 'Pro',
description: 'Para times em produção.',
priceDisplay: { currency: 'R$', whole: '49', fraction: '90' },
period: '/mês',
features: ['Usuários ilimitados', 'Suporte em português'],
highlight: true,
cta: { label: 'Começar', href: '/signup' },
},
{
name: 'Enterprise',
description: 'Para quem precisa de contrato.',
priceText: 'Sob consulta',
pricePrefix: null,
features: ['SLA dedicado'],
cta: { label: 'Falar com vendas', href: '/contato' },
},
]}
/>FAQ
Decisão
Acordeão em duas colunas com card de contato. A numeração continua entre colunas.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
badge | string | |
subtitle | string | |
title | string | |
description | string | |
questions | { question, answer }[] | |
contactCard | { title, description, cta } | Card lateral |
background | 'white' | 'gray' | Default: 'white' |
ta | 'left' | 'center' | Default: 'left' |
Exemplo
<FAQ
subtitle="Perguntas difíceis"
title="O que costumam perguntar"
questions={[
{ question: 'Consigo exportar meus dados?', answer: 'Sim, em JSON e CSV, a qualquer momento.' },
]}
contactCard={{
title: 'Ficou alguma dúvida?',
description: 'Responde quem escreveu o código.',
cta: { label: 'Falar no WhatsApp', href: 'https://wa.me/...' },
}}
/>ProductGrid
Conversão
Catálogo de produtos, cada card com CTA de saída próprio.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
description | string | |
items | { name, category, icon, headline, description, href, target, span }[] | `icon` é o componente Tabler, não o elemento |
ctaIcon | ReactNode | Ícone usado em todos os CTAs |
note | string | |
background | 'white' | 'gray' | Default: 'white' |
ta | 'left' | 'center' | Default: 'center' |
Exemplo
<ProductGrid
badge="No ar agora"
title="As quatro peças"
ctaIcon={<IconArrowUpRight size={16} />}
items={[
{
name: 'Auth',
category: 'Login e sessões',
icon: IconLock,
headline: 'Três semanas de login, de novo.',
description: 'Autenticação pronta para produção.',
href: '/products/auth',
span: 7,
},
]}
/>LeadForm
Conversão
Captura de lead de baixo atrito que entrega a conversa ao WhatsApp.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
badge | string | |
title | string | |
description | string | |
fields | { name, label, placeholder, type?, required?, span? }[] | `span: 'full'` ocupa a linha |
whatsappNumber | string | Só dígitos, com DDI |
messageIntro | string | Default: 'Olá! Vim pelo site.' |
submit | { label, icon } | Config do botão |
reassurance | string | Letra miúda sob o botão |
onSubmit | function | Handler alternativo ao WhatsApp |
background | 'white' | 'gray' | Default: 'gray'. Um dos dois tons de palco |
Exemplo
<LeadForm
badge="Contato"
title="Conte o que você precisa"
fields={[
{ name: 'nome', label: 'Seu nome', placeholder: 'Como te chamamos', required: true },
{ name: 'email', label: 'E-mail', type: 'email', required: true },
{ name: 'msg', label: 'O que você precisa', span: 'full' },
]}
whatsappNumber="5511986166966"
submit={{ label: 'Enviar no WhatsApp' }}
reassurance="Responde quem escreveu o código, no mesmo dia."
/>CTABanner
Conversão
Faixa final de conversão. Borda-topo de 4px — o único acento estrutural. Alias legado: SecondaryCTA.
Prop | Tipo | Nota |
|---|---|---|
id | string | |
subtitle | string | |
title | string | |
titleOrder | number | Default: 2. Use 1 quando for a única seção (404) |
description | string | |
featuresList | string[] | Lista entre descrição e CTA |
featuresLabel | string | Lead-in em negrito acima da lista |
cta | { label, href, icon, leftSection, target, size, variant, color, px } | |
background | 'white' | 'gray' | Default: 'gray' |
ta | 'center' | 'left' | Default: 'center' |
Exemplo
<CTABanner
subtitle="Pronto para começar"
title="Suba seu primeiro projeto hoje."
description="Grátis até 1.000 usuários. Sem cartão."
cta={{ label: 'Criar conta', href: '/signup', icon: <IconArrowRight size={18} /> }}
/>Footer
Fechamento
Rodapé com colunas de links e redes sociais.
Prop | Tipo | Nota |
|---|---|---|
logo | { src, alt, height } | ReactNode | |
description | string | ReactNode | Bloco à esquerda |
sections | { title, links: { label, href, to? }[] }[] | Colunas de links |
socialLinks | { href, label, icon }[] | |
copyrightText | string | |
madeByText | string | |
background | 'white' | 'gray' | Default: 'white' |
Exemplo
<Footer
logo={{ src: '/logo.webp', alt: 'Infrastructure', height: 28 }}
description="Infraestrutura em português para quem constrói produto brasileiro."
sections={[
{ title: 'Produtos', links: [{ label: 'Auth', href: '/products/auth' }] },
]}
socialLinks={[
{ href: 'https://github.com/...', label: 'GitHub', icon: <IconBrandGithub size={18} stroke={1.5} /> },
]}
copyrightText={`© ${new Date().getFullYear()} Infrastructure.`}
background="gray"
/>LegalPrivacy
Fechamento
Política de privacidade completa em português (LGPD).
Prop | Tipo | Nota |
|---|---|---|
companyName | string | Default: 'Infrastructure'. A marca — é a voz do documento |
legalEntity | string | Razão social + CNPJ. Só nas cláusulas que identificam a parte. Default: companyName |
productName | string | Default: 'nossos produtos' |
Exemplo
<LegalPrivacy
companyName="Acme"
legalEntity="Acme Tecnologia LTDA (CNPJ 00.000.000/0001-00)"
productName="o Acme Cloud"
/>LegalTerms
Fechamento
Termos de uso e licenciamento (EULA).
Prop | Tipo | Nota |
|---|---|---|
companyName | string | Default: 'Infrastructure'. A marca — é a voz do documento |
legalEntity | string | Razão social + CNPJ. Só nas cláusulas que identificam a parte. Default: companyName |
productName | string | Default: 'nossos produtos' |
lastUpdated | string | Data da última revisão DO TEXTO, por extenso. Default: '20 de setembro de 2026' |
Exemplo
<LegalTerms
companyName="Acme"
legalEntity="Acme Tecnologia LTDA (CNPJ 00.000.000/0001-00)"
productName="o Acme Cloud"
lastUpdated="20 de setembro de 2026"
/>LegalSecurity
Fechamento
Protocolos de segurança e infraestrutura.
Prop | Tipo | Nota |
|---|---|---|
companyName | string | Default: 'Infrastructure'. A marca — é a voz do documento |
legalEntity | string | Razão social + CNPJ. Só nas cláusulas que identificam a parte. Default: companyName |
productName | string | Default: 'nossos produtos' |
Exemplo
<LegalSecurity
companyName="Acme"
legalEntity="Acme Tecnologia LTDA (CNPJ 00.000.000/0001-00)"
productName="o Acme Cloud"
/>Aliases legados
Componentes renomeados mantêm alias. Remover um alias é breaking change.
Navbar
→ Header
SecondaryCTA
→ CTABanner
SeloExclusivo
→ TrustBadge
Copiar e adaptar
Receitas
Código que compila. Adapte o conteúdo, não a estrutura — a sequência das seções é parte do sistema.
Landing page completa
Página de produto ou serviço, do topo ao rodapé.
import { useState } from 'react'
import { IconArrowRight, IconLock, IconRocket, IconSettings } from '@tabler/icons-react'
import {
Layout, Header, Hero, SocialProof, StatsGrid, FeatureSplit,
Steps, Comparison, Pricing, Testimonials, FAQ, CTABanner, Footer, Wordmark,
} from '@ciromaciel/components-kit'
export default function LandingPage() {
const [billing, setBilling] = useState('monthly')
return (
<Layout>
<Header
logo={<Wordmark>Acme</Wordmark>}
navLinks={[{ label: 'Preço', href: '#preco' }]}
signupCta={{ label: 'Criar conta', href: '/signup' }}
/>
<main style={{ flex: 1 }}>
{/* Palcos alternam white ↔ gray, sempre */}
<Hero
background="white"
subtitle="Infraestrutura em português"
title={<>Login pronto<br />em uma tarde.</>}
description="Autenticação, sessões e permissões sem três semanas de trabalho."
cta={{ label: 'Começar grátis', href: '/signup', icon: <IconArrowRight size={18} /> }}
note="Grátis até 1.000 usuários ativos. Sem cartão."
/>
<SocialProof title="Quem já usa" logos={[{ src: '/a.webp', alt: 'A' }]} />
{/* Prova antes de preço */}
<StatsGrid
background="gray"
subtitle="No ar agora"
title="O que você confere sem falar com ninguém"
items={[
{ value: '99,9%', label: 'Uptime', description: 'Medido nos últimos 90 dias' },
{ value: '<200ms', label: 'Latência p95', description: 'Do Brasil, medido de fora' },
]}
note="Todo número aqui é verificável em dois minutos."
/>
<FeatureSplit
background="white"
subtitle="Como funciona"
title="Uma linha para proteger uma rota."
featuresList={['Sessões com refresh automático', 'RBAC por rota']}
image="/codigo.webp"
/>
<Steps
background="gray"
badge="Como funciona"
title="Três passos. Zero atrito."
items={[
{ number: '01', icon: IconLock, title: 'Instale', description: 'Um comando.' },
{ number: '02', icon: IconSettings, title: 'Configure', description: 'Um arquivo.' },
{ number: '03', icon: IconRocket, title: 'No ar', description: 'Menos de dois minutos.' },
]}
/>
<Comparison
background="white"
subtitle="ROI do seu tempo"
traditionalCol={{
title: 'Do zero', subtitle: 'O caminho longo',
items: [{ label: 'Autenticação', value: '3 semanas' }],
totalValue: 'R$ 42.000', footerText: 'Sem contar manutenção',
}}
newCol={{
title: 'Com o kit', badge: 'RECOMENDADO',
description: 'Pronto para produção.',
items: ['Login em uma tarde'],
pricingBox: { label: 'Tempo até o ar', price: '30 min' },
}}
/>
<Pricing
background="gray"
subtitle="Preço"
billing={billing}
setBilling={setBilling}
plans={[{
name: 'Pro',
description: 'Para times em produção.',
priceDisplay: { currency: 'R$', whole: '49', fraction: '90' },
period: '/mês',
features: ['Usuários ilimitados'],
highlight: true,
cta: { label: 'Começar', href: '/signup' },
}]}
/>
<Testimonials
background="white"
subtitle="Quem já fez"
items={[{ quote: 'Subimos em uma tarde.', name: 'Ana', role: 'CTO', company: 'Acme' }]}
/>
<FAQ
background="gray"
subtitle="Perguntas difíceis"
questions={[{ question: 'Consigo exportar?', answer: 'Sim, JSON e CSV.' }]}
/>
{/* CTABanner sempre fecha */}
<CTABanner
background="white"
subtitle="Pronto para começar"
title="Suba seu primeiro projeto hoje."
cta={{ label: 'Criar conta', href: '/signup', icon: <IconArrowRight size={18} /> }}
/>
</main>
<Footer
background="gray"
description="Infraestrutura em português."
sections={[{ title: 'Produto', links: [{ label: 'Preço', href: '#preco' }] }]}
copyrightText={`© ${new Date().getFullYear()} Acme.`}
/>
</Layout>
)
}Tela de painel completa
Lista de dados numa aplicação: cabeçalho, busca, filtros, tabela, estados. É a receita para qualquer tela de dashboard.
import { useMemo, useState } from 'react'
import { Box, Card, Group, Skeleton, Stack, Text, Title } from '@mantine/core'
import { IconPlus, IconSearch } from '@tabler/icons-react'
const COLS = '2.4fr 1fr 1fr 40px' // fr para conteúdo, px fixo para ações
const HEAD = { fz: 10, fw: 800, c: 'gray.5', tt: 'uppercase', lts: '1.5px' }
export default function InvoicesPage({ invoices = [], loading, onOpen, onCreate }) {
const [term, setTerm] = useState('')
const rows = useMemo(() => {
const q = term.trim().toLowerCase()
if (!q) return invoices
return invoices.filter(i => i.number.toLowerCase().includes(q) || i.customer.toLowerCase().includes(q))
}, [invoices, term])
return (
<Box px={48} pt={40} pb={80} maw={1280} mx="auto">
{/* Cabeçalho: crumb + título + subtítulo, ações à direita */}
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={24} mb={32}>
<Stack gap={8} flex={1} miw={0}>
<Text fz={10} fw={800} c="gray.4" tt="uppercase" lts="1.5px">
+ Acme · Produção · Faturas
</Text>
<Title order={1} fz={36} fw={900} lh={1} lts="-0.04em">Faturas</Title>
<Text fz={15} fw={500} c="gray.6" lh={1.5}>
Tudo o que foi cobrado nesta aplicação.
</Text>
</Stack>
<Group gap="md" wrap="nowrap" flex="none">
<Box
component="button"
onClick={onCreate}
px="md" py={8} bg="gray.9" c="white" fz={12} fw={800} tt="uppercase"
style={{ letterSpacing: '1px', border: 'none', borderRadius: 0, cursor: 'pointer' }}
>
Nova fatura
</Box>
</Group>
</Group>
<Card p={0} withBorder radius="0" style={{ overflow: 'hidden' }}>
{/* Busca local — filtra a lista desta tela */}
<Group px="md" py="sm" justify="space-between" wrap="nowrap"
style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Group gap="xs" wrap="nowrap" flex={1} px="sm" py={6}
style={{ border: '1px solid var(--mantine-color-gray-2)', borderRadius: 0 }}>
<IconSearch size={16} stroke={1.5} color="var(--mantine-color-gray-4)" />
<Box
component="input"
value={term}
onChange={e => setTerm(e.currentTarget.value)}
onKeyDown={e => e.key === 'Escape' && setTerm('')}
placeholder="Buscar por número ou cliente…"
style={{ border: 'none', outline: 'none', background: 'transparent',
fontFamily: 'inherit', fontSize: 13, width: '100%' }}
/>
</Group>
<Text fz={11} fw={800} c="gray.4" tt="uppercase" lts="1.5px" style={{ flexShrink: 0 }}>
{rows.length} {rows.length === 1 ? 'fatura' : 'faturas'}
</Text>
</Group>
{/* Header da tabela */}
<Box bg="gray.1" style={{ display: 'grid', gridTemplateColumns: COLS, gap: 12,
alignItems: 'center', padding: '12px 24px',
borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Text {...HEAD}>Fatura</Text>
<Text {...HEAD}>Status</Text>
<Text {...HEAD}>Valor</Text>
<Box />
</Box>
{/* Carregando: o skeleton tem a MESMA grade da tabela */}
{loading && rows.length === 0 &&
Array.from({ length: 5 }, (_, i) => (
<Box key={i} style={{ display: 'grid', gridTemplateColumns: COLS, gap: 12,
alignItems: 'center', padding: '14px 24px',
borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Skeleton height={14} width="60%" radius="0" />
<Skeleton height={14} width="50%" radius="0" />
<Skeleton height={14} width="40%" radius="0" />
<Box />
</Box>
))}
{/* Vazio: label uppercase + subtítulo, citando o termo quando houver */}
{!loading && rows.length === 0 && (
<Stack align="center" gap={8} py={56}>
<Text fz={10} fw={800} c="gray.4" tt="uppercase" lts="1.5px">
{term ? 'Nenhum resultado' : 'Sem faturas'}
</Text>
<Text fz={12} c="gray.5" fs="italic">
{term ? `Nada encontrado para «${term}».` : 'Nada foi cobrado ainda.'}
</Text>
</Stack>
)}
{/* Linhas: a linha inteira é clicável; texto é o papel "dado" */}
{rows.map((row, i) => (
<Box
key={row.id}
onClick={() => onOpen(row)}
style={{ display: 'grid', gridTemplateColumns: COLS, gap: 12,
alignItems: 'center', padding: '14px 24px', cursor: 'pointer',
transition: 'background 120ms ease',
borderBottom: i === rows.length - 1 ? 'none'
: '1px solid var(--mantine-color-gray-2)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--mantine-color-gray-1)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<Box miw={0}>
<Text fz={13} fw={700} c="gray.9" lineClamp={1}>{row.number}</Text>
<Text fz={12} c="gray.5" lineClamp={1}>{row.customer}</Text>
</Box>
<Text fz={13} c="gray.7">{row.status}</Text>
<Text fz={13} c="gray.7" ff="monospace">{row.amount}</Text>
<Text fz={13} c="gray.5" ta="center" aria-hidden>⋯</Text>
</Box>
))}
</Card>
</Box>
)
}Página 404
O CTABanner é a única seção — por isso titleOrder={1}.
import { CTABanner } from '@ciromaciel/components-kit'
import { IconArrowLeft } from '@tabler/icons-react'
export default function NotFoundPage() {
return (
<CTABanner
titleOrder={1}
subtitle="Erro 404"
title="Esta página não existe."
description="O link pode ter mudado de lugar."
cta={{ label: 'Voltar ao início', href: '/', leftSection: <IconArrowLeft size={18} /> }}
/>
)
}Página legal
Privacidade, termos ou segurança — container md, leitura contínua.
import { LegalPrivacy } from '@ciromaciel/components-kit'
export default function PrivacyPage() {
return <LegalPrivacy companyName="Acme Ltda" productName="o Acme Cloud" />
}Seção customizada
O vocabulário não cobre o caso. Envolva Section — nunca refaça o palco.
import { Box, SimpleGrid, Text } from '@mantine/core'
import { Section, CTAButton } from '@ciromaciel/components-kit'
export function IntegrationsGrid({ id, subtitle, title, items = [], cta, note, background = 'white' }) {
return (
<Section id={id} subtitle={subtitle} title={title} background={background}>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="xl">
{items.map((item, i) => (
<Box key={item.title || i}>
<Text fz="lg" fw={800} c="gray.9" mb="xs">{item.title}</Text>
<Text c="gray.6" lh={1.7}>{item.description}</Text>
</Box>
))}
</SimpleGrid>
{cta && <CTAButton {...cta} />}
{note && <Text size="sm" c="gray.6" mt={40}>{note}</Text>}
</Section>
)
}Armadilhas
Erros frequentes
Cada um destes é um erro real que quebra o build ou erode o sistema. A linha vermelha não compila ou não obedece; a verde é a correção.
01
O theme do kit já traz paleta, tipografia e defaults. Redefinir no consumidor cria um segundo sistema.
import { theme } from './meu-theme'
import { theme } from '@ciromaciel/components-kit'
02
Section já é Box + Container + Stack. Aninhar outro Container dobra o padding e quebra a largura.
<Section><Container><Stack>…</Stack></Container></Section>
<Section>…</Section>
03
Steps e ProductGrid recebem o componente e o instanciam com o tamanho certo. Um elemento pronto ignora o dimensionamento.
items={[{ icon: <IconLock /> }]}
items={[{ icon: IconLock }]}
04
Sem a classe, a quebra manual persiste no celular e corta a frase em lugar errado.
<Hero title={<>Linha um<br />Linha dois</>} />
<Hero title={<span className="hide-mobile-br">Linha um<br />Linha dois</span>} />
05
Lei 5: número forte sem nota de verificação é propaganda, não design.
<StatsGrid items={[{ value: "99,9%", label: "Uptime" }]} />
<StatsGrid items={[{ value: "99,9%", label: "Uptime", description: "Medido nos últimos 90 dias" }]} />
06
Palcos consecutivos alternam. Dois brancos seguidos apagam o ritmo do scroll.
<Hero background="white" /><StatsGrid background="white" />
<Hero background="white" /><StatsGrid background="gray" />
07
Corpo em preto absoluto achata a hierarquia — o título deixa de liderar.
<Text c="gray.9">parágrafo longo…</Text>
<Text c="gray.6">parágrafo longo…</Text>
08
Raio e sombra são cromo sem informação. A delimitação é o filete de 1px.
<Paper radius="md" shadow="sm">
<Paper radius="0" bd="1px solid var(--mantine-color-gray-2)">
09
O seletor mensal/anual é controlado. Sem o par billing/setBilling ele trava.
<Pricing plans={plans} />
const [billing, setBilling] = useState("monthly") <Pricing billing={billing} setBilling={setBilling} plans={plans} />
10
Só existem cinco tipos: metric, ring, bars, typography, icon-large. Um tipo desconhecido não renderiza nada.
<FeaturesGrid items={[{ visual: { type: "chart" } }]} />
<FeaturesGrid items={[{ visual: { type: "bars", data: [40, 70, 100] } }]} />
Referência
O que veio da Apple
As Human Interface Guidelines lidas regra a regra contra este sistema. Cada uma recebeu um veredito: entrou, já era assim, ou ficou de fora — com o motivo.
Adotado
Entrou no components-kit e no dashboard-kit; vale em todo site e todo painel.
Movimento
Sob "Reduzir movimento", deslocamento vira nada ou esmaecimento.
Hover, pressão e rolagem suave desligam; o menu lateral do painel aparece em vez de deslizar.
Pressão
Todo botão mostra que foi apertado.
Botão pressionado volta ao chão e escurece (brightness 0.92).
Foco
O anel segue a forma do elemento; um só em toda a interface.
2px gray.9 a 2px de distância, quadrado. Eram três cores diferentes.
Contraste
Toda cor própria tem uma variante de contraste aumentado.
prefers-contrast: more desloca gray.2–gray.6 um degrau para o escuro.
Alvo de toque
44×44 para o dedo; 28 é o padrão denso do desktop.
Ponteiro grosso ganha piso de 44px em Button, ActionIcon e CloseButton.
Tempo
Prefira fechar à mão a sumir sozinho.
notify.error fica até ser fechada; sucesso e info saem em 5s.
Erro
Diga como resolver, perto do problema, sem culpar e sem "Ops".
LeadForm: "Preencha o campo E-mail" no lugar de "Obrigatório".
Rastreamento
Texto pequeno abre, texto grande fecha.
O tracking dos labels nunca chegava à tela (prop ls, que o Mantine ignora). Agora é lts, nos 152 arquivos.
Escrita
Verbo no botão, reticências quando falta informação, tooltip ≤75 caracteres.
Grupo "Escrita" no checklist.
Sucesso
Uma cor por significado, nunca sozinha.
Verde para todo "deu certo" (green.6 preenche, green.7 escreve). Eram três.
Texto
Hierarquia pela cor do papel, não por acaso.
Texto sem cor herda o corpo gray.6; título diz gray.9. O padrão era gray.9 nos sites e #000 nos painéis.
Modo escuro
A base recua; o que sobe clareia. Não é a rampa invertida.
Rampa por elevação nos dois kits; padrão Automático; um botão que alterna, com o estado dito em palavras.
Consistência
O mesmo papel com a mesma aparência em todo lugar.
Um panelTheme no dashboard-kit para os 18 painéis. h2 era 48px sobre um h1 de 36px.
Já era assim
A Apple chega pelo outro lado à mesma regra — confirma, não muda.
Cor
Nunca só a cor para significar.
Glifo carrega o estado, palavra carrega o significado.
Hierarquia
Uma tinta em vários níveis; superfícies por papel, não por valor.
A rampa de dez cinzas com papéis nomeados.
Forma
Raio interno = raio externo − respiro.
Raio 0 satisfaz por construção.
Marca
A marca cede ao conteúdo; o logo não se repete.
Lei 1: conteúdo sobre cromo.
Carregamento
Placeholder com a forma do que vem.
Skeleton com a grade real é bloqueio de merge.
Exclusão
Desfazer vence confirmar; confirmar só o irreversível; destrutivo nunca como ação primária.
undoable com Desfazer e ⌘Z; hold de 3s para credencial; no modal de exclusão, Cancelar primeiro e destrutivo em contorno.
Modal
Um de cada vez, nunca empilhado.
useExclusiveModal.
Não adotado
Regras de plataforma ou que contrariam uma decisão deste sistema.
Caixa do botão
Title Case em botões e menus.
Não existe em português. O botão continua em caixa alta, peso 800, 1px.
Liquid Glass
Vidro translúcido na camada de controles.
Contraria a lei 1. O cromo se separa por filete e posição, não por desfoque.
Cápsula
Controles em cápsula para o toque.
Contraria o raio 0.
Tracking do SF
Tabela de tracking positivo acima de 24pt.
É da troca óptica da SF Display. A Montserrat fecha em display: −0.04em.
Peso 600
Semibold como peso de ênfase.
O sistema pula de 500 para 700. Ênfase continua sendo 800.
Auditoria
Antes de dar merge
Qualquer item marcado como falha é bloqueio, não sugestão.
Bloqueios automáticos
Falhas que reprovam sem discussão, com a lei que cada uma viola.
border-radius diferente de 0
Lei 1
Texto de conteúdo abaixo de 12px
Lei 2
Erro e sucesso com a mesma cor
Lei 5
Métrica ou tabela sem skeleton com a forma do conteúdo
Lei 3
box-shadow em qualquer lugar
Lei 1
Cor fora da rampa gray
Lei 1
Salto tipográfico <2× entre níveis
Lei 2
Corpo em gray.9
Lei 2
Seção que reimplementa o palco
Lei 3
Prop que aceita JSX arbitrário
Lei 4
Métrica grande sem nota
Lei 5
A lista completa
Tokens
Zero hex literal no componente — tudo via c="gray.N" / bg / var(--mantine-color-gray-N)
Zero px de fonte hardcoded em título — usa var(--fz-*)
Peso é 500, 700, 800 ou 900. Nenhum outro valor
letter-spacing é -0.04em, 1px, 1.5px ou 2px
radius="0" em todo elemento que aceita raio
Tipografia
Salto entre níveis adjacentes é ≥3×
Corpo é gray.6; dado tabular é gray.7 — nunca gray.9
Título é gray.9 — nunca gray.6
Todo label uppercase tem fw={800} e tracking positivo
gray.4 só aparece em label uppercase, nunca em parágrafo
Um único <h1> na página
<br /> manual em título está dentro de .hide-mobile-br
Cor
Nenhuma cor fora da rampa gray, exceto red.6 (perda, erro) e green.6/green.7 (sucesso) — sempre com palavra ou glifo
Toda borda é 1px solid gray.2 — salvo o topo 4px do CTABanner e a citação 2px gray.3
Fundo de palco é white ou gray.1, alternando com o palco anterior
Sem gradiente, sem sombra colorida
Espaço
Palco usa py={{ base: 80, md: 120 }} e Container size="lg"
Stack gap="xl" entre anúncio e conteúdo
Corpo tem maw ≤ 780
Mobile (base) é 1 coluna
Onde há filete divisor, spacing={0}
Borda divisória tem media query que a desliga quando a grade empilha
Forma
Sem sombra
Paper só onde a superfície precisa se declarar; caso contrário Card ou Box
Input tem borda inteira de 1px gray.2 e radius 0
Botão é uppercase, fw={800}, lts="1px"
Botão carregando mantém a cor e mostra as barras (14px no painel, 16px no site), centralizadas; sem camada por cima
Ícone é Tabler com stroke={1.5}
Arquitetura
Componente envolve Section em vez de refazer o palco
Props são dados, não JSX
Contrato padrão presente: id, badge, subtitle, title, background
Usa primitivas em vez de reimplementar
cta segue o shape universal
Exportado em src/index.js
Padrão repetido em 3+ componentes foi promovido a primitiva
Painel (telas de aplicação)
Texto tabular usa o papel "dado" (12–14px), não o corpo de 16px
Nenhum texto abaixo de 12px, exceto label uppercase (10–11px)
Skeleton reproduz a grade real do conteúdo, não um retângulo genérico
Refetch não pisca skeleton por cima de dado já visível
Erro usa red.6 — nunca a mesma cor do sucesso
Só um componente registra ⌘K por tela
Busca cancela a requisição anterior (AbortController)
Ação destrutiva pede permanência (hold 3s), não um "tem certeza?"
Ação destrutiva é acionável por teclado
Linha de tabela inteira é clicável quando leva a um detalhe
Acessibilidade
Imagem tem alt; vídeo tem aria-label
CTA com href renderiza <a>; com onClick, <button>
Contraste ≥4,5:1 em todo texto de corpo
Seção usa <section> semântico com id
Alvo de toque ≥44px em mobile
Foco visível é o anel do sistema (2px gray.9, 2px de distância) — nunca outline: none sem substituto
Nada se desloca sob prefers-reduced-motion — nem hover, nem rolagem suave
Botão tem estado de pressão (:active), não só hover
Botão só de ícone tem aria-label; ícone decorativo tem aria-hidden
Informação nunca só na cor: estado é glifo + palavra
Escrita
Botão e link começam com verbo — nunca "clique aqui" nem "saiba mais"
Ação que pede mais informação antes de concluir termina em reticências: "Exportar…"
Erro diz o que houve e como resolver: "Preencha o campo E-mail", não "Obrigatório"
Sem "Ops", sem culpa, sem "nós": "Não foi possível carregar", não "Tivemos um problema"
Notificação de erro fica até ser fechada; sucesso e info saem sozinhos
Tooltip tem no máximo 75 caracteres e não repete o nome do controle
Controle desabilitado diz por quê (tooltip ou texto de ajuda)
Placeholder é exemplo (nome@exemplo.com), nunca o único rótulo do campo
Gráfico tem título que diz a conclusão, não só o nome da série
Modo escuro
Nenhum hexadecimal da rampa na interface — cor é c="gray.N" ou var(--mantine-color-gray-N)
Palco de seção é gray.0; white é cartão (a superfície elevada no escuro)
Nunca color="dark" nem dark.N — o preto do sistema é gray.9
Superfície escura nos dois temas (terminal, código) usa cor literal, e o comentário diz por quê
Prévia de peça externa (formulário, e-mail) não vira com o tema
Nunca theme.colors.gray[N] nem 'white' literal num estilo — var(--mantine-color-gray-N) e var(--mantine-color-white)
Item ativo é igual a qualquer ativo, mesmo se for muted — o cinza discreto só vale fora do ativo
Selo de status é o Tag do dashboard-kit, nunca o Badge do Mantine — preenchido só para o que está no ar
MantineProvider com defaultColorScheme="auto", nunca forceColorScheme
index.html tem o script do tema no <head>, antes do CSS
Portabilidade
Prompt mestre
Para gerar interfaces no sistema em qualquer ferramenta de IA — Claude, ChatGPT, v0, Cursor, Figma. Copie e cole inteiro.
Prompt curto — uma tela ou componente
Cole em qualquer LLM
Você é um Senior Product Designer trabalhando no design system Zen. Gere UI seguindo estritamente estas regras: FILOSOFIA: conteúdo sobre cromo. Todo elemento que não comunica informação é removido. A hierarquia vem de tamanho, peso e espaço — nunca de cor. TIPOGRAFIA: Montserrat, pesos apenas 500/700/800/900. - Label: 10-13px, peso 800, UPPERCASE, letter-spacing +1.5px, cor #9CA3AF - Corpo: 15-20px, peso 400-500, line-height 1.7, cor #4B5563 - Display: 32-120px, peso 900, letter-spacing -0.04em, line-height 1-1.2, cor #11181C - O salto entre níveis adjacentes é de 3x ou mais. COR: monocromática. Rampa gray: #FFFFFF #F9FAFB #F3F4F6 #E5E7EB #9CA3AF #6B7280 #4B5563 #374151 #1F2937 #11181C. Sem cor de marca, sem gradiente, sem sombra. Fundo de seção alterna entre #FFFFFF e #F9FAFB. FORMA: border-radius 0 em absolutamente tudo. Sem box-shadow. Única borda: 1px solid #F3F4F6. Inputs sem moldura, só border-bottom. Botões são retângulos #11181C com label uppercase peso 800. ESPAÇO: cada seção é um "palco" — padding 80px 0 em mobile, 120px 0 em desktop, container de 1180px. Dentro do palco: um label pequeno, o título massivo logo abaixo (gap 10px), e o conteúdo separado por 32px. Mobile é sempre coluna única. CONTEÚDO: todo número grande carrega abaixo uma nota de 13px em #4B5563 que o torna verificável. Não adicione cor de destaque, canto arredondado, sombra ou segunda família tipográfica em nenhuma hipótese.
Prompt de código — React + Mantine
Quando o alvo é o codebase real
Gere um componente React usando Mantine e @ciromaciel/components-kit,
seguindo o Zen Design System.
RESTRIÇÕES TÉCNICAS:
- Zero CSS inline (style={{}}), zero arquivo .css. Tudo via props do Mantine.
- Props semânticas: bg, c, bd, fz, fw, ls, lh, maw, py, gap.
- Cores só como token: c="gray.6", bg="gray.1". Nunca hex literal.
- radius="0" em todo elemento que aceita raio.
- Ícones Tabler com stroke={1.5}.
- JavaScript, não TypeScript.
ESTRUTURA OBRIGATÓRIA:
import { Section } from '@ciromaciel/components-kit'
export function MinhaSecao({ id, subtitle, title, items = [], cta, note, background = 'white' }) {
return (
<Section id={id} subtitle={subtitle} title={title} background={background}>
{/* conteúdo config-driven a partir de items */}
</Section>
)
}
API CONFIG-DRIVEN: o componente recebe dados
(items={[{ value, label, description }]}), nunca JSX em prop.
CONTRATO DE PROPS: id, badge, subtitle, title, description,
background ('white'|'gray'), ta ('left'|'center'), items,
cta ({ label, href, icon, variant, target }), note.
TIPOGRAFIA VIA TOKENS: títulos usam fz="var(--fz-section-title)",
hero usa var(--fz-hero), métricas usam var(--fz-big-metric).
Use as primitivas existentes (SectionHeader, CTAButton, MediaBlock) em vez
de reimplementar. Se um padrão aparece em 3+ componentes, proponha
promovê-lo a primitiva.Prompt de auditoria
Para revisar UI existente
Audite esta interface contra o Zen Design System e liste as
violações, cada uma com a lei violada e a correção concreta.
REPROVAÇÃO AUTOMÁTICA: border-radius != 0 · qualquer box-shadow · cor fora
da rampa gray · salto tipográfico <2x entre níveis · corpo em #11181C ·
seção que reimplementa o palco em vez de usar Section · prop que aceita JSX
arbitrário · métrica grande sem nota de verificação.
VERIFIQUE TAMBÉM: peso fora de {500,700,800,900} · letter-spacing fora de
{-0.04em, 1.5px, 2px} · borda diferente de 1px solid #F3F4F6 · corpo com
max-width >780px · grade com múltiplas colunas em mobile · texto #9CA3AF
fora de label uppercase · imagem sem alt.
Para cada achado informe: arquivo e linha, lei violada (1 Conteúdo sobre
cromo / 2 Hierarquia massiva / 3 O Palco / 4 Config-driven / 5 Honestidade
verificável), e o diff da correção.Instruções negativas
Ferramentas de IA tendem a adicionar exatamente o que este sistema remove. Anexe quando o modelo insistir.
NÃO adicione: cor de destaque ou accent · gradiente · border-radius · box-shadow · glassmorphism ou blur · segunda família tipográfica · ícone decorativo sem função · animação além de translateY(-1px) + brightness(1.1) no hover de botão · badge colorido · card com elevação · dark mode (o sistema é light-only). Se a tela parecer vazia demais, o problema é o texto, não o design. Melhore a copy — não adicione moldura.
Onde este documento vive
A versão em markdown — com o raciocínio completo por trás de cada regra — está em marketing/brand/design-system/. Os valores executáveis estão em website/src/design-system/tokens.js, que é o que esta página consome. Token novo nasce no markdown, entra no tokens.js, desce para components-kit/src/theme.js. Nunca o contrário.
Automação
Para agentes
Esta página é React — um agente que faz fetch nela recebe uma div vazia. Estas rotas servem a mesma informação em formato legível por máquina.
As três rotas
https://ciromaciel.click/design-system.txt
text/plain
A especificação completa em Markdown: tokens, API, receitas, erros e protocolo de validação. É o arquivo para passar a um agente.
https://ciromaciel.click/design-system.json
application/json
Os mesmos dados estruturados, para quem prefere consumir programaticamente.
https://ciromaciel.click/llms-design.txt
text/plain
Ponto de entrada no padrão llmstxt.org para o design system, com as regras invariantes.
Por que existe uma versão em texto
Uma SPA React renderiza no cliente. Quem faz fetch em /design-system recebe <div id="app"></div> e nada mais — a página é legível por humanos e invisível para máquinas. As rotas acima são geradas dos mesmos tokens.js e api.js que esta página renderiza, então não têm como divergir do que você está vendo.
Prompt pronto
Cole isto, troque a descrição da tarefa, e o agente tem tudo o que precisa para trabalhar e se validar sozinho.
Prompt para o agente
Construa/atualize [DESCREVA A TAREFA] seguindo o Zen Design System. A especificação completa e autocontida está em: https://ciromaciel.click/design-system.txt Busque esse arquivo ANTES de escrever qualquer código — ele é autossuficiente, você não precisa de mais nenhuma fonte. Ele contém: - As 6 leis e as regras invariantes (que reprovam o trabalho se violadas) - Todos os design tokens com valores exatos - A API completa dos 24 componentes, com shapes de props aninhadas - Receitas de páginas inteiras que compilam - Os 10 erros frequentes que quebram o build - O protocolo de validação executável Se você não conseguir acessar a URL, me avise em vez de trabalhar de memória. Ao terminar, rode o protocolo de validação da seção "Protocolo de validação" e me relate: o build passou, o resultado da auditoria automática, quais componentes você usou, e qualquer regra que não conseguiu cumprir.
Auditoria automática
O agente deve rodar isto antes de dizer que terminou. Qualquer linha de código impressa é uma violação.
bash — na raiz do projeto
echo "== radius não-zero =="
grep -rnE 'radius=("(?!0)[^"]*"|\{(?!0)[^}]*\})' src/ --include=*.jsx || echo " OK"
echo "== box-shadow =="
grep -rn 'boxShadow\|box-shadow' src/ --include=*.jsx | grep -v 'none' || echo " OK"
echo "== hex literal (use tokens) =="
grep -rnE '#[0-9a-fA-F]{6}' src/ --include=*.jsx || echo " OK"
echo "== peso fora de {500,700,800,900} =="
grep -rhoE 'fw=\{[0-9]+\}' src/ --include=*.jsx | sort -u \
| grep -vE 'fw=\{(500|700|800|900)\}' || echo " OK"
echo "== letter-spacing fora da escala =="
grep -rhoE 'lts="[^"]*"' src/ --include=*.jsx | sort -u \
| grep -vE 'lts="(-0\.04em|1\.5px|2px)"' || echo " OK"O que o agente deve relatar
O comando de build rodado e seu resultado · o resultado da auditoria automática · quais componentes do kit foram usados e quais precisou criar · qualquer regra que não conseguiu cumprir, e por quê. Não relate sucesso sem ter rodado o build.