ao.dev
O portfólio que você está navegando agora — boilerplate de referência do ecossistema Nuxt 4, SSG-first, cinco morfismos de design trocáveis em tempo real, i18n completo e auditoria de performance/acessibilidade documentada passo a passo.
Este é o único case study do portfólio que descreve o próprio portfólio. ao.dev nasceu com uma intenção dupla: ser minha vitrine profissional e, ao mesmo tempo, servir como boilerplate de referência do ecossistema Nuxt 4 — um projeto real, gerado e não abandonado no meio, que qualquer pessoa possa ler de ponta a ponta e aprender algo genuíno sobre SSG, i18n, design systems trocáveis e auditoria de performance levada a sério.
Todo componente visual daqui vem do fui-content, a lib de temas que construí em paralelo. Todo case study que você já leu neste site — Leve Mix, Grupo Clamed, os módulos do Grupo 3C — foi escrito, revisado e publicado através do próprio Nuxt Content que este projeto usa. É o projeto que fecha o círculo: a ferramenta e a vitrine da ferramenta são a mesma coisa.
Por baixo do capô
| Camada | Tecnologia |
|---|---|
| Framework | Nuxt 4 (compatibilityVersion: 4, diretório app/) |
| Linguagem | TypeScript |
| Conteúdo | @nuxt/content — Markdown com frontmatter tipado via Zod |
| i18n | @nuxtjs/i18n — pt-br (default) e en-us, estratégia prefix_except_default |
| SEO | @nuxtjs/seo — schema.org, OG image, sitemap, robots |
| PWA | @vite-pwa/nuxt |
| Estado | Pinia |
| Estilos | fui-content — SCSS com 5 morfismos trocáveis |
| Infra dev | Docker + Traefik, HTTPS local via mkcert |
| AI discoverability | nuxt-llms — /llms.txt gerado do conteúdo real |
O projeto roda em cima de nuxt generate (SSG) — o HTML de cada página é gerado uma única vez, em build time. Essa decisão de arquitetura, tomada logo no início, é a raiz de boa parte das decisões técnicas mais interessantes do projeto: tudo que normalmente seria "decisão em runtime" (breakpoint, tema, idioma) precisa ser resolvido sem um servidor por trás re-renderizando a cada request.
i18n — dois campos, dois formatos, uma convenção
O detalhe mais fácil de errar em i18n multi-locale é misturar dois conceitos que parecem a mesma coisa: o prefixo de URL e a tag de idioma HTML. O projeto separa os dois explicitamente:
| Campo | Valor | Responsabilidade |
|---|---|---|
code | pt-br / en-us | Prefixo de URL — sempre lowercase (convenção HTTP, RFC 3986) |
language | pt-BR / en-US | BCP 47 — HTML lang, hreflang, meta tags |
file | pt-BR.json / en-US.json | Caminho de filesystem — não é URL, mantém uppercase |
A regra existe porque nuxt-link-checker (parte do @nuxtjs/seo) reporta no-uppercase-chars para qualquer path com letra maiúscula — trocar o code pra lowercase elimina o warning na raiz, sem precisar de skipInspections como gambiarra.
Nomes de coleção — derivados algoritmicamente, nunca por ternário
Com quatro coleções por tipo de conteúdo (blog_ptBR, blog_enUS, projects_ptBR, projects_enUS...), o padrão ingênuo — locale.value === 'en-us' ? queryCollection('blog_enUS') : queryCollection('blog_ptBR') — não escala: com um terceiro idioma vira uma cadeia de if/else espalhada por dezenas de arquivos. A solução foi um composable que deriva o sufixo algoritmicamente a partir do code:
// app/composables/useLocaleCollection.ts
// pt-br → ptBR · en-us → enUS · es-mx → esMX
// (split '-', partes após o hífen viram UPPERCASE, join sem separador)
const { blogCol, pathPrefix } = useLocaleCollection()
queryCollection(blogCol.value).all()
Adicionar um quinto idioma vira: estender uma type union num único arquivo, criar as coleções em content.config.ts, criar a pasta de conteúdo. Zero mudança nos composables de listagem ou nas páginas — nenhum novo ternário nasce.
SSG-first — nenhuma decisão de layout em JavaScript
Como o HTML é gerado uma única vez, useViewport() para decisão de layout visual é proibido no projeto: no SSR/SSG, o hook usa um valor de fallback, não o viewport real do visitante — a hidratação então descobre o viewport de verdade e troca o layout debaixo do usuário, gerando CLS garantido.
A regra prática:
- Responsividade sempre via media query CSS (
fui-col-md-6,fui-col-lg-4) — o browser aplica depois de já ter o viewport real, sem mismatch. - Contagem de itens, tamanhos e visibilidade condicionados a breakpoint nunca em JS de componente de listagem.
perPagefixo (31, hoje) em vez de variar por viewport — quem precisa de menos itens por página troca via select, não via detecção automática.- Única exceção: interação puramente client-side (hover, foco, animação) pode usar JS normalmente — não afeta o HTML já pintado.
Morfismos — o FOUC que o SSG cria sozinho
A lib fui-content foi desenhada pra instalar um morfismo por projeto — os seletores que ela emite são flat, sem prefixo (.fui-btn {}, não .fui-glass .fui-btn {}). O ao.dev quebra essa premissa de propósito: carrega os cinco temas como arquivos CSS separados e troca via <link id="fui-theme">, pra dar ao visitante o seletor de morfismo em tempo real. Isso cria dois problemas que a lib, sozinha, nunca precisaria resolver.
Problema 1 — FOUC em SSG
useCookie não tem contexto de request durante o prerender: o HTML gerado sai sem classe de morfismo e sem <link> de tema. O Vue hidrata, useMorphism lê o cookie do visitante, adiciona a classe e o link — e nesse intervalo o usuário vê um flash de estilo sem tema nenhum.
Problema 2 — bleed entre temas
Sem escopar os seletores, .fui-btn {} de um tema carregado vaza pro documento inteiro, mesmo que o morfismo ativo seja outro.
A solução — PostCSS scoping + script bloqueante
scripts/build-themes.ts roda um passo de PostCSS depois de cada compilação SCSS, prefixando todo seletor com html.{tema}:
/* antes (emitido pela lib, pensada pra single-theme) */
:root { --fui-color-primary: #7c3aed; }
.fui-btn { padding: var(--fui-space-3); }
/* depois do PostCSS scoping, por tema */
html.skeu { --fui-color-primary: #7c3aed; }
html.skeu .fui-btn { padding: var(--fui-space-3); }
E no <head>, um script inline com tagPriority: 'critical' roda antes de qualquer paint: lê o cookie via regex, valida contra a lista de morfismos conhecidos, adiciona html.{morfismo} via classList.add (idempotente — no SSR a classe já está lá) e injeta um <link rel="preload" as="style"> que vira stylesheet no onload. No SSR o servidor já renderiza a classe certa — zero FOUC automaticamente. No SSG, sem cookie no HTML, o script assume o trabalho no client, antes do primeiro paint.
Lighthouse testa sem cookies — o script é no-op, nenhum CSS de tema carrega, zero impacto em Performance.
O bug real — ordem de @layer num reload frio
Esse foi o bug mais difícil de diagnosticar do projeto inteiro. Trocar de morfismo funcionava perfeitamente client-side — setMorphism(), sem reload. Mas um reload real com o cookie já setado causava bleed visual: border-radius, padding, gap, font-size e box-shadow trocando de valor de forma inconsistente, elemento por elemento.
A causa raiz: cada arquivo de tema gerado (public/themes/{skeu,glass,neu,clay}.css) só declarava blocos soltos de @layer fui.components {} / @layer fui.utilities {} — só o bundle base declarava a ordem canônica completa (@layer fui.layout, fui.base, fui.components, fui.utilities;). Numa corrida de rede real (o script inline injeta o <link> do morfismo muito cedo), se o browser parseasse o <link> do tema antes do bundle base, a ordem global de registro dos layers saía errada — e o reset cru do base passava a vencer utilitários e componentes do morfismo.
O fix: prefixar cada arquivo de tema gerado com a mesma declaração de ordem do base, antes de qualquer seletor. Não importa qual arquivo o browser parseia primeiro — a ordem correta é estabelecida de cara por qualquer um dos dois, e a segunda declaração idêntica é um no-op. Elimina a corrida de rede estruturalmente, sem depender de timing de <link>.
Hidratação — quando o próprio módulo de SEO causava o mismatch
Outro bug que só aparecia em SSG, nunca em dev: Hydration completed but contains mismatches., sempre no mesmo lugar — um nó de texto dentro de um <style> do body.
O culpado era nuxt-seo-utils (incluído via @nuxtjs/seo), que tem minify: true por padrão. O plugin Nitro de minificação de HTML comprime todos os <style> inline durante o prerender — incluindo o <style> que o Shiki/MDC injeta no body com as CSS vars de syntax highlighting. Resultado: o HTML do servidor tinha CSS minificado, o payload Nuxt tinha CSS não-minificado (vindo direto do SQLite do @nuxt/content), e o cliente re-renderizava o MDCRenderer usando o payload — CSS diferente, mismatch garantido.
// nuxt.config.ts
seo: {
minify: { build: false }, // só desativa a minificação de body no prerender —
}, // runtime (head tags via unhead) continua ativo e não causa mismatch
Debugar esse tipo de mismatch em produção (SSG) tem uma flag pouco conhecida do Vue 3.4+ que ativa mensagens detalhadas mesmo em build de produção:
vite: {
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(true),
},
}
Auditoria de performance — números reais, não estimativa
Uma auditoria pré-deploy na home, usando Lighthouse a sério (não só rodando uma vez e aceitando o número), revelou uma série de problemas de sistema — coisas que valem pra qualquer página nova, não só a que foi testada.
Compressão que o preset de produção não fazia sozinho
compressPublicAssets é false por padrão no Nitro. Sem essa config, o preset node-server (o que roda em produção SSR) serve _nuxt/*.css/*.js/fontes sem gzip nem brotli — chegamos a medir o mesmo arquivo CSS 15× maior em SSR (1,28 MB) do que num servidor estático genérico (83 KB) servindo o mesmo build.
nitro: {
compressPublicAssets: { gzip: true, brotli: true },
},
Fetch de componente global só na interação, nunca no mount
O CommandPaletteComponent (busca via ⌘K) fica montado em app.vue em toda página, mesmo fechado. Ele chamava useAsyncData(..., { server: false, lazy: true }) — mas lazy: true só adia o render, o fetch dispara imediatamente após a hidratação de qualquer jeito. Isso instanciava o motor de busca do @nuxt/content (SQLite-WASM client-side) em toda página, mesmo que o visitante nunca abrisse a busca: medimos 1–2,4s de scripting não-atribuído no bootup e 239–404 KiB de JS não usado.
A correção: immediate: false no useAsyncData, com execute() disparado manualmente só quando o usuário efetivamente abre a busca.

Purge de CSS não usado
A lib fui-content é utility-first — gera um conjunto fixo de classes em compile-time, sem noção de quais componentes .vue deste projeto realmente usam o quê. @fullhuman/postcss-purgecss, configurado no nuxt.config.ts (nunca na lib), resolve isso como uma otimização de build equivalente ao tree-shaking de JS:
| Métrica | Antes | Depois | Redução |
|---|---|---|---|
| Tamanho bruto | 1,28 MB | 143 KB | −89% |
| Tamanho gzip | ~113 KB | ~21,6 KB | −81% |
| Classes CSS | 14.508 | 464 | — |
O risco real de um purge automático é classe montada dinamicamente — `fui-tag-${tech.color}` nunca aparece como string completa no código-fonte, então o scanner erraria e apagaria a classe. Antes de ativar, uma varredura por interpolação de template literal com prefixo fui- identificou os poucos casos reais, que entraram numa safelist por regex (/^fui-tag-/). O resultado foi validado com rebuild + screenshot + computed styles em três páginas antes de considerar fechado — nunca só "o build passou sem erro".
Prioridade de LCP por página
O elemento de LCP muda por página — na home é o primeiro card de morfismo, em outra pode ser uma imagem de capa. Só o elemento real de LCP recebe fetchpriority="high" + preload; aplicar em várias imagens dilui a prioridade e não ajuda em nada.
<NuxtImg
:preload="isLcp ? { fetchPriority: 'high' } : undefined"
:fetchpriority="isLcp ? 'high' : undefined"
...
/>
queryCollection — payload é orçamento, não detalhe
O campo body de uma coleção type: 'page' é o AST completo do Markdown — 30 a 200 KB por documento. Uma query de listagem sem .select() inclui o body de todos os posts, multiplicado por rota SSG.
// ERRADO — body de todos os posts (~770 KB por rota)
queryCollection(blogCol.value).order('date', 'DESC').all()
// CORRETO — só frontmatter (~5–20 KB)
queryCollection(blogCol.value)
.select('path', 'title', 'shortTitle', 'description', 'date', 'readTime', 'tags', 'cover')
.order('date', 'DESC')
.all()
Duas regras derivadas: indexes em content.config.ts para toda coluna usada em .where()/.order() (obrigatório em bancos como Cloudflare D1, onde cada linha lida é billing); e chaves de useAsyncData sempre delimitadas por :, nunca -, quando combinam prefixo literal com slug dinâmico — o cache de useAsyncData é global no app, e blog-${slug} colide estruturalmente entre um post "prev-foo" e outro post "foo" cuja chave também vira blog-prev-foo-.... Com : como delimitador, a colisão é impossível, porque slug de conteúdo nunca contém :.
Acessibilidade — o que o IBM Equal Access ensinou
Ciclos reais de auditoria com o IBM Equal Access Checker corrigiram padrões que pareciam corretos até serem testados de verdade:
- ARIA combobox é ARIA 1.2 —
role="combobox"vai no<input>, nunca num<div>wrapper; o input precisa sertype="text", porquetype="search"já carrega role implícitosearchboxe não aceita override. <p>com bold dentro de link dispara heurística de heading falso — IBM interpreta<p class="fui-fw-bold">dentro de<a>como possível heading mal-formado. Trocar por<span class="fui-d-block">preserva o visual sem acionar a regra, porque span é inline.aria-labelledbynunca aponta pra elemento condicional ausente — se o alvo év-if, o valor cai praundefinedquando esse elemento não existe no DOM.new Date()como fallback em template é hydration mismatch garantido — o timestamp do server nunca bate com o do client; a correção év-ifpra omitir o elemento, nunca gerar uma data "de qualquer jeito".
Nem todo achado do IBM é bug real.
aria_keyboard_handler_existsdispara porque a ferramenta não enxerga@keydowndo Vue (sóonkeydownHTML puro) — falso positivo documentado, não "corrigido" às cegas.
Descoberta por IA — /llms.txt gerado, nunca hardcoded
O site expõe /llms.txt e /llms-full.txt (padrão da comunidade) via nuxt-llms — alternativa gratuita ao equivalente pago do Nuxt SEO PRO. Cada seção do arquivo é gerada a partir de uma coleção real (blog_ptBR, projects_enUS...), nunca de um template estático — o arquivo nunca fica desatualizado porque não existe versão "escrita à mão" pra ficar velha.
A mesma lib expõe o Markdown cru de cada página de conteúdo via /raw/<path>.md — é o link "Ver como Markdown" que aparece no topo de cada post de blog e case study, inclusive neste que você está lendo agora.

O checklist de finalização — cinco fases, sempre nessa ordem
Nenhuma página nova (ou refatoração significativa) é considerada pronta sem passar, em ordem, por:
- Lint + typecheck —
lint:fix,lint:css:fix,typecheck - Auditoria SSG —
nuxt generate+ preview estático, WAVE (visual) + IBM Equal Access (relatório exportado) em PT-BR/EN-US × light/dark - Auditoria SSR —
nuxt build+nuxi preview, mesma dupla de ferramentas, mais atenção a hidratação e Schema.org no HTML inicial - Logs de build — payloads acima de 50 KB são investigados, não ignorados
- Lighthouse — a melhor nota possível em Performance, Acessibilidade, Boas Práticas e SEO, com LCP, INP e CLS dentro dos limites recomendados pelo Core Web Vitals, nas duas dimensões (desktop/mobile) × dois temas (light/dark)
O throttlingMethod: simulate padrão do Lighthouse tem variância entre execuções — a mesma página, sem nenhuma mudança de código, pode sair um pouco diferente de uma rodada pra outra, dependendo da carga da máquina no momento do teste. Persigo a nota mais alta possível em toda página; a régua real de aceite é sempre o resultado mais recente rodado nas condições descritas acima.
Só depois de todos os cinco passos passarem uma página entra em commit. A regra existe porque Core Web Vitals, neste projeto, não é bônus — é requisito, no mesmo nível que lint ou typecheck.
A listagem de projetos — o design system em produção

Essa é a página mais honesta pra ver o fui-content funcionando em escala: dezenas de cards, todos o mesmo componente (ProjectCardGridComponent), cada um puxando cover do frontmatter real de cada projeto, com busca, filtro por categoria e contagem dinâmica — toda a lógica de listagem vive nos composables (useProjectsListing), zero duplicação entre a versão grid e a versão lista.
Nav mobile e busca global
Fora do desktop, a navegação principal vira bottom tabs fixas (Início, Projetos, Buscar, Blog, Contato) mais um menu hambúrguer pro resto dos links — nunca decidido por useViewport() (pela mesma regra SSG-first já descrita), sempre por CSS puro reagindo ao viewport real do dispositivo. Isso é bem mais fácil de sentir ao vivo do que numa captura de tela redimensionada no desktop — se você está lendo isto num notebook, vale abrir ao.dev.br direto no celular pra ver a barra de tabs de verdade, no dispositivo de verdade.
O que ainda falta
Duas frentes seguem abertas, deliberadamente não resolvidas ainda:
- Migração
@nuxtjs/mdc→ Comark — o@nuxtjs/mdcestá em deprecação confirmada pelo próprio time que o mantém, em favor do Comark ("faster, AI-friendly, and no longer bound to Vue or Nuxt"). O@nuxt/contentainda depende da versão antiga; a migração real só chega via update de dependência, com guia oficial de breaking changes — não faz sentido instalar o Comark manualmente antes disso. @nuxt/a11y— removido demodules:por bugs reais documentados (conflito de versão com@nuxt/devtools-kit, erros de RPC silenciados no próprio módulo), mas mantido nopackage.json— reativação é um ciclo de investigação dedicado, não um ajuste de última hora perto de deploy.
Este case study fecha um ciclo estranho de escrever: é o único projeto deste portfólio cujo "cliente" sou eu mesmo, cujo "deploy" ainda não aconteceu no momento em que escrevo isto, e cuja auditoria de qualidade se aplica ao próprio texto que você está lendo. O primeiro post do blog e o deploy em ao.dev.br vêm logo depois deste commit.