# Leve Mix

> Institutional site for a private-label food manufacturing company under Grupo Romanus — 100% static, zero framework, a hand-built JSON CMS, native Web Components, and a real PWA.

Leve Mix is an outsourcing manufacturer for food production and packaging — coffee, powdered chocolate, collagen, tea, seasonings, flour-based mixes — for companies that want to launch a private-label product line without building a factory from scratch. It's part of Grupo Romanus, over 26 years in the market, headquartered in Campo Largo, Paraná (Brazil). I took this project as a freelancer, in partnership with [al2.studio](https://beacons.ai/al2.studio) — they bridged the technical team and the client, a role similar to a PO. It's the most complete project I've documented here: zero framework, zero third-party build dependency, a CMS I built from scratch in plain JavaScript, and a dynamic per-page color theming system most visitors will never notice exists.

<figure className="rehype-figure">

![Leve Mix home page, with a video hero and an impact-numbers section](/images/pages/projects/leve-mix/leve-mix-home.png)<figcaption>

Leve Mix home page, with a video hero and an impact-numbers section

</figcaption>
</figure>

## Under the hood

<table>
<thead>
  <tr>
    <th>
      Layer
    </th>
    
    <th>
      Technology
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Structure
    </td>
    
    <td>
      Semantic <a href="https://developer.mozilla.org/en-US/docs/Web/HTML" rel="nofollow" target="_blank">
        HTML5
      </a>
      
      , one static page per route
    </td>
  </tr>
  
  <tr>
    <td>
      Styling
    </td>
    
    <td>
      <a href="https://developer.mozilla.org/en-US/docs/Web/CSS" rel="nofollow" target="_blank">
        CSS3
      </a>
      
       — neomorphism, CSS variables, Grid/Flexbox
    </td>
  </tr>
  
  <tr>
    <td>
      Interactivity
    </td>
    
    <td>
      <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" rel="nofollow" target="_blank">
        Vanilla JavaScript
      </a>
      
      , native <a href="https://developer.mozilla.org/en-US/docs/Web/Web_Components" rel="nofollow" target="_blank">
        Web Components
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Carousel/Lightbox
    </td>
    
    <td>
      <a href="https://swiperjs.com/get-started" rel="nofollow" target="_blank">
        Swiper.js
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Offline/PWA
    </td>
    
    <td>
      <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API" rel="nofollow" target="_blank">
        Service Worker API
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Hosting
    </td>
    
    <td>
      <a href="https://vercel.com/docs" rel="nofollow" target="_blank">
        Vercel
      </a>
      
      , no remote build step
    </td>
  </tr>
</tbody>
</table>

No Nuxt, no React, no Vue, no bundler. `npm run build` just runs a local Node script (`build.js`) that handles cache-busting and injects images before the commit — the deploy itself is purely static.

## From pure CMS to a hybrid approach

The first version of the site rendered product pages entirely through JavaScript: a Web Component would read the slug from the URL, fetch the matching JSON, and build the DOM at runtime. It worked, but Google saw an almost-empty page until the JS ran — bad for SEO, bad for Core Web Vitals.

The decision I documented in an internal file at the time (`ABORDAGEM-HIBRIDA.md`, "hybrid approach") was to migrate to a hybrid model: static HTML with all the SEO already written into the file (title, meta tags, Open Graph, canonical), with the Web Component only filling in the dynamic content on top.

<table>
<thead>
  <tr>
    <th>
      Aspect
    </th>
    
    <th>
      Pure CMS (before)
    </th>
    
    <th>
      Hybrid approach (after)
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      SEO
    </td>
    
    <td>
      Meta tags via JS — worse
    </td>
    
    <td>
      Meta tags in HTML — perfect indexing
    </td>
  </tr>
  
  <tr>
    <td>
      Performance
    </td>
    
    <td>
      JS required to render
    </td>
    
    <td>
      HTML already visible on first paint
    </td>
  </tr>
  
  <tr>
    <td>
      Maintenance
    </td>
    
    <td>
      Just edit JSON
    </td>
    
    <td>
      HTML + JSON — a bit more work
    </td>
  </tr>
  
  <tr>
    <td>
      Scalability
    </td>
    
    <td>
      Trivial
    </td>
    
    <td>
      Needs one HTML template per new page
    </td>
  </tr>
</tbody>
</table>

Trading some maintenance ease for SEO/performance was worth it — every product page has its own real URL, title, and Open Graph, without depending on any SSR framework to make that happen.

## The CMS: data in JSON, engine in vanilla JS

Every product and every packaging type becomes a JSON file under `CMS/slugs/{producao|embalagens}/{slug}.json`. A single generic Web Component, `SlugPage`, reads that file by the URL's slug and fills the template — the same component serves both production and packaging, only the `category` detected from the URL changes. Here's the real JSON for the "Teas" product:

```json [CMS/slugs/producao/chas.json]
{
  "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"
  ]
}
```

That system — a homemade CMS powered by JSON, no database, no admin panel — is what gave me the idea for how I wanted to structure dynamic content on my own portfolio, ao.dev. There I use real `@nuxt/content` instead of reinventing the wheel, but the root idea — content as versioned data living in the repo, not hidden behind an external CMS — was born here.

## A per-slug color system that was built and never used

By default, every page category gets its own accent color: green (`footer-secondary`) for Production pages, wine (`footer-primary`, the same `#792a47` used across the institutional heroes) for Packaging pages. That already covered the real use case. But I built one more layer on top: any slug's JSON can declare a `color` field with a custom hex value, and the whole page retheme itself around it — without writing a single new line of CSS.

```js [js/components/slug-page.js]
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)
  // Light version for hover/emphasis: 95% white + 5% original color
  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; }
    /* + a dozen swiper, benefits, and CTA selectors */
  `
  document.head.appendChild(style)
}
```

The function validates the hex via regex, converts it to RGB, computes a light version for hover/backgrounds, and injects a `<style>` tag into `<head>` overriding more than ten selectors — hero, variant cards, carousel buttons, pagination, benefits list, CTA — all derived from a single value. No real product or packaging entry uses that field today: the client never asked for a custom color per item, so everything stayed on the default green/wine split by category. But the code is there, tested and working, waiting for the day someone fills in a `"color": "#..."` on a new JSON.

## Web Components without a framework

Every reusable piece of the site (header, footer, carousel, testimonial slider) is a native `HTMLElement` registered via `customElements.define`, with no Vue or React underneath. The footer, for example:

```js [js/components/leve-footer.js]
class LeveFooter extends HTMLElement {
  constructor() {
    super()
  }

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

    // Reads env vars injected at build time, with a hardcoded fallback
    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)
```

The `window.__LEVE_CTX` pattern with a hardcoded fallback solves a real problem: sensitive contact data (phone, email) needs to come from an environment variable in production, but the component can't break if that env var isn't injected — hence the always-present fallback.

## Build: timestamp-based cache-busting, in parallel

Without a framework, CSS/JS cache-busting doesn't come for free — I had to build it by hand. `build.js` orchestrates three scripts:

- Renames every CSS/JS file with a timestamp in the filename (e.g. `home-27-02-2026-17-44-10.css`) and updates the references across every HTML file
- `build-images.js` processes `CMS/images.json` — the single source of truth for every ID-managed image (`IMG-01`, `IMG-02`...) — and injects `src` with `?v=timestamp`, `alt`, `width`/`height` into every `<img data-image-id="IMG-XX">`
- `build-images-worker.js` runs that processing in parallel, one page per Worker Thread — without it, processing every HTML page sequentially got noticeably slower with each new page added

## The cache bug that needed surgery on `vercel.json`

After one of the first deploys, the client reported not seeing changes even though the new code was already published. Root cause: the original cache headers applied `max-age=31536000, immutable` (one year, "never changes") on rules that were too broad — including HTML and the main JS/CSS files, which *do* change on every deploy even without cache-busting in the root filename.

The fix was splitting the rules by file type: HTML and the few files without a hash in their name get aggressive revalidation; versioned assets (`/assets/*`, `/icons/*`, with a timestamp hash) safely keep the one-year cache, because the filename changes on every build.

```jsonc [vercel.json]
{
  "headers": [
    // HTML always revalidates — never gets stuck on a stale cache
    {
      "source": "/(.*)\\.(html)",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }]
    },
    // Timestamp-named assets — a 1-year cache is safe here
    {
      "source": "/assets/(.*)",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
    }
  ]
}
```

> The rule that fixed everything: `max-age=0, must-revalidate` forces the browser to always ask the server whether there's a new version before using the local cache — without it, an overly generic `immutable` leaves the client stuck on the old version indefinitely.

## A real PWA

`manifest.json` and `sw.js` (Service Worker) are live and return 200 — not just decoration, the site works as an installable Progressive Web App, with offline caching for static assets. One annoying technical detail: Service Workers don't register over `file://`, only through a real HTTP server — I documented that in an internal `TESTE-LOCAL.md` after forgetting more than once and testing straight off disk.

## SEO and AI discoverability

Alongside the usual `sitemap.xml` and `robots.txt`, the site ships an `llm.txt` at the root — the same idea behind the `/llms.txt` that ao.dev itself now exposes (via `nuxt-llms`), just hand-written here instead. Structured data via JSON-LD covers `Organization`, `LocalBusiness`, and `WebSite`. Every product/packaging page has a unique title, description, and Open Graph — a direct consequence of migrating to real static HTML.

## Documentation built for the client, not the dev

One of the pieces of this project I'm proudest of isn't even code: it's a Google Docs form — `formulario-adicionar-novos-itens.md` — that walks someone with zero technical background through describing a new product (name, description, images, features, SEO keywords) in a way I can turn straight into a new CMS JSON entry. The form's technical section (slug, category, file path) comes pre-filled as "that part's on us, you don't need to worry about it."

There's also an internal guardrail document (`produtos-e-servicos-descontinuados.md`): a list of what the company no longer works with, so I never accidentally reintroduce something removed at the client's request in an earlier review round — protection against content regression, not just code regression.

## Deploy

Deployment runs entirely on Vercel, no remote build — the `Output Directory` points to the repo root (`.`), because `build.js` already produces finished HTML locally. The hardest part of this project wasn't technical: it was the domain-access and SSL-certificate bureaucracy on the client's side, resolved directly with their IT team. It took longer than expected, but both of the group's sites (Leve Mix and Romanus's institutional site) ended up live, validated, and with a correct certificate.

## About

<figure className="rehype-figure">

![About page, with Mission, Vision, Values and the food-safety commitment](/images/pages/projects/leve-mix/leve-mix-sobre.png)<figcaption>

About page, with Mission, Vision, Values and the food-safety commitment

</figcaption>
</figure>

The mission statement on the site sums up the business well:

> To be the complete, trustworthy outsourcing solution for developing, producing, and packaging private-label brands in the food market, simplifying the path for entrepreneurs to launch high-quality products with agility and optimized costs.

Right below it, the "Food Safety and Quality Commitment" section explains the operation runs across **two independent plants**, with cross-contamination control systems — information that went in during a specific review round, at the client's explicit request, to make clear this isn't generic mixed production. The Values section lists items like "Excellence: premium quality in every product" and "Partnership: your success is our success," each with its own Lucide icon. It closes with the "What you'll produce with us" carousel — the same product list that also becomes its own page under Production.

## Production

Unlike Packaging, Production doesn't have its own listing page — each product line is just a direct slug (`/producao/{slug}/`), discovered through the "What you'll produce with us" carousel (on the About page) or through the CMS Loader's real-time search. As of this writing, the catalog covers lines like coffee and cappuccinos, powdered chocolate, collagen, teas, flour mixes, and seasonings — but since each one is an isolated JSON entry, that catalog shifts over time: new items come in, others may go, none of it needing a code deploy. "Liquids," for instance, was the last line added on this project, in a review round near the end — it went live first with placeholder image and text (literal *lorem ipsum*) while the final material hadn't arrived yet, and was only filled in with real data afterward. That's the kind of thing only a CMS separated from the HTML lets you do without a hack: the page exists, it's live, it's indexable, and the content evolves underneath without needing anything new in code.

### One product slug up close

<figure className="rehype-figure">

![Example product page, with variants, advantages and CTA](/images/pages/projects/leve-mix/leve-mix-produto-chocolate.png)<figcaption>

Example product page, with variants, advantages and CTA

</figcaption>
</figure>

Every product page follows the same anatomy, generated by the same `SlugPage`, in this order: hero (title, photo, subtitle, green since it's the Production category), a full description paragraph, variant cards (when the item has more than one version), an "Advantages" list in a two-column grid, a "Talk to a Specialist" CTA, and an "Other Products" swiper at the end, pulling the rest of the category's slugs via `cmsLoader.getItemsForSlider()`. The example above is the Powdered Chocolate page, captured at one specific point in the catalog — the exact content may change; the structure underneath won't.

## Packaging

<figure className="rehype-figure">

![Packaging page with the carousel of available formats](/images/pages/projects/leve-mix/leve-mix-embalagens.png)<figcaption>

Packaging page with the carousel of available formats

</figcaption>
</figure>

Unlike Production, Packaging has its own listing page — `/embalagens/`, with a carousel of the available formats, linked from both the nav menu and the footer. Each individual format follows the same slug structure (`/embalagens/{slug}/`), same `SlugPage` Web Component, same JSON schema — only the `category` in the URL changes from `producao` to `embalagens`. Reusing the same generic component for both sections, instead of duplicating "product page" and "packaging page" logic like early in the project, was one of the decisions that saved the most maintenance work down the line: one bug fix or new feature in `SlugPage` benefits every page at once, product or packaging.

### One packaging slug up close

<figure className="rehype-figure">

![Example packaging page, in wine, same structure as the product page](/images/pages/projects/leve-mix/leve-mix-embalagem-pouches.png)<figcaption>

Example packaging page, in wine, same structure as the product page

</figcaption>
</figure>

Same anatomy as the product page, point for point — just in wine, the default Packaging color. The example above is the Stand Up Pouch page, with its own size variants and format-specific advantages, followed by the same "Other Packaging" carousel at the end. Seeing both pages side by side is the most direct way to show why a single generic component paid off: zero structural difference between them, only the data (which can change) and the color differ.

## Services

<figure className="rehype-figure">

![Services page, with the accordion of the eight outsourcing steps](/images/pages/projects/leve-mix/leve-mix-servicos.png)<figcaption>

Services page, with the accordion of the eight outsourcing steps

</figcaption>
</figure>

An accordion with eight steps of the outsourcing process, each with its own Lucide icon:

1. Mixing, filling and labeling — *"Our complete infrastructure ensures precise mixing of powdered formulations — coffees, cocoa mixes, seasonings, and other blends — followed by efficient filling and labeling."*
2. New product development
3. Packaging development
4. Supplier management
5. Conventional production
6. Allergen-free production
7. Feasibility consulting
8. Logistics hub

The icons for these eight items went through a mid-project revision: they started as 80×80 placeholder images, then became real Lucide icons (`package`, `flask-conical`, `box`, `users`, `factory`, `shield-check`, `trending-up`, `truck`) — lighter, crisper at any resolution, and no longer dependent on any image exported from design.

## Contact

<figure className="rehype-figure">

![Contact page, with a form integrated via Google Apps Script](/images/pages/projects/leve-mix/leve-mix-contato.png)<figcaption>

Contact page, with a form integrated via Google Apps Script

</figcaption>
</figure>

The form asks for company, name, email, phone, and a free-text description of the idea or product — no excess fields to scare off someone who just wants to send a quick message. There's one invisible extra field, `website_url`, kept out of the visual flow (`tabindex="-1"`, `autocomplete="off"`) — a classic anti-spam honeypot: form bots tend to fill in every field they find in the DOM, while a real human never sees that field to fill it in. If it comes back filled on submit, the entry gets silently discarded as spam.

Submission itself goes through a **Google Apps Script** published as a Web App — no backend of my own, no email server to maintain, just a script running under the client's own Google account. The page closes with a map of the real location (Campo Largo, PR) and the client-testimonials section.

## What carried over to ao.dev

This project ran just before I restarted ao.dev from scratch, and a few lessons came straight from mistakes made here:

- The overly generic `Cache-Control` bug on this project is why I arrived at ao.dev already knowing exactly that hash-named assets use `immutable` and everything else revalidates — documented from day one instead of discovered after a broken deploy
- The homemade JSON CMS — no panel, no database, the root idea that "content is versioned data in the repo" — is literally why I chose `@nuxt/content` for ao.dev instead of an external headless CMS
- Hand-writing `llm.txt` here is what made me look, on ao.dev, for a way to generate that automatically from real content (`nuxt-llms`) instead of maintaining a static file that quietly goes stale

## Grupo Romanus and the full portfolio

Leve Mix offers full outsourcing — formula development, scaled production, and final packaging under the client's own brand — across five main lines:

1. Hot beverages: coffee and cappuccino (traditional and functional, like green coffee and yerba mate) and powdered chocolate with **Rainforest Alliance**-certified cocoa
2. Supplements and collagen, including the **Verisol** line — patented bioactive collagen peptides
3. Soluble and loose-leaf teas
4. Flour-based mixes, like the protein mug cake blend (gluten-free, lactose-free)
5. Seasonings and condiments, traditional and functional

Today the site shows over 200 solutions developed, over 50 active clients, 10 years of Leve Mix, and 26 years of Grupo Romanus — numbers the client validated themselves before publishing.
