AO·dev
portfolioopen-sourcePessoalpúblico
Ver como Markdown (abre em nova aba)

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.

Empresa

Pessoal

Período

2026 – atual

Papel

Criador, Desenvolvedor

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ô

CamadaTecnologia
FrameworkNuxt 4 (compatibilityVersion: 4, diretório app/)
LinguagemTypeScript
Conteúdo@nuxt/content — Markdown com frontmatter tipado via Zod
i18n@nuxtjs/i18npt-br (default) e en-us, estratégia prefix_except_default
SEO@nuxtjs/seo — schema.org, OG image, sitemap, robots
PWA@vite-pwa/nuxt
EstadoPinia
Estilosfui-content — SCSS com 5 morfismos trocáveis
Infra devDocker + Traefik, HTTPS local via mkcert
AI discoverabilitynuxt-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:

CampoValorResponsabilidade
codept-br / en-usPrefixo de URL — sempre lowercase (convenção HTTP, RFC 3986)
languagept-BR / en-USBCP 47 — HTML lang, hreflang, meta tags
filept-BR.json / en-US.jsonCaminho 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.
  • perPage fixo (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.

Command Palette (⌘K) aberta, com sugestões de projetos, posts e tags puxadas do conteúdo real antes mesmo de digitar
Command Palette (⌘K) aberta, com sugestões de projetos, posts e tags puxadas do conteúdo real antes mesmo de digitar

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étricaAntesDepoisRedução
Tamanho bruto1,28 MB143 KB−89%
Tamanho gzip~113 KB~21,6 KB−81%
Classes CSS14.508464

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.2role="combobox" vai no <input>, nunca num <div> wrapper; o input precisa ser type="text", porque type="search" já carrega role implícito searchbox e 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-labelledby nunca aponta pra elemento condicional ausente — se o alvo é v-if, o valor cai pra undefined quando 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-if pra omitir o elemento, nunca gerar uma data "de qualquer jeito".

Nem todo achado do IBM é bug real. aria_keyboard_handler_exists dispara porque a ferramenta não enxerga @keydown do Vue (só onkeydown HTML 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.

Cabeçalho de um case study com o link "Ver como Markdown" visível, expondo o conteúdo real via /raw/<path>.md
Cabeçalho de um case study com o link "Ver como Markdown" visível, expondo o conteúdo real via /raw/<path>.md

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:

  1. Lint + typechecklint:fix, lint:css:fix, typecheck
  2. Auditoria SSGnuxt generate + preview estático, WAVE (visual) + IBM Equal Access (relatório exportado) em PT-BR/EN-US × light/dark
  3. Auditoria SSRnuxt build + nuxi preview, mesma dupla de ferramentas, mais atenção a hidratação e Schema.org no HTML inicial
  4. Logs de build — payloads acima de 50 KB são investigados, não ignorados
  5. 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

Listagem de projetos em grid, com filtro por categoria e vários cards usando o mesmo componente de card
Listagem de projetos em grid, com filtro por categoria e vários cards usando o mesmo componente de card

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.

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/mdcComark — o @nuxtjs/mdc está 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/content ainda 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 de modules: por bugs reais documentados (conflito de versão com @nuxt/devtools-kit, erros de RPC silenciados no próprio módulo), mas mantido no package.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.