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.

1

Família tipográfica

Montserrat, 4 pesos

10

Degraus de cinza

Nenhuma cor de marca

0

Border radius

57 ocorrências, 0 exceções

4

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.

01

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.

02

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.

03

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.

04

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.

05

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.

06

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.

Aa

Montserrat

Variável 100–900

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.

Infraestrutura

Black 900

44× no kit

fw={900}

Títulos, métricas, wordmark

Infraestrutura

ExtraBold 800

20× no kit

fw={800}

Labels uppercase, botões, badges

Infraestrutura

Bold 700

10× no kit

fw={700}

Ênfase em corpo, rótulo de métrica

Infraestrutura

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.

Aa

--fz-hero

clamp(2.5rem, 8vw + 1rem, 6rem)

40 → 96px · Primeira dobra

Aa

--fz-section-title

clamp(2rem, 5vw + 1rem, 3.5rem)

32 → 56px · Título de seção — o mais frequente

Aa

--fz-big-metric

clamp(3rem, 10vw + 1rem, 7.5rem)

48 → 120px · Métrica — a mais dramática

Aa

--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.

Infraestrutura

tight

2× no kit

lts="-0.04em"

Todo display e título

Infraestrutura

control

5× no kit

lts="1px"

Botão, link de navegação

Infraestrutura

label

10× no kit

lts="1.5px"

Todo label uppercase

Infraestrutura

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"

980px

Leitura contínua: legal, FAQ, artigo

size="lg"

1180px

Padrão de toda seção de landing

size="xl"

1320px

Faixas 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.

0

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.2

Delimitação neutra

Acento

4px solid gray.2

Topo do CTABanner: "aja aqui"

Citação

2px solid gray.3

Compete com texto, não com fundo

Input

1px solid gray.2

Borda 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 16

Cabeç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.

+ Convidar

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.

Membro

Status

Criado

Ana Souza

ana@acme.com.br

Ativo

2h

⋯

Bruno Lima

bruno@acme.com.br

Ativo

5d

⋯

Carla Dias

carla@acme.com.br

Pendente

12d

⋯

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.

Abrir

https://ciromaciel.click/design-system.json

application/json

Os mesmos dados estruturados, para quem prefere consumir programaticamente.

Abrir

https://ciromaciel.click/llms-design.txt

text/plain

Ponto de entrada no padrão llmstxt.org para o design system, com as regras invariantes.

Abrir

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.