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.

Bootstrap 5.3.8 highlight.js 11.11.1 sem npm jul/2026

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:

AtributoO 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 &amp; &lt; &gt;
erro  doc.html:4   URL de CDN sem versão — amanhã ela aponta para outro código

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>
  1. role="tablist" no <ul>, role="presentation" no <li>.
  2. role="tab" no gatilho, role="tabpanel" no painel.
  3. data-bs-target="#pane-a" com #aria-controls="pane-a" sem #.
  4. id do gatilho ↔ aria-labelledby do painel.
  5. Um gatilho .active e um painel .show.active. Só um.

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 &lt; em &amp;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:

Sai com linha em branco e indentação falsa
<pre>
  <code class="language-js">
    const x = 1;
  </code>
</pre>
Feio no fonte, certo na tela
<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);

Terminalnohighlight 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:

src/server/auth.tsTypeScript
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…UseNão use
Destacar um avisoalertnegrito e cor na mão
Detalhe que nem todo mundo lêaccordionoutra aba
Esconder um trecho curtocollapseaccordion de um item
Comparar valorestableduas colunas de texto
Etiquetar versão/estadobadgeparê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

Aba é ou isto ou aquilo — eixos paralelos entre os quais o leitor escolhe. Accordion é tudo isto, mas nem tudo agora — detalhe opcional, na ordem em que está. Não empilhe abas dentro de accordion.

Tire o 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 deUse
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:

PainelviewBox do SVG
visível0 0 340.45 70correto
escondido-8 -8 16 16caixa 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.

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 Chromiumwindow.isSecureContext === true e navigator.clipboard existe (verificado). O fallback do botão continua necessário por causa de http:// em IP de rede, não do file://.
  • 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.js dentro do arquivo — e aí remova os integrity, que só fazem sentido em recurso externo.