AO·dev
institutionalFreelapúblico
Ver como Markdown (abre em nova aba)

Leve Mix

Site institucional de uma indústria de terceirização de alimentos (private label) do Grupo Romanus — 100% estático, zero framework, CMS próprio em JSON, sistema de theming por cor, Web Components nativos e PWA de verdade.

Empresa

Freela

Período

2025 – 2026

Papel

Frontend Developer

A Leve Mix é uma indústria de terceirização (outsourcing) de produção e envase de alimentos — café, chocolate em pó, colágeno, chá, tempero, farináceo — pra quem quer lançar marca própria sem construir fábrica do zero. Faz parte do Grupo Romanus, com mais de 26 anos de mercado, sediada em Campo Largo, no Paraná. Peguei esse projeto como freela, em parceria com a al2.studio — eles fizeram a ponte entre a equipe técnica e o cliente, papel parecido com o de um PO. É o projeto mais completo que já documentei aqui: zero framework, zero dependência de build de terceiro, um CMS que eu inventei do zero em JavaScript puro, e um sistema de theming dinâmico por cor que a maioria de quem visita o site nunca vai perceber que existe.

Home da Leve Mix, com hero em vídeo e seção de números de impacto
Home da Leve Mix, com hero em vídeo e seção de números de impacto

Por baixo do capô

CamadaTecnologia
EstruturaHTML5 semântico, uma página estática por rota
EstiloCSS3 — neomorfismo, variáveis CSS, Grid/Flexbox
InteratividadeJavaScript Vanilla, Web Components nativos
Carrossel/LightboxSwiper.js
Offline/PWAService Worker API
HostingVercel, sem build step remoto

Sem Nuxt, sem React, sem Vue, sem bundler. npm run build só roda um script Node local (build.js) que gera cache-busting e injeta imagens antes do commit — o deploy em si é puramente estático.

De CMS puro pra abordagem híbrida

A primeira versão do site renderizava as páginas de produto inteiramente via JavaScript: um Web Component lia o slug da URL, buscava o JSON correspondente e montava o DOM em runtime. Funcionava, mas o Google via uma página quase vazia até o JS rodar — ruim pra SEO, ruim pra Core Web Vitals.

A decisão que documentei num arquivo interno na época (ABORDAGEM-HIBRIDA.md) foi migrar pra um modelo híbrido: HTML estático com todo o SEO (title, meta tags, Open Graph, canonical) já escrito no arquivo, e o Web Component só preenche o conteúdo dinâmico por cima.

AspectoCMS puro (antes)Abordagem híbrida (depois)
SEOMeta tags via JS — piorMeta tags no HTML — indexação perfeita
PerformanceJS necessário pra renderizarHTML já visível no primeiro paint
ManutençãoSó editar JSONHTML + JSON — um pouco mais de trabalho
EscalabilidadeTrivialPrecisa de um template HTML por página nova

Trocar facilidade de manutenção por SEO/performance valeu a pena — cada página de produto tem sua própria URL, título e Open Graph reais, sem depender de nenhum framework de SSR pra isso.

O CMS: dados em JSON, motor em JS vanilla

Cada produto e cada embalagem vira um arquivo JSON dentro de CMS/slugs/{producao|embalagens}/{slug}.json. Um único Web Component genérico, o SlugPage, lê esse arquivo pelo slug da URL e preenche o template — o mesmo componente atende produção e embalagens, só muda a category detectada na URL. Exemplo real, o JSON do produto "Chás":

{
  "id": "chas",
  "slug": "chas",
  "category": "producao",
  "meta": {
    "title": "Chás Solúveis e a Granel | LeveMix",
    "description": "Terceirização de produção de chás solúveis e a granel...",
    "keywords": "chá solúvel, chá a granel, chá matcha, chás funcionais",
    "ogImage": "/assets/images/produtos/chas-01.webp"
  },
  "title": "Chás Solúveis e a Granel",
  "descriptionShort": "Soluções para o mercado de produtos naturais e saudáveis",
  "variants": [
    { "title": "Chás Solúveis", "description": "Chás em formato solúvel, práticos e convenientes." },
    { "title": "Chás a Granel", "description": "Chás em formato a granel para embalagem personalizada." }
  ],
  "benefits": [
    "Formatos solúveis e a granel",
    "Chás funcionais disponíveis",
    "Produção em escala"
  ]
}

Foi esse sistema — um CMS caseiro movido a JSON, sem banco de dados, sem painel administrativo — que me deu a ideia de como eu queria estruturar conteúdo dinâmico no meu próprio portfólio, o ao.dev. Lá eu uso o @nuxt/content de verdade em vez de reinventar a roda, mas a ideia raiz — conteúdo como dado versionado no repositório, não escondido atrás de um CMS externo — nasceu aqui.

CMS Loader: cache, busca em tempo real e slider, tudo do mesmo JSON

O cms-loader.js é a classe central que qualquer componente usa pra falar com o CMS — nunca um fetch solto espalhado pelo código:

// Carregar um item específico
const item = await window.cmsLoader.loadItem('producao', 'chocolate')

// Carregar todos os itens de uma categoria (pra busca e sliders)
const allItems = await window.cmsLoader.loadAllItems()

// Buscar por termo
const results = await window.cmsLoader.search('chocolate')

O mesmo JSON de produto alimenta três coisas diferentes, sem duplicar dado em lugar nenhum:

  • O slider da home (<swiper-showcase category="producao" exclude-slug="chocolate" color-scheme="secondary">) — busca todos os itens da categoria, exclui o item atual quando necessário, renderiza os cards
  • A busca em tempo real — o loader concatena título, descrição curta, descrição longa, meta description, keywords, benefícios e nome/descrição de cada variante num único texto pesquisável, e filtra conforme o usuário digita, sem round-trip nenhum pro servidor
  • A página do slug — o SlugPage component já coberto acima

Uma limitação real que ficou registrada no próprio código: pra um slug novo aparecer na busca e nos sliders, o slug precisa entrar manualmente numa lista conhecida dentro do cms-loader.js (getKnownSlugs()) — o carregamento não descobre arquivos novos sozinho, porque não existe listagem de diretório em produção estática. Passo simples, mas é o tipo de coisa que só descobre na prática, não lendo a documentação de longe.

Config idempotente: de .env pra atributo data-bind no HTML

Dado sensível de contato (telefone, e-mail, redes sociais) não podia ficar hardcoded no HTML nem espalhado em cada Web Component. A solução: um .env na raiz, processado por build-config.js durante o build, que gera js/app-conf.js (esse sim gitignored) expondo tudo em window.__LEVE_CTX. Um segundo script, dom-binder.js, injeta esses valores direto no HTML através de atributos customizados:

<!-- Texto simples -->
<span data-bind="CONTACT_PHONE_FORMATTED"></span>

<!-- Link com template -->
<a data-bind-href="tel:+55{CONTACT_PHONE}">Ligar</a>

<!-- Até dentro de JSON-LD -->
<script type="application/ld+json" data-bind-json>
{ "name": "{COMPANY_NAME}", "url": "{BASE_URL}" }
</script>

O processo é idempotente de propósito: rodar o build várias vezes seguidas nunca corrompe o .env fonte nem duplica binding — app-conf.js é sempre regenerado do zero a partir da mesma fonte de verdade. Cada Web Component (como o footer, já mostrado abaixo) ainda mantém um fallback hardcoded pros mesmos valores, então nada quebra visualmente se a env var não for injetada por algum motivo.

Um sistema de cor por slug que ficou pronto e nunca foi usado

Por padrão, cada categoria de página tem sua própria cor de destaque, com os tokens de design já definidos globalmente:

--primary-color: #792a47;          /* vinho — cor principal, Embalagens */
--primary-color-emphasis: #f5ebe8;
--secondary-color: #5a7c2e;        /* verde — cor secundária, Produção */
--secondary-color-emphasis: #e6f2d3;

Isso já resolvia o caso de uso real. Mas construí um passo a mais: qualquer JSON de slug pode declarar um campo color com um hex customizado — três formatos aceitos (#FF6B35 de 6 dígitos, #F6B de 3 dígitos que expande automaticamente; rgb() e hex sem # são rejeitados) — e a página inteira se retema em cima dele, sem precisar escrever uma linha de CSS nova. A cor customizada se propaga por oito áreas diferentes: hero, cards de variante, seção de benefícios, CTA, swiper da galeria, footer (só em páginas slug), título da seção "Outros Produtos"/"Outras Embalagens" e o próprio swiper-showcase.

applyCustomColor(color) {
  if (!/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color.trim())) {
    console.warn('Invalid color format. Expected hex color (e.g., #792a47)')
    return
  }

  const [r, g, b] = hexToRgb(color)
  // Versão clara pra hover/ênfase: 95% branco + 5% da cor original
  const emphasis = blend([r, g, b], [255, 255, 255], 0.95)

  const style = document.createElement('style')
  style.textContent = `
    :root {
      --custom-color: ${color};
      --custom-color-emphasis: rgb(${emphasis.join(',')});
    }
    .slug-hero-custom { background: radial-gradient(var(--custom-color), black) !important; }
    .slug-variant-card-custom h3 { color: var(--custom-color) !important; }
    .slug-cta-custom .btn-primary { border-color: var(--custom-color) !important; }
    /* + uma dezena de seletores de swiper, benefícios e CTA */
  `
  document.head.appendChild(style)
}

A função valida o hex por regex, converte pra RGB, calcula uma versão clara pra usar em hover/backgrounds, e injeta um <style> no <head> sobrescrevendo mais de dez seletores — hero, cards de variante, botões do carrossel, paginação, lista de benefícios, CTA — tudo derivado de um único valor. Nenhum produto ou embalagem real usa esse campo hoje: o cliente nunca pediu cor customizada por item, então ficou tudo no padrão verde/vinho por categoria. Mas o código está lá, testado e funcional, esperando o dia que alguém preencha um "color": "#..." num JSON novo.

Web Components sem framework

Cada peça reutilizável do site (header, footer, carrossel, slider de depoimentos) é um HTMLElement nativo registrado via customElements.define, sem Vue nem React por trás. O footer, por exemplo:

class LeveFooter extends HTMLElement {
  constructor() {
    super()
  }

  connectedCallback() {
    const colorScheme = this.detectColorScheme()

    // Lê variáveis de ambiente injetadas no build, com fallback hardcoded
    const E = window.__LEVE_CTX || {}
    const phone = E.CONTACT_PHONE || '41992694691'
    const email = E.CONTACT_EMAIL || 'comercial@levemix.ind.br'

    this.innerHTML = `
      <footer class="footer ${colorScheme}">
        <!-- ... -->
      </footer>
    `
  }
}

customElements.define('leve-footer', LeveFooter)

window.__LEVE_CTX é exatamente o objeto gerado pelo pipeline .envapp-conf.js explicado acima — o fallback hardcoded garante que o footer nunca quebra visualmente mesmo se essa injeção falhar por qualquer motivo.

Build: cache-busting via timestamp, em paralelo

Sem framework, cache-busting de CSS/JS não vem de graça — tive que construir na mão. build.js orquestra três scripts:

  • Renomeia cada arquivo CSS/JS com um timestamp no nome (ex: home-27-02-2026-17-44-10.css) e atualiza as referências em todos os HTMLs
  • build-images.js processa CMS/images.json — a fonte única de verdade pra imagem gerenciada por ID (IMG-01, IMG-02...) — e injeta src com ?v=timestamp, alt, width/height em cada <img data-image-id="IMG-XX">
  • build-images-worker.js faz esse processamento em paralelo, uma página por Worker Thread — sem isso, processar todas as páginas HTML sequencialmente ficava perceptivelmente mais lento a cada nova página adicionada

O bug de cache que exigiu cirurgia no vercel.json

Depois de um dos primeiros deploys, o cliente reportou não ver as mudanças mesmo com o novo código já publicado. Causa raiz: os headers de cache originais aplicavam max-age=31536000, immutable (um ano, "nunca muda") em regras amplas demais — inclusive pegando HTML e os arquivos JS/CSS principais, que mudam a cada deploy mesmo sem cache-busting no nome do arquivo raiz.

A correção foi separar regras por tipo de arquivo: HTML e os poucos arquivos sem hash no nome recebem revalidação agressiva; assets versionados (/assets/*, /icons/*, com hash de timestamp) mantêm o cache de um ano com segurança, porque o nome muda a cada build.

{
  "headers": [
    // HTML sempre revalida — nunca fica preso em cache velho
    {
      "source": "/(.*)\\.(html)",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }]
    },
    // Assets com timestamp no nome — cache de 1 ano é seguro
    {
      "source": "/assets/(.*)",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
    }
  ]
}

A regra que resolveu tudo: max-age=0, must-revalidate obriga o navegador a sempre perguntar pro servidor se há versão nova antes de usar o cache local — sem isso, immutable genérico demais deixa o cliente preso na versão antiga indefinidamente.

PWA de verdade

manifest.json e sw.js (Service Worker) estão no ar e respondem 200 — não é enfeite, o site funciona como Progressive Web App instalável, com cache offline dos assets estáticos. Único detalhe técnico chato: Service Worker não registra em file://, só via servidor HTTP real — documentei isso num TESTE-LOCAL.md interno depois de mais de uma vez esquecer e testar direto do disco.

SEO e descoberta por IA

Junto do sitemap.xml e robots.txt, o site tem um llm.txt na raiz — mesma ideia do /llms.txt que o próprio ao.dev expõe hoje (via nuxt-llms), só que aqui é o próprio build.js que regenera os três arquivos a cada build, junto com a lista dinâmica de slugs do CMS Loader. Dado estruturado via JSON-LD cobre Organization e LocalBusiness. Cada página de produto/embalagem tem título, descrição e Open Graph únicos — consequência direta de ter migrado pra HTML estático real.

Documentação pensada pro cliente, não pro dev

Um dos artefatos que mais me orgulho desse projeto nem é código: é um formulário em Google Docs — formulario-adicionar-novos-itens.md — que ensina alguém sem nenhum conhecimento técnico a descrever um produto novo (nome, descrição, imagens, características, palavras-chave de SEO) de um jeito que eu consigo transformar direto num JSON novo do CMS. A seção técnica do formulário (slug, categoria, caminho do arquivo) vem preenchida como "isso aqui é com a gente, você não precisa se preocupar".

Também existe um documento interno só de guarda-corpo (produtos-e-servicos-descontinuados.md): uma lista do que a empresa não trabalha mais, pra eu nunca reintroduzir por engano algo removido a pedido do cliente numa rodada anterior de revisão — proteção contra regressão de conteúdo, não só de código.

Deploy

O deploy roda 100% na Vercel, sem build remoto — Output Directory aponta pra raiz do repositório (.), porque o HTML já sai pronto do build.js local. A parte mais chata do projeto não foi técnica: foi a burocracia de acesso a domínio e certificado SSL do lado do cliente, resolvida diretamente com o time de TI deles. Levou mais tempo do que o esperado, mas os dois sites do grupo (Leve Mix e o institucional da Romanus) terminaram no ar, validados e com certificado correto.

Sobre

Página Sobre, com Missão, Visão, Valores e o compromisso com segurança alimentar
Página Sobre, com Missão, Visão, Valores e o compromisso com segurança alimentar

A missão declarada no site resume bem o negócio:

Ser a solução de terceirização completa e confiável para o desenvolvimento, produção e embalagem de marcas próprias no mercado de alimentos, simplificando o caminho do empreendedor para lançar produtos de alta qualidade com agilidade e custos otimizados.

Logo abaixo, a seção "Compromisso com Segurança e Qualidade Alimentar" conta que a operação roda em duas plantas independentes, com sistemas de controle de contaminação cruzada — informação que entrou nessa versão a pedido explícito do cliente, numa rodada de revisão específica pra deixar claro que não é produção genérica misturada. A seção de Valores lista itens como "Excelência: qualidade premium em cada produto" e "Parceria: seu sucesso é o nosso sucesso", cada um com ícone Lucide próprio. Fecha com o carrossel "O que você vai produzir com a gente" — a mesma lista de produtos que também vira página própria em Produção.

Produção

Diferente de Embalagens, Produção não tem uma página de listagem própria — cada linha de produto é só um slug direto (/producao/{slug}/), descoberto pelo carrossel "O que você vai produzir com a gente" (lá na página Sobre) ou pela busca em tempo real do CMS Loader. No momento em que escrevo isso, o catálogo cobre linhas como cafés e cappuccinos, chocolate em pó, colágenos, chás, farinhas e temperos — mas como cada uma é um JSON isolado, esse catálogo muda com o tempo: itens novos entram, outros podem sair, sem precisar de deploy de código nenhum. "Líquidos", por exemplo, foi a última linha adicionada nesse projeto, numa rodada de revisão já perto do fim — subiu primeiro com imagem e texto placeholder (lorem ipsum mesmo) enquanto o material final não chegava, e só depois foi preenchido com dado real. É o tipo de coisa que só um CMS separado do HTML permite fazer sem gambiarra: a página existe, está no ar, indexável, e o conteúdo evolui por baixo sem precisar de nada novo em código.

Um slug de produto por dentro

Exemplo de página de produto, com variantes, vantagens e CTA
Exemplo de página de produto, com variantes, vantagens e CTA

Toda página de produto segue a mesma anatomia, gerada pelo mesmo SlugPage, nessa ordem: hero (título, foto, subtítulo, verde por ser categoria Produção), parágrafo de descrição completa, cards de variante (quando o item tem mais de uma versão), uma lista de "Vantagens" num grid de 2 colunas, CTA "Fale com um Especialista", e um swiper "Outros Produtos" no fim, puxando os demais slugs da categoria via cmsLoader.getItemsForSlider(). O exemplo no print acima é a página de Chocolate em Pó, capturada num momento específico do catálogo — o conteúdo exato pode mudar, a estrutura por trás, não.

Embalagens

Página de Embalagens com o carrossel de formatos disponíveis
Página de Embalagens com o carrossel de formatos disponíveis

Diferente de Produção, Embalagens tem sua própria página de listagem — /embalagens/, com carrossel dos formatos disponíveis, presente no menu e no rodapé. Cada formato individual segue a mesma estrutura de slug (/embalagens/{slug}/), mesmo Web Component SlugPage, mesmo schema de JSON — só a category na URL muda de producao pra embalagens. Reaproveitar o mesmo componente genérico pras duas seções, em vez de duplicar a lógica de "página de produto" e "página de embalagem" como no início do projeto, foi uma das decisões que mais economizou trabalho de manutenção lá na frente: um bug corrigido ou uma feature nova no SlugPage beneficia todas as páginas de uma vez, produto ou embalagem.

Um slug de embalagem por dentro

Exemplo de página de embalagem, em vinho, mesma estrutura da página de produto
Exemplo de página de embalagem, em vinho, mesma estrutura da página de produto

Mesma anatomia da página de produto, ponto por ponto — só que em vinho, a cor padrão de Embalagens. O exemplo acima é a página de Stand Up Pouch, com suas próprias variantes por tamanho e vantagens específicas do formato, seguida do mesmo carrossel de "Outras Embalagens" no fim. Ver as duas páginas lado a lado é o jeito mais direto de mostrar por que valeu a pena ter um único componente genérico: zero diferença estrutural entre elas, só o dado (que pode mudar) e a cor mudam.

Nossos Serviços

Página Nossos Serviços, com o accordion das oito etapas do outsourcing
Página Nossos Serviços, com o accordion das oito etapas do outsourcing

Um accordion com oito etapas do processo de outsourcing, cada uma com ícone Lucide próprio:

  1. Mistura, envase e rotulação — "Nossa infraestrutura completa garante a mistura precisa das formulações em pó — cafés, achocolatados, temperos e mixes diversos — seguida pelo envase e rotulação eficientes."
  2. Desenvolvimento de novos produtos
  3. Desenvolvimento de embalagens
  4. Gestão de fornecedores
  5. Produção convencional
  6. Produção livre de alergênicos
  7. Consultoria de viabilidade
  8. HUB Logístico

Os ícones desses oito itens passaram por uma revisão no meio do projeto: começaram como imagens placeholder 80×80, depois viraram ícones Lucide reais (package, flask-conical, box, users, factory, shield-check, trending-up, truck) — mais leve, mais nítido em qualquer resolução, e sem depender de nenhuma imagem exportada pelo design.

Contato

Página de contato, com formulário integrado via Google Apps Script
Página de contato, com formulário integrado via Google Apps Script

O formulário pede empresa, nome, e-mail, telefone e uma descrição livre da ideia ou produto — sem excesso de campo pra não afugentar quem só quer mandar uma mensagem rápida. Tem um campo invisível a mais, website_url, fora do fluxo visual (tabindex="-1", autocomplete="off") — um honeypot clássico anti-spam: bot de formulário costuma preencher todo campo que encontra no DOM, humano nunca vê esse campo pra preencher. Se ele chegar preenchido no submit, a entrada é descartada como spam sem incomodar ninguém.

O envio em si passa por um Google Apps Script publicado como Web App — sem backend próprio, sem servidor de e-mail pra manter, só um script rodando na conta Google do cliente. A página fecha com um mapa da localização real (Campo Largo, PR) e a seção de depoimentos de clientes.

O que ficou pro ao.dev

Esse projeto rodou pouco antes de eu recomeçar o ao.dev do zero, e algumas lições vieram diretas de erro cometido aqui:

  • O bug de Cache-Control genérico demais neste projeto é o motivo de eu já ter chegado no ao.dev sabendo exatamente que assets com hash no nome usam immutable e tudo o resto revalida — documentado desde o início em vez de descoberto depois de um deploy quebrado
  • O CMS caseiro em JSON, sem painel, sem banco — a ideia raiz de "conteúdo é dado versionado no repositório" — é literalmente por que optei pelo @nuxt/content no ao.dev em vez de um CMS headless externo
  • Ter automatizado a geração de llm.txt aqui — build.js regenerando o arquivo a cada deploy em vez de mantê-lo estático na mão — é a mesma lógica por trás de eu ter ido atrás do nuxt-llms no ao.dev assim que soube que esse padrão existia como módulo pronto

Grupo Romanus e o portfólio completo

A Leve Mix oferece terceirização completa — desenvolvimento de fórmula, produção em escala e embalagem final com a marca do cliente — em cinco frentes principais:

  1. Bebidas quentes: cafés e cappuccinos (tradicionais e funcionais, como café verde e erva-mate) e chocolate em pó com cacau certificado Rainforest Alliance
  2. Suplementos e colágenos, incluindo a linha Verisol — peptídeos bioativos de colágeno patenteados
  3. Chás solúveis e a granel
  4. Farináceos, como o mix pra bolo de caneca proteico (sem glúten, sem lactose)
  5. Temperos e condimentos, tradicionais e funcionais

Hoje o site mostra mais de 200 soluções desenvolvidas, mais de 50 clientes ativos, 10 anos da Leve Mix e 26 anos do Grupo Romanus — números que o próprio cliente validou antes da publicação.