Um arquivo .html explica melhor que um .md
Quando a explicação tem mais de um eixo, Markdown vira rolagem infinita. Aba é o índice que não sai da tela — e o arquivo continua sendo um só, que abre com duplo clique.
#hash na URL, que muda quando você troca de aba.
O documento inteiro cabe nesta estrutura
Três blocos de CDN no <head>, o conteúdo em abas, dois <script>
no fim. Nada além disso — sem package.json, sem pasta assets/.
<!doctype html>
<html lang="pt-BR" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark">
<link rel="stylesheet" href="…/bootstrap@5.3.8/dist/css/bootstrap.min.css"
integrity="sha384-…" crossorigin="anonymous">
</head>
<body>
<ul class="nav nav-tabs" role="tablist">…</ul>
<div class="tab-content">…</div>
<script src="…/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-…" crossorigin="anonymous"></script>
</body>
</html>
Duas coisas no <html> fazem o tema escuro inteiro:
| Atributo | O que faz |
|---|---|
data-bs-theme="dark" |
Troca todas as variáveis de cor do Bootstrap. Nenhum CSS seu é necessário. |
<meta name="color-scheme" content="dark"> |
Vale antes do CSS carregar: mata o flash branco e deixa scrollbar,
<select> e <input> nativos escuros. |
Gerando pela skill
O script monta a casca com as abas já pareadas — que é onde o erro costuma nascer:
node ~/.claude/skills/html-explainer/scripts/new-doc.mjs \
"Como o cache invalida" ./cache.html \
--tabs "Resposta,Como funciona,Armadilhas" --sub "v3 · jul/2026"
# preencha o conteúdo, depois valide:
node ~/.claude/skills/html-explainer/scripts/check-doc.mjs ./cache.html
O linter reprova o que o olho não vê. Numa página propositalmente quebrada:
erro doc.html:10 aria-controls="#p1" deveria ser "p1" (sem #)
erro doc.html:14 o painel ativo #p1 tem .active mas não .show — abre invisível (opacity 0)
erro doc.html:15 HTML não escapado dentro de <code> — escape & < > como & < >
erro doc.html:4 URL de CDN sem versão — amanhã ela aponta para outro código
radio e
checkbox. Documento normal não tem construtor;
o
exemplo publicado mostra um funcionando.
Aba é eixo paralelo, não passo a passo
As fatias respondem à mesma pergunta de ângulos diferentes e o leitor escolhe uma.
Se ele precisa das três em ordem, não é aba — é seção com <h2>.
Eixo paralelo
curl · Python · TypeScript
Como era · Como fica · Como migrar
Sequência
Passo 1 · Passo 2 · Passo 3
Isso é rolagem. Ninguém quer clicar para continuar lendo.
Os cinco elos do markup
Quebrar qualquer um deixa a aba muda ou abre o painel errado:
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-a" data-bs-toggle="tab" data-bs-target="#pane-a"
type="button" role="tab" aria-controls="pane-a" aria-selected="true">Rótulo A</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="pane-a" role="tabpanel"
aria-labelledby="tab-a" tabindex="0">…</div>
</div>
role="tablist"no<ul>,role="presentation"no<li>.role="tab"no gatilho,role="tabpanel"no painel.data-bs-target="#pane-a"com#↔aria-controls="pane-a"sem#.iddo gatilho ↔aria-labelledbydo painel.- Um gatilho
.activee um painel.show.active. Só um.
fade exige show e active no painel inicial.
Só active renderiza com opacity: 0 — a aba abre vazia e parece
conteúdo faltando.
Teclado: já vem pronto
Do Bootstrap 5.2 em diante o tab.js implementa a navegação ARIA no
role="tablist": ← → andam entre as abas,
Home e End vão para a primeira e a última. Não escreva
keydown próprio — você só vai brigar com o que já existe.
As abas não ativas ficam com tabindex="-1", então Tab pula direto da
aba ativa para o conteúdo. É o padrão, não um bug.
Aba na URL
Olhe a barra de endereço: o #hash mudou quando você abriu esta aba. Mandar
"veja a aba Armadilhas" vira mandar um link. Os dois sentidos, com o detalhe que
importa:
// escrever: replaceState, nunca location.hash — atribuir o hash faz a página pular
history.replaceState(null, '', '#' + id);
// ler: o hash pode apontar para o painel OU para um <h2> dentro dele
const pane = document.querySelector(hash)?.closest('.tab-pane');
bootstrap.Tab.getOrCreateInstance(trigger).show();
getOrCreateInstance e não new bootstrap.Tab(el): uma segunda
instância no mesmo elemento gera dois listeners, e o shown.bs.tab dispara em dobro.
Duas regras sem exceção
1. Declare a linguagem
A auto-detecção do highlight.js decide por estatística sobre o texto do bloco. Em trechos curtos ela erra — e erra diferente em cada bloco, então o mesmo snippet sai roxo numa aba e verde na outra.
<pre><code class="language-typescript">const x: number = 1;</code></pre>
2. Escape &, < e >
Nessa ordem — & primeiro, senão você transforma < em
&lt;. Um < cru faz o navegador abrir uma tag ali: o resto
do bloco some e o layout se desfaz em silêncio.
node ~/.claude/skills/html-explainer/scripts/escape-code.mjs src/auth.ts --lines 40-58
Espaço em branco vaza
Dentro de <pre> todo caractere conta. Compare:
<pre>
<code class="language-js">
const x = 1;
</code>
</pre>
<pre><code class="language-js">const x = 1;</code></pre>
O botão de copiar
Passe o mouse em qualquer bloco desta página: ele está no canto. O que o torna confiável são três detalhes.
// 1. o texto cru é capturado ANTES do highlight — depois, um plugin de número
// de linha faria a pessoa colar "1 const x = 1;"
const sources = new WeakMap();
document.querySelectorAll('pre > code').forEach(c => sources.set(c, c.textContent.replace(/\n$/, '')));
hljs.highlightAll();
// 2. testar navigator.clipboard antes de usar: fora de contexto seguro ele é
// undefined, e a chamada estoura TypeError — exceção síncrona, que .catch() não pega
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(ok, () => legacyCopy(text)); // 3. e rejeita
} else { // se a aba não
legacyCopy(text); // estiver focada
}
O legacyCopy usa document.execCommand('copy'). É obsoleto, continua
implementado em todos os navegadores, e é o único caminho que funciona em
http://192.168.0.10/doc.html — onde a Clipboard API simplesmente não existe.
function legacyCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
// position:fixed (não absolute) impede a página de rolar até o textarea invisível
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length); // iOS ignora select() sozinho
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok; // só funciona DENTRO do handler de um gesto do usuário
}
Receitas
Diff — o highlight.js colore + e - sozinho:
- history.pushState(null, '', '#' + id);
+ history.replaceState(null, '', '#' + id);
Terminal — nohighlight impede que o highlight.js
reescreva o bloco e apague o <span> do prompt:
$ node scripts/check-doc.mjs doc.html
✓ doc.html — sem problemas
Nome do arquivo em cima — contexto sem gastar uma frase:
export const verify = (t: string) => jwt.verify(t, SECRET);
Procure aqui antes de escrever CSS
Todos funcionam com data-bs-theme="dark" sem um byte de ajuste.
| Quero… | Use | Não use |
|---|---|---|
| Destacar um aviso | alert | negrito e cor na mão |
| Detalhe que nem todo mundo lê | accordion | outra aba |
| Esconder um trecho curto | collapse | accordion de um item |
| Comparar valores | table | duas colunas de texto |
| Etiquetar versão/estado | badge | parênteses no texto |
| Tecla do teclado | <kbd> | código |
Alerts: quatro, e só quatro
Três alerts seguidos não destacam nada: se tudo é importante, nada é. (Estes quatro estão aqui como catálogo — num documento de verdade, escolha um.)
Accordion — o que é opcional
data-bs-parent. Em FAQ que a pessoa vai comparar, tirar é melhor.
Tokens em vez de hex
Cor cravada é o que faz o documento parecer dois documentos:
| Em vez de | Use |
|---|---|
style="color: #aaa" | class="text-body-secondary" |
style="background: #1a1a1a" | class="bg-body-tertiary" |
style="border: 1px solid #333" | class="border" |
style="margin-bottom: 1rem" | class="mb-3" |
style="display:flex; gap:.5rem" | class="d-flex gap-2" |
Diagrama
Este está numa aba escondida — que é exatamente onde diagrama costuma quebrar. Ele só é renderizado quando você abre a aba; o porquê está na aba Armadilhas.
graph LR
A[Markdown] -->|mais de um eixo| B{rolagem infinita}
C[HTML em abas] -->|indice fixo| D[o leitor escolhe]
B --> E[procurando]
D --> F[achou]
Mermaid é opcional e entra por mais uma tag de CDN. Use quando a relação entre as coisas for o ponto — não para enfeitar.
Quatro que custam caro
1. Aba escondida é display: none
Dentro dela, getBoundingClientRect() e getBBox() devolvem zero.
Qualquer biblioteca que meça para desenhar produz lixo — e você só descobre
quando alguém clica na aba. Medido com Mermaid 11.16, dois diagramas idênticos:
| Painel | viewBox do SVG | |
|---|---|---|
| visível | 0 0 340.45 70 | correto |
| escondido | -8 -8 16 16 | caixa de 16×16 |
O diagrama existe — tem os nós, tem os textos — dentro de uma caixa minúscula. Abrir a aba não conserta: o SVG já nasceu errado. A correção é renderizar quando a aba aparece:
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
mermaid.run({ nodes: document.querySelectorAll('.tab-pane.active .mermaid') });
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t => {
t.addEventListener('shown.bs.tab', e => {
const pane = document.querySelector(e.target.getAttribute('data-bs-target'));
const todo = pane.querySelectorAll('.mermaid:not([data-processed])');
if (todo.length) mermaid.run({ nodes: todo }); // :not([data-processed]) = não repete
});
});
Com isso, o mesmo painel escondido passa a viewBox="0 0 332.75 70" — medido. É o
código que está rodando nesta página. Para Chart.js o equivalente é chart.resize()
no shown.bs.tab.
shown.bs.tab" de tutorial — chamar highlightElement duas vezes
gera <span> dentro de <span>.
2. integrity sem crossorigin bloqueia
Não é aviso, é bloqueio. A verificação de SRI precisa de resposta CORS legível; sem
crossorigin="anonymous" a resposta é opaca, o navegador não consegue conferir e
descarta o arquivo. O sintoma é a página sem estilo nenhum e um erro de CORS
que não menciona integrity.
3. Versão flutuante + SRI = bomba-relógio
bootstrap@5 e @latest resolvem para o patch mais novo. No dia do
release o conteúdo muda, o hash não bate, e um documento que funcionava havia meses abre em
branco. Trave 5.3.8. É este o motivo real da regra — não é purismo.
4. A impressão sai com uma aba só
Óbvio depois: o que está em display: none não vai para o papel nem para o PDF.
Sem @media print, exportar um documento de 5 abas produz um PDF de 1 aba — e
ninguém percebe até o cliente reclamar.
@media print {
.nav-tabs, .copy-btn { display: none !important; }
.tab-content > .tab-pane { display: block !important; opacity: 1 !important; page-break-inside: avoid; }
.tab-pane::before { content: attr(data-print-title); display: block; font-weight: 600; }
}
Teste agora: Ctrl + P. As cinco abas aparecem empilhadas, cada uma com seu título.
Parece problema e não é
-
file://é contexto seguro no Chromium —window.isSecureContext === trueenavigator.clipboardexiste (verificado). O fallback do botão continua necessário por causa dehttp://em IP de rede, não dofile://. -
Abas inativas com
tabindex="-1"é o padrão ARIA: Tab deve pular da aba ativa direto para o conteúdo. -
O documento depende da rede. É o preço de não ter build. Para uso offline
real, embuta os
.min.css/.min.jsdentro do arquivo — e aí remova osintegrity, que só fazem sentido em recurso externo.