O CLAUDE.md diz o que vale. Este arquivo diz por quê, e
guarda os casos que já custaram uma noite para alguém.
A separação existe por economia: o contrato é lido em toda sessão de trabalho e precisa ser curto. O manual é consultado quando você está construindo, e pode ser longo à vontade. As seções aqui têm a mesma numeração do contrato.
§1 — Por que a stack é essa, e o que ela cobra
O alvo é um snippet que cola em três lugares sem adaptação: página estática,
.astro, ou bloco HTML do Elementor. É isso que decide todo o resto. Qualquer
coisa que exija passo de build quebra o terceiro caso, que é justamente o mais
usado no dia a dia.
GSAP entra só quando a interação depende de timeline encadeada ou de
ScrollTrigger com scrub. Reveal, parallax simples e entrada de seção são
IntersectionObserver + requestAnimationFrame, já resolvidos no
core/motion.js. Nenhum dos onze componentes autorais precisou de GSAP.
O 404 de fonte não cai no fallback de graça
Custou o showroom inteiro rodando em fonte de sistema sem ninguém notar.
O tema declarava @font-face para Qanelas e Neue Haas Display. Os arquivos
são licenciados e nunca estiveram no repositório — o .gitignore os barra, e
está certo. Mas a declaração ficou.
O que a intuição diz que acontece: o navegador não acha, usa a próxima fonte
da pilha, fim. O que de fato acontece: o navegador pede o arquivo, toma
404, e só então desiste. Em toda página. E o showroom carrega dezenas de
previews em <iframe>, cada um com a própria cópia do CSS — o que multiplicou
o pedido morto por dezenas. Medido na home: mais de 400 requisições de
fonte, todas 404, e 212 erros de console.
Nenhum validador viu, e os dois estavam certos para o que sabiam fazer: o
valida.py conferia caminho em HTML, e o conformidade.py não olha fonte.
url() dentro de CSS era ponto cego dos dois — hoje não é mais: o
valida.py resolve todo url() de CSS a partir da própria folha, e ignora o
que está dentro de comentário, porque regra comentada não roda.
A regra que ficou: declare @font-face só quando o arquivo estiver lá.
Sem o arquivo, a pilha de --display/--sans nomeia a fonte assim mesmo e
cai limpo na seguinte — que é o que o core/temas/kirvano.css sempre fez.
Por que as fontes da casa são auto-hospedadas
Inter e Manrope vivem em core/temas/fontes/biblioteca/, servidas pelo
próprio site. Não por <link> para o Google, por duas razões que sozinhas já
decidiriam: o §1 pede @font-face local, e a CSP em _headers declara
font-src 'self' — um pedido ao fonts.googleapis.com seria bloqueado do
mesmo jeito silencioso que o 404.
Elas podem ser versionadas porque são SIL Open Font License 1.1, livre para redistribuir. Qanelas e Neue Haas não podem. É a licença que decide, não a conveniência.
São woff2 variáveis: um arquivo por família cobre a faixa inteira de peso.
Por isso não têm o par .woff — woff não carrega fonte variável, e todo
navegador que entende fonte variável entende woff2. O par continua valendo
para fonte estática. Créditos: core/temas/fontes/biblioteca/LICENCA.md.
§2 — Por que o idioma é misturado
Português na documentação, inglês no código. O motivo é portabilidade: os sites que já rodam (Tarvos, Liberta Wealth) foram escritos assim. Trocar agora quebraria a cópia nos dois sentidos — daqui para lá e de lá para cá.
§3 — Tokens
Componente que lê só token é o que permite o mesmo hero rodar preto-e-sage na Liberta e claro-e-glow em outro cliente, sem tocar no arquivo do componente.
Global pede conversa, local não
Token global entra no vocabulário que todo componente enxerga e todo tema tem de preencher. Criar um é decisão de arquitetura, e o custo de errar é alto: um nome mal escolhido fica.
Custom property do próprio componente é outra coisa. --cine-dur,
--wheel-radius, --sync-band e --fold-spread não são vocabulário — são o
jeito de o CSS e o JS trocarem um número sem cravá-lo em dois lugares. O
midia/roda-de-cards mede o raio no script e escreve em --wheel-radius; o
CSS lê. Se isso precisasse de aprovação, ninguém construiria nada.
A regra prática: se outro componente pode querer ler, é global. Se só o dono entende o que o número significa, é dele.
A cor de marca não é cor de texto
--accent existe para o botão, o filete do olho, o ponto aceso, a borda que
acende. Em nenhum desses casos ele é texto pequeno sobre fundo claro — e
foi exatamente ali que ele falhou.
O core pinta o realce em itálico do título com a cor de marca:
h2.sec-title em{color:var(--accent)}. Sobre o escuro da casa isso dá 12:1,
folgado. Sobre o creme do .sec-paper, com o sage da Liberta, dá 1.36:1 —
o mínimo é 3:1 para texto grande e 4,5:1 para texto normal. O realce ficava
praticamente invisível, e já era assim em quatro componentes catalogados:
conversao/calculadora-sucessao, efeitos/quebra-papel-rasgado,
listas/leque-paineis e prova/manchetes-imprensa.
A saída foi um token de escopo, --accent-ink, e a escolha do valor tem uma
razão que a conta mostrou: misturar accent com --fg só serve no claro.
| escopo | fundo | accent puro | 45% accent + --fg |
|---|---|---|---|
.sec-dark |
#0a0a0a |
12,08 | 14,86 |
.sec-paper |
#ede9e3 |
1,36 | 4,59 |
No claro a mistura escurece e resolve. No escuro ela clarearia, e aí o
problema é outro: 45% de accent com uma tinta quase branca devolve um cinza
esverdeado pálido — o contraste sobe e a cor de marca desaparece. Por isso
o escopo escuro mantém --accent-ink: var(--accent), e só o claro mistura.
Duas consequências práticas:
-
Componente que quer realce EM TEXTO SOBRE O FUNDO DA SEÇÃO lê
--accent-ink, comvar(--accent)como fallback para quem estiver fora de um escopo de tom. -
Texto sobre FOTO continua lendo
--accentcru, e trocar pioraria. Esta ressalva não é detalhe: dezessete componentes põem texto em--accentem cima de imagem com scrim ou véu, e ali o fundo real não é o--bgdo escopo — é a foto escurecida. Medido nomidia/grade-cases-foto: sobre a foto com véu, o accent puro dá 6,9 a 10,3:1, e o--accent-inkde uma.sec-papercairia para cerca de 3,5:1. Uma varredura que trocasse um pelo outro em nome da consistência pioraria dezessete peças em silêncio.
A regra que decide é qual fundo está atrás do glifo: fundo do escopo →
--accent-ink; foto com véu → --accent.
- A correção é do tema de graça. As duas pontas da mistura são tokens, e um
tema que troque o --accent leva o ajuste sem escrever nada.
O que não se resolve com isso: --accent como fundo de botão em seção
clara continua sendo decisão de contraste do tema, porque ali quem tem de
mudar é a cor do texto por cima, não a do fundo.
A tinta de apoio tinha o mesmo defeito, e ninguém tinha medido
Um dia depois de .nota virar primitiva — a ressalva de 12,5px, que a
promoção levou a dezessete componentes de uma vez —, a conta foi refeita para
--fg-muted. Texto pequeno pede 4,5:1, e o cinza cravado não fechava em
nenhum dos três temas da casa:
| escopo | fundo | --fg-muted cravado |
|---|---|---|
.sec-light |
#f4f2ee (default do core) |
4,63 — passa raspando |
.sec-light |
#f1ece1 (kirvano) |
4,39 |
.sec-light |
#ede9e3 (liberta-wealth) |
4,28 |
.sec-light |
#e4e4e7 (liberta-eventos) |
4,08 |
.sec-paper |
#ede9e3 (liberta-wealth) |
4,37 |
A causa é a mesma do --accent-ink, e vale como regra: valor cravado num
escopo é calibrado contra o fundo do default, e todo tema que escurece
--light-2 invalida a calibragem sem avisar. Os três escurecem — é o que
um tema faz.
A saída também é a mesma: derivar das duas pontas que o tema controla.
--fg-muted:color-mix(in srgb,var(--fg) 62%,var(--bg)) nos dois escopos
claros devolve 4,62 a 4,95:1 nos três temas, medido no Chrome com
bin/medir.js trocando o href do tema em tempo de execução.
62% é o menor valor que fecha 4,5 no pior fundo da casa (#e4e4e7) com
folga. Acima disso a tinta de apoio começa a disputar com --fg-2 e a
hierarquia de três níveis achata.
O escuro ficou de fora de propósito: lá o cinza cravado dá 6,05:1 no
.sec-dark e 5,43:1 no .sec-deep, e derivar só tiraria a calibragem de
quem já está certo.
Consequência para quem escreve tema: core/temas/liberta-wealth.css
deixou de redeclarar --fg-muted no .sec-paper dele. Redeclarar é
legítimo — mas quem redeclara volta a assinar a conta sozinho, e a conta é
contra o fundo daquele tema, não contra o do core.
A exceção do escopo de tom
"Componente jamais faz override de --fg" continua valendo. Mas existe um
padrão que parece violação e não é: declarar .sec-dark ou .sec-deep no
markup do próprio bloco.
A diferença é real. Override é o componente redefinir --fg com um valor
próprio, e aí ele deixa de ser tematizável. Escopo no markup é o componente
entrar num escopo que o core já define, continuando a ler os mesmos nomes
— o tema segue mandando nas cores dos dois escopos.
Serve para inverter o tom dentro de uma dobra de tom oposto: quatro cards
escuros numa seção clara. Precedente: conteudo/bento-recursos-expansivel.
O tom vizinho
Fundos escuros adjacentes têm de ser quase idênticos (#0a0a0a / #131313).
Salto forte de tom entre seções suja a página. Quando precisa de contraste de
verdade, use uma seção .sec-paper inteira como respiro, e a seguinte volta
direto ao escuro.
§4 — Grid
.wrap sozinha controla a coluna. A tentação é sempre a mesma: um componente
que precisa sangrar até a borda ganha margin-inline: calc(50% - 50vw). Não
faça. 100vw inclui a barra de rolagem em alguns navegadores, e o componente
passa a estourar a página na horizontal em vez de sangrar.
A saída é o elemento sair de dentro do .wrap. Quando não dá — trilho que
sangra mas cujo primeiro card tem de alinhar à coluna — o padrão da casa é uma
custom property de recuo calculada com a mesma conta do .wrap, aplicada ao
trilho. Ver --pfl-inset no midia/carrossel-perfis e --depo-inset no
prova/carrossel-depoimentos.
§5 — Movimento
Nada entra seco
Toda entrada carrega filter: blur(14px) indo a zero, junto do fade e da
subida. Translate puro não é aceito. É a assinatura de movimento da casa: o
elemento não aparece, ele entra em foco.
O caso que custou uma noite: blur(0) ainda é um filtro
[data-rv].in termina em filter: blur(0), e não em filter: none. Isso não
é descuido, e a diferença é grande: um elemento com filtro é bloco de
contenção para descendente position: fixed.
Consequência: tudo que já foi revelado por [data-rv] segue prendendo um
filho fixed à própria caixa, para sempre — não só durante a entrada. Medido
no Chrome: filho fixed com top:0 dentro de um pai com blur(0px) pinta no
topo do pai; com filter:none no pai, pinta no topo da janela.
Trocar por filter:none não resolve: a transição sai de blur(14px), e
none não interpola a partir de um filtro — a entrada perderia o desfoque.
Então a regra fica, e quem precisa de um elemento fixed (uma imagem que
segue o cursor, um painel colado à janela) põe esse elemento fora de
qualquer [data-rv], como filho direto da seção. É o que o
listas/lista-imagem-flutuante faz.
sticky não sofre disso, e a diferença importa. A leitura natural da
regra acima sugere que todo posicionamento quebra dentro de um elemento
filtrado — não quebra. position:sticky gruda no contêiner de rolagem mais
próximo, e filtro nenhum interfere nisso. Medido no mesmo pai com
filter:blur(0px): o filho fixed ficou ancorado ao contêiner (topo em
1253, rolando junto com ele, em vez de parar no topo da janela), e o filho
sticky grudou certinho no topo do seu scroller (1653). Um cabeçalho de
tabela sticky dentro de uma linha revelada por data-rv funciona.
Precedente: listas/tabela-comparativa.
O outro caso que custou uma noite: a especificidade do reveal
O core termina o reveal com [data-rv].in{transform:none}. Esse seletor vale
(0,2,0) — mais que [meu-atributo] ou .minha-classe, que valem (0,1,0).
Um componente que gerencie o próprio transform de repouso no mesmo nó que
leva o data-rv perde a regra em silêncio assim que a dobra entra. O sintoma
é cruel: o JS continua rodando, o style inline até aparece certo no
inspetor, e o computado volta a none.
Medido: com o core carregado, [data-tilt]{transform:…} e
.classe{transform:…} no mesmo nó computam transform:none depois do .in;
o mesmo transform num filho, com o data-rv no pai, computa a matriz
certa.
A saída é sempre a mesma: data-rv num invólucro, elemento que se move
por dentro. É o que efeitos/inclinacao-3d faz com .tilt-card-slot.
A armadilha é com transform, e só com ele. As propriedades individuais
translate, rotate e scale são outras propriedades, e o reset do core não
as toca. Medido em 2026-10-01: um [data-rv].in com translate:0 30% computa
transform:none e o translate intacto. Um deslocamento de repouso
escrito em translate pode morar no mesmo nó do data-rv — e às vezes
precisa: no hero/letreiro-marca a capa carrega a sombra no invólucro
deslocado, e com o data-rv num filho a sombra aparecia pousada um segundo
antes da capa. Quem "simplificar" um desses para transform: translateY()
reabre o caso desta seção.
Princípios da leva autoral
- Tempo, não quadro. Velocidade em px/s ou graus/s multiplicada por
dt, com teto de 50ms — 60Hz e 120Hz andam igual, e voltar de uma aba em segundo plano não salta. - Toque não tem hover. Tudo que depende de hover nasce visível em
@media(hover:none)ou tem alternativa. - Movimento reduzido desliga o que anda sozinho, nunca o que a pessoa pediu: arrasto e setas continuam, sem inércia.
§6 — CSS
Por que prefixo em tudo
Classe solta (.n, .full, .veil) colide no primeiro projeto que juntar
dois componentes na mesma página. Como a biblioteca existe justamente para
juntar componentes, a colisão não é hipótese: é questão de tempo.
Por que seletor de elemento é proibido solto — e onde não é
h3 { } dentro do CSS de um componente vaza para todo h3 da página no
momento em que a folha é colada, e o §6.4 diz que ela pode ser colada em
qualquer posição. Por isso a estrutura do componente é sempre classe.
O que não é problema: elemento descendente do próprio prefixo, em
conteúdo de prosa. .fan-body p não vaza — está preso ao bloco. E prosa é
justamente o lugar onde exigir classe não paga: o texto vem de quem escreve
conteúdo, não de quem escreve markup, e obrigar .fan-body-paragraph em cada
parágrafo transforma edição de texto em edição de código.
A fronteira: estrutura e peça de interface pedem classe; corpo de texto
corrido (p, li, strong, em, a) pode ser elemento, sempre descendente
do prefixo.
O [hidden] que não esconde
O [hidden] do navegador é display:none na folha do agente do usuário, e
qualquer regra de autor com display vence essa — inclusive um
.minha-classe{display:flex} inocente.
O sintoma é cruel: o JavaScript põe hidden, o atributo está lá no inspetor,
e o elemento continua na tela. A linha que resolve é uma só, e vive no próprio
componente:
.meu-bloco[hidden]{display:none}
Já foi preciso em quinze componentes (contados em 2026-09-06):
agendamento-reuniao, aviso-cookies, card-expansivel-fullscreen,
carrossel-foco-central, comparador-cenarios, diagnostico-perfil,
drawer-fullscreen, drawer-lateral, galeria-scroll-horizontal,
modal-dialogo, planos-honorarios, progresso-leitura, select-custom,
simulador-carteira, slideshow-cinematico. Não vira primitiva porque o
seletor tem de ser do próprio bloco — o que se promove aqui é a regra de
escrevê-la.
Que a lista tenha ido de sete a quinze em cinco dias diz o de sempre: toda peça que alterna estado esbarra nisso, e cada uma esbarra sozinha. Se um dia alguém achar um jeito de a regra valer sem o seletor do bloco, é a maior economia de linha repetida que a biblioteca tem para fazer.
Por que mobile fica no fim do bloco
Media query de largura em arquivo separado obriga a abrir dois arquivos para entender uma regra. No fim do próprio bloco, a leitura é linear: desktop, depois o que muda.
@media(prefers-reduced-motion) é a exceção e fica colada à regra que
desliga — ali a proximidade é o que torna a intenção legível.
Véu sobre imagem
backdrop-filter em caixa reta produz borda dura e lê "chapado". Sempre
acompanhado de máscara em gradiente:
mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
Texto sobre imagem
Legenda ou título de card de mídia vai dentro da imagem, ancorado embaixo, com gradiente mais blur mascarado. Não vira parágrafo abaixo da foto — a legenda embaixo transforma uma peça gráfica em duas.
Hover não mexe em nada
Decisão do dono em 2026-09-29, com a razão dele: "sempre acaba quebrando as páginas". E quebra mesmo, por três caminhos que a biblioteca já tinha pisado:
- O zoom pede uma escala de repouso para esconder a borda. Seis
componentes deixavam a foto em
scale(1.02)parada, só para o passo de zoom não descobrir o fundo — a foto nunca era exibida no tamanho em que foi enquadrada. Colado num contêiner semoverflow:hidden(bloco do Elementor, coluna de outro tema), o mesmo zoom vaza por cima do vizinho. - O card que sobe 3px carrega a sombra e o vizinho junto, e numa grade de altura justa empurra a linha de base.
- Abrir por passagem do mouse rearranja a página sem a pessoa ter pedido.
O
listas/leque-paineisabria o painel nomouseenter: bastava cruzar a seção a caminho do menu para a fileira inteira se reorganizar. Amidia/galeria-pastaespalhava as fotos em arco sob o ponteiro.
O que saiu, na varredura do dia: o zoom de foto de dez componentes
(pilares-texto-na-foto, carrossel-foco-central, carrossel-perfis,
faixa-videos, galeria-scroll-horizontal, grade-cases-foto,
showreel-expansivel, card-expansivel-fullscreen, depoimento-video e o
card que subia no drawer-lateral), o salto de cinco botões
(stage-arrow, fab e os três discos de play) e a abertura por hover do
leque e da pasta. O .btn do core já dizia a mesma coisa desde o começo —
"o botão não salta nem move o layout" —, e cinco peças o desobedeciam.
O retorno de hover mora onde não há geometria: filter (brilho,
saturação), cor de borda e de texto, opacidade de um véu. Quase todos os
componentes já tinham os dois — o zoom e a cor —, e a cor sozinha basta.
O que continua permitido, e por quê: a seta de 2–4px dentro do .btn
(é o .btn svg do core) e o filete de link que cresce por ::after. Os dois
são tipografia: não mudam a caixa de nada e não tocam imagem. E os
componentes cujo assunto é o ponteiro — efeitos/inclinacao-3d,
efeitos/cursor-disco, listas/lista-imagem-flutuante — não são hover
incidental; quem os cola está pedindo exatamente aquele efeito.
O bin/conformidade.py acusa transform sob :hover quando o sujeito da
regra é o próprio elemento sob o ponteiro ou um descendente que não seja
svg, pseudo-elemento ou ícone.
Quando promover ao core
Uma regra que aparece em três ou mais componentes é candidata a primitiva.
Antes de recopiar pela terceira vez, promova e ajuste os componentes. O
.display-xl entrou assim: conteudo/manifesto-com-imagens era a terceira
cópia, depois de conteudo/apresentacao-foto-alternada e
midia/carrossel-perfis.
O ul,ol do reset entrou pelo mesmo caminho em 2026-09-06:
conteudo/rolagem-sincronizada, conversao/agendamento-reuniao e
listas/linha-do-tempo-etapas escreviam as mesmas três declarações, cada um
com o próprio comentário explicando que o core zerava ul e não ol.
A primitiva que quase entrou errada
O .field nasceu de três cópias idênticas, e a receita foi copiada da mais
antiga delas — que trazia outline:none no controle. Passou para o core junto,
e o comentário da primitiva chegou a afirmar que "o outline do
:focus-visible continua de pé".
Não continuava. .field input vale (0,1,1) e o ::focus-visible do core vale
(0,1,0): o anel de foco morreria em todo campo que vestisse a primitiva.
Dois dos três componentes de origem já zeravam o anel localmente, então para
eles nada mudaria — o erro era invisível justamente onde nasceu. Quem o pegou
foi o terceiro, o conversao/agendamento-reuniao, o único que mantinha o anel
e o único que tinha escrito por quê.
Duas lições, e a segunda é a que interessa:
- Promover é reescrever, não copiar. A cópia mais antiga não é a referência: é só a mais antiga. Ela carrega as decisões do dia em que foi escrita, e algumas eram erradas.
- O componente que documenta por que diverge é o que protege o core. Se o
agendamento-reuniaotivesse simplesmente copiado ooutline:nonedos irmãos, a primitiva teria entrado quebrada e o defeito só apareceria meses depois, num campo qualquer sem anel de foco.
Adotar uma primitiva cria uma dependência silenciosa
O caso, inteiro, aconteceu em uma tarde:
- A
.dialog-cascanasceu comcolor:inherit, para o<dialog>não cair noCanvasTextdo navegador. - O
overlay/aviso-cookiesadotou a primitiva e apagou ocolorlocal, porque a primitiva agora fazia aquilo. Correto, e é o objetivo da promoção. - Mediu-se que
color:inheritvale (0,1,0) — o mesmo que.sec-deep{color:var(--fg)}— e, carregando depois, vencia o escopo de tom declarado no markup, que é a exceção nomeada do §3. Foi removido. - O painel de cookies ficou com texto preto sobre fundo
rgb(24,24,24). Nada acusou: o CSS é válido, a conformidade passa, o console fica limpo.
A regra que fica: um componente que apaga uma declaração local porque a primitiva a cobre passa a depender daquela linha da primitiva. Mexer na primitiva depois quebra o componente em silêncio, e o silêncio é o problema — não há validador para "esta cor herdada ficou ilegível".
Duas consequências práticas:
- Ao mudar uma primitiva, meça os consumidores dela, não só o arquivo do
core.
greppela classe é o começo da lista. - Um componente que precisa de tom próprio declara escopo de tom no markup
(
class="dialog-casca lgpd-painel sec-deep") em vez de depender da herança. O escopo é contrato; a herança é acidente. Foi assim que o caso se resolveu, e é o queoverlay/modal-dialogoeoverlay/drawer-lateraljá faziam.
Seis armadilhas que a leva de 2026-09-09 mediu
Todas apareceram construindo, nenhuma lendo o código. Ficam aqui porque a próxima pessoa vai esbarrar nas mesmas.
1. <dialog> modal ESCAPA da contenção do filter. O §5 diz que elemento
com filtro prende descendente position:fixed, e o sticky já ganhou a
ressalva dele. Agora o diálogo: com um [data-rv] de blur(10px) real por
cima, um <dialog> aberto com showModal() mediu [0,0,1425,900] e pintou
nítido. É a primeira coisa fixed da casa que escapa da armadilha — porque
o diálogo modal vive na top layer, fora da árvore de pintura do ancestral.
Precedente: prova/depoimento-video.
2. width/height no <img> transforma aspect-ratio em no-op silencioso.
Uma capa com width="1600" height="900" e aspect-ratio:16/9 saiu 348×900
— o atributo ganha, e o card foi a 902px. block-size:auto resolve.
Precedente: midia/faixa-videos.
3. Véu de legenda se mede pela altura da LEGENDA, não do card. Véu com
altura em porcentagem do card é ultrapassado por título de três linhas em tela
estreita, e o texto sai do gradiente. O véu vira ::before da própria legenda.
Precedente: midia/grade-cases-foto.
4. list-style:none custa a semântica de lista no Safari. O reset
ul,ol do core remove o marcador, e o VoiceOver deixa de anunciar "lista de N
itens". Onde a ordem é a informação, escolher uma das duas saídas é
obrigatório: role="list" no <ol>, ou número audível (sem aria-hidden no
contador). O listas/linha-do-tempo-etapas tinha as duas faltando — cinco
etapas que não entregavam ordem a ninguém que ouve a página.
5. O [hidden] do §6.5 tem um segundo buraco: a decoração do vizinho.
.meu-bloco[hidden]{display:none} esconde o bloco, e deixa de pé a seta que o
penúltimo item desenha apontando para ele. A saída é
:has(+ .meu-item[hidden]), que a casa já usa em três componentes.
Precedente: dados/fluxo-conectado.
6. Na ficha, código inline com um atributo de caminho vaza caminho
resolvível. (E sim: a primeira versão deste próprio parágrafo caiu nela,
escrevendo o atributo em span inline — o valida.py acusou o MANUAL.html.)
O
ficha.html gerado escapa as aspas dentro de bloco cercado (``), mas **não**
dentro de span inline — e ovalida.pypassa a acusar um caminho que só
existia como exemplo. Escreva exemplo de markup em bloco cercado.
Precedente:midia/faixa-videos`.
E uma de ambiente, ao lado da nota dos quatro autofocus do §8: legenda
.vtt não carrega por file://. O <track> é buscado com CORS e cada
arquivo local é origem opaca. Conferir legenda pede servidor.
A sétima, medida em 2026-09-21: o comentário que se fecha sozinho
Custou 885px de rolagem horizontal num celular de 390, com o
conformidade.py passando limpo.
O componente de efeitos delimita a peça no markup com um comentário de
abertura e outro de fechamento, como moldura-secao e cursor-anotacao
fazem. O cabeçalho do arquivo, que também é comentário, citava esses
delimitadores por escrito — e a citação carregava a marca de fechar
comentário. O HTML não tem comentário aninhado: a marca fecha o comentário
ali, no meio da explicação. Da linha seguinte em diante o cabeçalho
deixou de ser comentário e virou conteúdo — a prosa foi parar na página, e o
<em> que o texto mencionava virou um elemento de verdade.
Por que nada acusou: o auditor lê CSS e JS, não interpreta HTML; o navegador não reclama de comentário mal fechado, ele só decide onde o comentário acaba; e o preview abre normalmente, com uma barra de rolagem horizontal que só aparece em tela estreita.
A regra: dentro de comentário, escreva o delimitador por extenso — "o comentário de início e o de fim da peça" —, nunca a marca literal. Vale para o HTML e vale para o CSS, onde a marca de fechar bloco tem o mesmo poder. É primo do caso 6 acima: as duas nascem de escrever, dentro do arquivo, um exemplo do próprio arquivo.
Precedente: efeitos/destaque-texto-scroll.
E a oitava: grid-template-rows:0fr não fecha nada
A receita de acordeão sem altura fixa é uma pista de grade indo de 0fr a
1fr, e ela está espalhada por toda a internet escrita assim:
.item{display:grid;grid-template-rows:0fr;transition:grid-template-rows .4s}
.item.aberto{grid-template-rows:1fr}
0fr sozinho é minmax(auto,0fr) — o mínimo automático é o conteúdo, e o
conteúdo não encolhe. Medido no listas/etapas-em-abas: a linha "fechada"
tinha 103,938px, exatamente a altura da aberta, e a transição não tinha o
que animar. Com min-height:0 no item de dentro sobraram 10px (o padding).
Só minmax(0,0fr) na pista fechou em 0px.
O erro é silencioso das duas pontas: o CSS é válido, o navegador não reclama,
e num acordeão com pouco texto a diferença passa por decisão de desenho.
Quem escrever a receita, escreva minmax(0,0fr) e meça a altura fechada.
Precedente: listas/etapas-em-abas.
A fila de promoção — o que saiu dela e o que continua
A fila é contada à mão, e envelheceu duas vezes: em 2026-09-21 e de novo em
2026-09-29 ela listava como pendente o que já estava no core. Quem promover
algo daqui, risca a linha na mesma mudança — e quem for contar de novo relê
o core/ antes de acreditar na tabela.
Já promovidas — a primitiva existe, e o número entre parênteses é quantos componentes a consomem hoje:
| Primitiva | Onde | Consumidores |
|---|---|---|
| Trava de rolagem com contador | LIB.travarRolagem/soltarRolagem, core/motion.js |
6 — drawer-fullscreen, card-expansivel-fullscreen, drawer-lateral, modal-dialogo, faixa-videos, depoimento-video. O bug que esperava — dois overlays se destravando mutuamente — morreu com o contador |
Casca do <dialog> |
.dialog-casca, core/base.css |
6. Só a casca neutra; a coreografia de cada um continua local |
| Campo com régua | .field, core/base.css |
4, com a conversao/faixa-newsletter |
.sec-head centrar os filhos |
core/base.css, 2026-09-29 |
todo .sec-head. Ver abaixo |
Tokens --veil e --z-overlay |
core/tokens.css |
2 leem hoje; os <dialog> vivem na top layer e não precisam |
.nota, o parágrafo de ressalva |
core/base.css, 2026-09-21 |
17 migrados, mais os que nasceram depois. Ver "Saíram da fila — 2026-09-21" |
.btn-play + LIB.pausavel |
core/base.css e core/motion.js, 2026-09-21 |
o botão de pausar o que anda sozinho (WCAG 2.2.2) |
| Hora num fuso | LIB.relogio(fuso), core/motion.js, 2026-10-01 |
3 — contato-escritorios, cta-com-assessor e hero/letreiro-marca, que foi a terceira cópia. Só o relógio subiu; a regra de cada selo continua local. Ver abaixo |
O .sec-head, e por que a contagem estava errada. A fila dizia "doze
componentes escrevem margin-inline:auto para corrigir". Medido no Chrome
antes da promoção, a 1440px, eram dezenove os que não corrigiam: o título
saía 103px à esquerda do eixo do cabeçalho e o apoio 65px, em
comparador, FAQ, tabela, modal, drawer, manchetes, painel e mais doze. A conta
certa não era "quantos remendam" — era "quantos precisam". Duas declarações no
core consertaram os dezenove e tornaram nove remendos redundantes, apagados na
mesma mudança; dois (diagrama-hub, baralho-sticky) ficaram porque a mesma
regra faz outra coisa.
Continuam na fila:
| Candidata | Cópias | O que promover |
|---|---|---|
| Véu-legenda sobre mídia | 18 | linear-gradient + backdrop-filter + a mesma mask-image, caractere a caractere. Promova a casca, nunca a forma: altura, ancoragem e conteúdo são de cada peça |
| Reveal por estado, disparado por classe do ancestral em vez de interseção | 3 — card-expansivel-fullscreen, showreel-expansivel, drawer-lateral |
o data-rv é IntersectionObserver e revela uma vez; painel que abre e fecha repetidamente não pode usá-lo |
| Disco de fechar sobre mídia | 3 | 44px, color-mix de --light/--dark e backdrop-filter. O base.css já registra que o .btn-disc não cobre este caso de propósito |
| Selo concedido pelo relógio | 7 — cards-cotacoes, grade-artigos, agenda-eventos, contato-escritorios, e em 2026-09-29 cta-com-assessor, selo-prazo, programacao-evento |
o padrão é o mesmo (a peça só afirma o que a data sustenta), mas as regras são cinco — validade, janela, início/fim, horário por fuso, prazo com vagas. Promover a função de relógio, nunca a regra. ~~O horário por fuso via Intl~~ saiu da fila em 2026-10-01 como LIB.relogio (ver a tabela de promovidas). Continua com segunda cópia literal o início/fim de sessão (agenda-eventos → programacao-evento); a terceira é o gatilho |
| Loop de vídeo com pausa | 2 — hero/video-fundo e midia/case-ficha-flutuante (2026-10-01) |
não é a mesma função, e a diferença vai importar na promoção: o hero segue os eventos do <video> (ninguém o pausa por fora), e o case segue o pedido da pessoa, porque o próprio script pausa o vídeo ao sair da tela e trocar de aba. A primitiva, se vier, é a do case — toca só na tela, nasce parado sob reduce/saveData, botão pelo LIB.pausavel —, com o hero como caso particular sem a escuta de visibilidade |
E as que ainda não bateram, anotadas para a contagem: .pill-sm de ressalva
(2 — painel-patrimonio, lista-beneficios-palco), .visually-hidden que
alterna por media query (2 — galeria-pasta, tabela-comparativa), o par de
ações de peso igual em grade (2 — aviso-cookies, cta-dois-caminhos), e o token --veil-media:
showreel-expansivel, prova/depoimento-video e midia/faixa-videos chegaram
independentemente aos mesmos 88% — "vídeo pede sala escura" —, e três
peças convergindo no mesmo número é o sinal de que o número tem nome. Token
global pede conversa antes (§3).
Saíram da fila — 2026-09-21
| Promovida | Cópias na hora | O que entrou no core |
|---|---|---|
.nota, o parágrafo de ressalva |
17 componentes sob 25 nomes de classe — a fila acima dizia 11, e a recontagem achou dezessete | só o invariante tipográfico (corpo, entrelinha, letter-spacing:normal, --fg-muted, medida), com --nota-size e --nota-max como parafuso. Margem, alinhamento, filete e recuo ficaram locais, que é o que impediu a primitiva de ser desobedecida por doze delas |
.btn-play + LIB.pausavel, o botão de pausar o que anda sozinho |
2 na casa (hero/video-fundo, hero/slideshow-cinematico) e mais três chegando na mesma leva |
a troca de ícone sai de [data-pausado] no próprio botão, e não de classe de estado na raiz — duas instâncias na mesma página compartilhariam o seletor. E a copy dos rótulos saiu do JavaScript: data-rot-pausar/data-rot-tocar no markup, que é onde um redator procura |
Sobre a segunda: o gatilho do §6 é três cópias, e aqui eram duas — o que decidiu foi a leva que estava sendo escrita no mesmo dia, com mais três botões de pausa. Promover antes da terceira cópia é o que a própria regra manda quando a terceira já está à vista.
Quando esta seção foi escrita, a fila ainda listava como pendentes o
.field, a .dialog-casca e a trava de rolagem, que já estavam no core. A
tabela de promovidas acima foi reescrita em 2026-09-29 para refletir isso.
Saiu da fila — 2026-10-01: a hora num fuso
O hero/letreiro-marca precisava mostrar a hora do escritório, e a função
que dá dia e minuto num fuso (Intl.DateTimeFormat com formatToParts) já
estava copiada ao caractere em conversao/contato-escritorios e
conversao/cta-com-assessor. Seria a terceira cópia, e subiu como
LIB.relogio(fuso), devolvendo {dia, min, h, m} ou null.
O que subiu é o relógio, não a regra. "Aberto até 18h", "volta amanhã às
9h" e "agora são 17h08" continuam cada um no seu componente — são três textos
e três decisões. O null também é contrato: fuso ilegível ou navegador sem
Intl, e quem chama não afirma nada, que é a regra de toda peça concedida
pelo relógio. Os dois consumidores antigos passaram a sair no guard sem
LIB.relogio, o mesmo comportamento de sem JavaScript.
§7 — JS
As seis regras do contrato são todas sobre a mesma coisa: o componente pode
aparecer duas vezes na mesma página, em qualquer posição, com ou sem os
irmãos. Guard clause silenciosa em vez de erro, busca a partir da raiz em vez
do documento, e passive para não travar a rolagem de uma página que você não
controla.
requestAnimationFrame para qualquer coisa presa ao scroll não é otimização
prematura: cálculo direto no listener roda dezenas de vezes por quadro e
produz jank visível em celular médio.
§8 — Ordem e preview
destaque: por que a ordem não sai da complexidade
A vitrine abre para impressionar, e a ordem em que os componentes aparecem é
metade disso. Ela sai de dois lugares: bin/categorias.json decide a sequência
dos blocos, e o destaque de cada componente decide a ordem dentro do bloco.
Antes, dentro da categoria, ordenava por complexidade — e complexidade é um
proxy ruim para interesse. O conteudo/manifesto-com-imagens é o título
gigante em caixa alta com imagens dentro da frase, um dos mais bonitos da
casa, e é de complexidade baixa: caía em nono de dez. O
conteudo/pilares-texto-na-foto, que é imagem com véu e texto por cima, era o
último. Enquanto isso o conversao/select-custom, um <select> estilizado,
subia por ser de complexidade média.
A escala é curta de propósito, porque escala longa vira discussão:
3 |
vitrine — para a rolagem de quem está só passando o olho |
2 |
forte — bom componente, mas não é o cartão de visita da categoria |
1 |
normal — o padrão de quem entra |
0 |
bastidor — existe porque é necessário, não porque impressiona |
A complexidade continua no critério, como desempate, e o id fecha. Componente
novo nasce em 1 e sobe quando merecer — o _modelo/componente.json já traz
assim.
O preview é gerado, nunca escrito duas vezes
preview.html sai do markup.html. Nunca escreva o markup
duas vezes à mão.
Os quatro avisos de autofocus no console do showroom local são do
file://, não do seu componente. Abrindo index.html com duplo-clique, o
Chrome trata cada <iframe> como origem opaca e recusa o foco automático:
"Blocked autofocusing on a <button> element in a cross-origin subframe" —
hoje, vindo de modal-dialogo, drawer-lateral e aviso-cookies, que usam
autofocus dentro do <dialog> de propósito. Medido nos dois modos: por
file:// os avisos aparecem; servido por HTTP (same-origin, que é como o site
roda no ar) o console fica limpo, com os mesmos 70 cards. O preview
isolado de cada um também é limpo nos dois. Não persiga esses quatro.
bash bin/previews.sh # todos
bash bin/previews.sh hero/full-bleed-media # um só
TEMA=<projeto> bash bin/previews.sh # com outro tema
Precisa de mais que o markup? Cinco arquivos opcionais na pasta do componente, todos respeitados pelo gerador:
| Arquivo | Para quê |
|---|---|
preview-antes.html |
markup antes do componente — um rodapé só existe no fim de uma página, e sem dobras em cima não há o que ele revele |
preview-extra.html |
markup depois do componente — uma seção falsa para o parallax ter para onde rolar, um botão que dispara o estado |
preview-tema.txt |
nome do tema só deste componente, uma linha — clone de referência costuma só fazer sentido na cor da referência, e assim uma regeração geral no tema padrão não o desmancha |
preview-css.txt |
folhas extras, um caminho por linha — para componente que compõe outro |
preview-js.txt |
scripts extras, um por linha, inseridos antes do próprio |
§9 — Fluxo para catalogar um componente novo
- Copie
_modelo/paracomponentes/<categoria>/<nome>/. - Extraia o markup da origem e normalize os prefixos de classe.
- Troque toda cor crua por token. Faltando token global, discuta antes (§3).
- Se a regra já apareceu em três componentes, promova ao core antes de recopiar.
- Preencha
componente.json— inclusive odestaque, que nasce em1— e aficha.mdinteira — inclusive o padrão editorial (que conteúdo entra ali) e o histórico. - Registre a origem no arquivo certo de
projetos/(§11). - Rode os quatro comandos do contrato §9, nesta ordem, e o
node bin/varredura.js <categoria>/<nome>— ele mede o componente no navegador nos quatro cenários que ninguém lembra de testar à mão: celular, sem JavaScript, movimento reduzido e o console.
§11 — Status e caminhos de entrada
Os três status, e o que foi aposentado
estavel— sem pendência aberta. Copie.revisar— funciona, mas a ficha tem pendência aberta, e ela diz qual.experimental— a forma ainda pode mudar entre versões.
Existia um quarto, externo, para "nunca rodou em produção". Foi aposentado,
e a razão é que ele não informava nada: 33 dos 54 componentes o carregavam.
Quando a maioria tem o mesmo selo, o selo para de discriminar.
O erro de origem foi medir o componente pelo histórico em vez de pelo código.
Histórico não prevê uso: cada projeto pede uma peça diferente, e "já rodou na
Liberta" não diz se serve na próxima. Pior, o rótulo misturava duas coisas que
não coincidem — nunca rodou e veio de fora sob licença de terceiro —, e a
ficha do conversao/cta-fechamento-foto chegou a precisar de três parágrafos
para explicar qual das duas valia no caso dela.
Os três experimental de então caíram junto: os três diziam "rodou uma vez",
que é histórico com outro nome. Em variacoes[], porém, experimental tem
sentido próprio e ficou — ali marca a variante que ainda não foi exercitada,
o que é estado de código.
A procedência não se perdeu: ela vive onde sempre viveu, no histórico da ficha
e nos registros de projetos/, que é onde alguém vai procurar.
Clone de referência
O caminho padrão de entrada: um site no ar, uma seção dele, e um clone reescrito do zero aqui dentro.
Licença de site aleatório nunca é clara, então a regra decide sozinha: não
se copia — se reescreve. Da referência vem a ideia da interação, a proporção,
o timing e a curva; o arquivo é nosso, em token, prefixo, primitiva do core e
gancho do motion.js. Na prática o original quase nunca serviria mesmo: vem
em Tailwind, GSAP ou React.
Na ficha, o histórico traz a URL como referência visual — não como origem
de código — mais o que mudou na tradução. No componente.json:
"origem": [{ "projeto": "referência: <site>", "arquivo": "<url>", "ancora": "<seção>", "data": "<AAAA-MM-DD>" }]
Registro consolidado: projetos/referencias-externas.md, seção Clones.
Origem externa de código
Caso distinto: não um site no ar, mas um componente publicado como código, com autor nomeado e licença explícita. Licença clara é condição de entrada — sem ela, reescreva do zero ou deixe passar.
O crédito é obrigatório na ficha mesmo quando a licença dispensa: de onde veio, quem escreveu, sob qual licença. Crédito não custa nada.
O código continua sendo reescrito. Licença permissiva autoriza copiar; é o
contrato desta biblioteca que impede — um arquivo em React e Tailwind não
caberia aqui de nenhum jeito. Precedente: midia/galeria-pasta.
Registro consolidado: projetos/referencias-externas.md, seção Origem
externa de código.
Componente autoral
Um padrão de interação que aparece em muitos sites premium (anel 3D arrastável, faixa horizontal pinada, lista com imagem que segue o cursor) e que aqui é construído do zero, sem referência única e sem uma linha copiada.
A diferença para o clone é só de registro: não há URL. No componente.json,
origem[].projeto é "autoral", ancora nomeia o padrão, e nota diz quais
famílias de exemplo inspiraram a forma e o que foi decidido na tradução.
Autoral não é licença para inventar: a regra de ouro continua. Um autoral só existe quando nenhum componente catalogado resolve a interação, e a ficha tem de dizer qual irmão ele substitui e em que caso.
Registro consolidado: projetos/autorais.md.
O que nunca atravessa
Fonte de terceiro, ícone proprietário, logo, foto e qualquer asset com direito
de imagem — mesmo que o original tenha. O preview usa os placeholders de
showroom/demo/, e a ficha especifica o que o asset real precisa ser.