---
name: theme-building
description: Guide for building, editing, or debugging Vvveb CMS themes — plain HTML templates in public/themes/ bound to dynamic content through data-v-* attributes. Use when creating a new theme, adding or converting pages, wiring dynamic content (posts, products, cart, checkout, user), fixing pages that show placeholder instead of live data, working with cart/checkout JS behaviors, or converting html templates.
---

# Vvveb Theme Building

Themes are **plain HTML** — any static HTML site is a valid theme. Dynamic data is bound via `data-v-*` attributes; the Vtpl engine compiles them into PHP templates (cached under `storage/compiled-templates/`). Never introduce PHP into theme HTML.

Reference implementations:
- `public/themes/landing` — the default Bootstrap 5 theme.
- `public/themes/landing-tailwind` — full-parity Tailwind port built from `src/**` with Vite + Handlebars partials (see its section below).

When unsure how something works, find it in one of these two and copy the pattern. For `.tpl` template overrides and the engine itself see `skills/vtpl-building/SKILL.md`; for SqlP query models see `skills/sqlp/SKILL.md`.

## Workflow

1. Study the closest existing page in `public/themes/landing` (or `landing-tailwind/src`) before writing anything.
2. Build pages as complete static HTML with realistic demo content — the demo text/images are what shows in the page builder and in the static file; at runtime attributes replace them.
3. Wire dynamic data with `data-v-*` attributes (reference below).
4. Keep shared parts (head, nav, footer) synced with `data-v-save-global`.
5. Verify: activate the theme in admin, load each page type, confirm dynamic data renders and no raw `data-v-` artifacts leak into output. For landing-tailwind also run `npm run build` inside the theme folder and re-check (see below).

## Location & structure

Themes live in `public/themes/<theme-slug>/`. Required page files (routes map 1:1 to these names):

```
├── index.html            homepage (default template)
├── index.coming-soon.html  replaces homepage when site is set to coming soon
├── index.maintenance.html  replaces homepage when site is set to maintenance
├── error404.html         page not found
├── error500.html         internal server error
├── blank.html            minimal layout, no content
├── content/
│   ├── index.html        blog page
│   ├── post.html         single post
│   ├── page.html         static page template
│   ├── tag.html          posts for tag
│   ├── category.html     posts for category
│   ├── archive.html      posts by date
│   └── user.html         posts by author
├── product/
│   ├── index.html        shop page
│   ├── product.html      single product
│   ├── manufacturer.html / vendor.html / category.html
├── search/search.html    search results
├── user/                 index (dashboard), signup, login, reset, edit,
│                         address, comments, orders, downloads
├── cart/cart.html        cart page
├── checkout/             checkout.html, confirm.html
└── email/                order/new.html, order/update.html,
                          user/signup.html, user/reset.html (+ .txt.html variants)
```

Optional folders: `css/`, `js/`, `media/`, `demo/` (placeholder images), `sections/` + `blocks/` + `js/vvvebjs.js` (page-builder integration), `template/` (Vtpl overrides: `common.tpl`, `components/posts.tpl`, …).

Full route-mapped file set also includes: `content/category-page.html` (category holding **pages**, auto-selected by the controller), `content/index-one-column.html`, `post-one-column.html`, `post-image-hero.html`, `post-image-header.html` (post layout variants), `product/tag|manufacturer|vendor.html`, `search/search.html`, `user/*` full dashboard (index, profile, address(+edit), orders(+order), downloads, comments, wishlist, order-tracking, return-form), `checkout/checkout-minimal|digital|digital-minimal.html`, `order.html`, homepage variants (`index-blog`, `index-blog-excerpts`, `index-hero-*`, `index-nav-hero`, `index-no-sidebar`, `index-slider`, `index-landing`) and standalone marketing/utility pages (`contact`, `download`, `pricing`, `about`, `services`, `portfolio`, `submit-extension`, `white-label`, `style-preview`, `empty`, `blank-breadcrumb`).

## Theme header

Declare metadata in a comment at the top of `theme.json`:

```php
/*
Name: Landing
Slug: landing
URI: https://vvveb.com
Author: Vvveb
Description: Default theme
Version: 2.0
License: Apache 2.0
Tags: blog, ecommerce
Text Domain: landing
*/
{}
```

## Dynamic data attributes

Two kinds of components:
- **Static** (`data-component-*`) — recognized by the page builder only.
- **Dynamic** (`data-v-component-*`) — pull database content at runtime.

### List component pattern

Wrapper declares the component and its query params; the repeating item element is marked once per item; leaf attributes receive values. Write one item's worth of markup with demo content — the engine clones it per result.

```html
<div class="container" data-v-component-posts data-v-limit="3">

    <article class="card" data-v-post>
        <img src="/media/posts/6.jpg" data-v-post-image>

        <a href="/hello-world-6" data-v-post-url>
            <h3 data-v-post-name>Mauris viverra cursus ante laoreet eleifend</h3>
        </a>

        <p data-v-post-content>Et et saepe suscipit debitis a accusamus nulla…</p>

        <a href="/hello-world-6" data-v-post-url>Read more</a>
    </article>
</div>
```

Common wrapper params: `data-v-limit="3"`, `data-v-page="url"` (current page from url) or `"1"`, `data-v-image_size="large"`, `data-v-404="true"` (404 when empty on single pages), `data-v-year`/`data-v-month` (archive), `data-v-taxonomy_item_slug="url.slug"` (category/tag), named instances via value: `data-v-component-posts="recent-posts-sidebar"`.

### Component → attribute cheat sheet

| Wrapper | Item marker | Item attributes (prefix) |
|---|---|---|
| `data-v-component-posts` | `data-v-post` | `-name -url -image -content -excerpt -created -author-url -comments-url` |
| `data-v-component-post` | (single) | same as post prefix |
| `data-v-component-products` | `data-v-product` | `-name -url -image -price -content -add -buy` |
| `data-v-component-product` | (single) | same + `-main-image -images -manufacturer` |
| `data-v-component-cart` | `data-v-cart-product` | `-name -image -price -quantity -remove-url -subscription`; totals rows via `data-v-cart-total*`; scalars `-total_items -total_formatted` |
| `data-v-component-user` | `data-v-user_address` (addresses) | `data-v-user-*` (profile data) |
| `data-v-component-site` | — | `data-v-site-url -site-logo -site-logo-sticky -site-contact-email …` |
| `data-v-component-breadcrumb` | `data-v-breadcrumb-item` | `-text -url` |

The attribute prefix always mirrors the singular component name (`posts`→`post-*`, `products`→`product-*`, `cart products`→`cart-product-*`).

### Conditionals

Boolean expressions against the component's context:

```html
<img … data-v-post-image data-v-if="post.image">
<article … data-v-if="posts.count > 0">
<nav … data-v-if="pagecount > 1">
<li class="page-item pager-prev" data-prev data-v-if="current_page > 1">
```

Conditional *attributes*: `data-v-attr-if-data-bs-toggle="category.children > 0"` adds `data-bs-toggle` only when true.

### Cart totals & coupons (exact structure matters)

The cart template loops totals as `[data-v-component-cart] [data-v-cart-totals] [data-v-cart-total]`:

```html
<dl data-v-cart-totals>
  <div class="flex justify-between" data-v-cart-total>
    <dt data-v-cart-total-title>Sub-Total</dt>
    <dd>
      <span data-v-cart-total-text data-v-if="total.text"> - </span>
      <span data-v-cart-total-value_formatted data-v-if="total.value != 0">$101.00</span>
    </dd>
  </div>
</dl>
<div><dt>Total:</dt><dd data-v-cart-total_formatted>$0.00</dd></div>
```

- **Every repeating row wrapper must carry `data-v-cart-total`** inside the `[data-v-cart-totals]` scope — without it the loop never matches and the trailing catch-all (`[data-v-cart-*]` → `$cart['…']`) fills leaves with non-existent keys → empty values at runtime.
- Row fields: `-title`, `-text`, `-value`, `-value_formatted` (use the formatted one for display; guard raw value with `data-v-if="total.value != 0"`).
- Grand total outside the loop = scalar `data-v-cart-total_formatted`.
- Applied coupons: list under `[data-v-cart-coupons] > [data-v-cart-coupon]` with `-name`/`-code`; ajax apply/remove via `.btn-coupon` / `.btn-remove-coupon` buttons (app/cart.js reads the sibling `.code` element for removal) and an `input[name=coupon]`. Reusable partial: `_cart-coupon.html`.

### Multiple component instances per page

Every `[data-v-component-cart]` (or any component) occurrence consumes one instance index (`$_cart_idx++`). A page with a mini-cart trigger + offcanvas panel + main scope runs the component three times — all get real data in production. When testing offline, fixtures must supply one entry per instance or later scopes render empty.

### Pagination

```html
<div data-pagination data-v-parent-component="posts" data-v-parent-index="0" data-v-parameters='{}'>
  <nav data-v-if="pagecount > 1">
    <ul class="pagination">
      <li class="page-item pager-prev" data-prev><a class="page-link" data-page-url href="?page=1">…</a></li>
      <li class="page-item" data-page><a class="page-link" data-page-url href="?page=1">1</a></li>
      <li class="page-item pager-next" data-next><a class="page-link" data-page-url href="?page=2">…</a></li>
    </ul>
  </nav>
</div>
```

### Sections

<header>, <section>, <footer> elements that are direct descendents of <body> element are treated as sections by the pagebuilder.
Sections can have top, bottom separators, background and overlay color.
Background is inside `.background-container` and can contain one of the following types: none, <img>, local <video>, youtube <iframe> video
Overlay is used to add semitransparent color overlay by setting an rgba color with opacity, usually 0.8

```html
  <section>


    <div class="separator bottom">

      <svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 1200 120" preserveaspectratio="none" id="Layer_1" class="section-divider" aria-hidden="true" width="100%" height="30" fill="var(--bs-body-bg)" style="transform: rotate(180deg);">
        <path d="M321.39,56.44c58-10.79,114.16-30.13,172-41.86,82.39-16.72,168.19-17.73,250.45-.39C823.78,31,906.67,72,985.66,92.83c70.05,18.48,146.53,26.09,214.34,3V0H0V27.35A600.21,600.21,0,0,0,321.39,56.44Z"></path>
      </svg>

    </div>
    
    <div class="background-container">

      <img src="/themes/landing/demo/img/hero-3.webp" alt="Hero background">

    </div>
    
	<div class="background-container">

      <img src="demo/img/hero-3.webp" alt="Hero background">

		<video playsinline="" loop="" muted="" autoplay="" src="../../media/demo/sample.webm" poster="../../media/demo/sample.webp" class="d-none"></video>
		
		
		<div class="iframe-container d-none">
			<iframe src="https://www.youtube.com/embed/C6fOoy7Se_4?autoplay=1&amp;loop=1&amp;playsinline=1&amp;controls=0&amp;mute=1" frameborder="0" allow="accelerometer; autoplay;" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="">
		</iframe>
    </div>    

  </section>
```

### Routes instead of hard-coded links

Prefer route URLs over literal paths so links survive URL overrides: `<a href="" data-v-url="user/index">Account</a>`, `data-v-url="checkout/checkout/index"`. Optional params JSON: `data-v-url-params='{"slug":"terms-conditions"}'`.

### Global JS stack & per-page helpers

`_includes/footer-scripts.html` / tailwind `partials/scripts.html` load, in order: `app.js` (VvvebTheme core + `data-v-vvveb-action` Gui handlers: addToCart, removeFromCart, addToWishlist/Compare, comment/review actions), `app/cart.js` (quantity change + coupon/remove clicks), `glightbox` + `theme.js` (product zoom, lightbox, password toggle), speculation rules. Pages additionally include what they need:

| Page | Extra script | Markup contract |
|---|---|---|
| checkout | `/js/app/checkout.js` + `let regionsUrl = '{ $this->regionsUrl }';` | `.cart-summary` refresh target; `.billing_address`/`.shipping_address` blocks; `#checkout-login-container` + `#login-form-check`; register/guest radios `name=register`; saved-address radios `name=billing_address_id` + `#billing_address_new`; shipping/payment accordions use `.accordion-item/.accordion-header/.collapse` (engine adds `.show` to selected) |
| address/edit | same | selects need `id`+`name` pairs the country/region tpl binds (`data-v-countries`/`data-v-regions`) |
| product listings w/ filters | `/js/app/filter.js` | filter groups wrapped in `.filters`; checkboxes named `manufacturer_id[]` / `vendor_id[]` auto-reload the listing with `filter[…][]=` params |
| cart/offcanvas | included by default | remove links use `data-v-vvveb-action="removeFromCart"` + `data-v-cart-product-remove-url`; rows carry `data-key`/`data-product_id` (set by components/cart.tpl) |
| product gallery | — | main container needs class `.gallery`, thumbs row `.carousel` — theme.js opens GLightbox over every img inside those containers; hover zoom requires the `.zoom` CSS (background-image set by tpl) and `div.zoom img:hover {opacity:0}` reveal |

Bootstrap-free replacements used by landing-tailwind: dropdowns via `[data-dropdown-toggle]`, drawers via `[data-drawer-open]`/`[data-drawer]`/`[data-drawer-close]` + `[data-drawer-backdrop]` (js/main.js), `<details>` or inline fallback scripts for accordions, inline SVG icons instead of icon fonts.

## Shared parts (head/nav/footer)

Mark an element with the file + CSS selector it should stay synced from. On save (page builder or compile), the referenced section is copied over this one so all pages share one nav/footer/head:

```html
<head data-v-save-global="index.html,head">
<nav class="navigation-1 clearfix" data-v-save-global="index.html,.navigation-1">
<footer data-v-save-global="index.html,.footer-3">
<aside data-v-save-global="content/index.html,aside.sidebar">
```

Rules:
- Every page's `<head>` should carry `data-v-save-global="index.html,head"`.
- Pages in subfolders use `<base href="../">` so relative css/js/media paths resolve.
- Don't remove these attributes when editing; edit the source page (usually `index.html`) to change nav/footer everywhere.

## Child themes

A child theme contains only overridden files and pulls shared parts from the parent with `data-v-copy-from` (path relative to the current file):

```html
<link href="../landing/css/style.bundle.css" rel="stylesheet">
<nav data-v-copy-from="../landing/blank.html,body > nav:first">test</nav>
<footer data-v-copy-from="../landing/blank.html,footer"></footer>
```

Slug convention: `<parent>-child` (e.g. `landing-child`). Any parent template (`content/post.html`, …) can be overridden the same way.

## The landing-tailwind theme (Vite + Tailwind)

`public/themes/landing-tailwind` is authored in `src/**` (mirroring the final structure) and compiled to the theme root with Vite + `vite-plugin-handlebars` — **edit `src/`, never the built root files** (`emptyOutDir: false`; a stale built file is overwritten on next build).

- Build: `npm run build` inside the theme folder (`npm run watch` for dev). Partials expand with `{{> name }}` (+ params: `{{> head title="Cart" base="../" }}`).
- Partials live in flat `src/partials/` (`_name.html` = private/component partial); extra directories can be registered in `vite.config.js` `partialDirectory` array (e.g. `src/user/orders`, `src/sections` for page-builder sections referenced as `{{> hero/hero-3 }}`).
- Every new page must be added to the `pageInputs()` list in `vite.config.js` or it won't be emitted.
- Shared chrome is handlebars partials composed per page: `head` (carries `data-v-save-global`), `navigation` → `top-menu` → `navbar` + `user-dropdown` + cart trigger; `_cart-drawer` (offcanvas panel) renders AFTER `</nav>`; `footer`, `scripts`.
- Homepage variants and marketing pages are compositions of the `src/sections/<group>/<name>.html` library (171 sections mirroring landing's), not unique markup.

### Bootstrap → Tailwind conversion conventions

| Bootstrap | Tailwind equivalent |
|---|---|
| `form-control` / `form-select` | `.input` |
| `form-label` | `.label` |
| `btn-primary` / `btn-outline-*` | `.btn-primary` / `.btn-outline` |
| `card` / `container` | `.card` / `.container .section` |
| accordion (bootstrap JS) | keep `.accordion-item/.accordion-header/.collapse` classes for tpl bindings, toggle visibility with `<details>` or a small inline fallback script; remember tailwind ships its own `.collapse { visibility: collapse }` utility — override with `.collapse{display:none}.collapse.show{display:block;visibility:visible}` |
| `la la-*` icon fonts | inline heroicon-style SVGs |
| `data-bs-toggle="dropdown"` | `[data-dropdown-toggle="#id"]` (js/main.js) |
| offcanvas | `[data-drawer-open]`/`[data-drawer]`/`[data-drawer-close]` + `[data-drawer-backdrop]` (js/main.js adds `.open`, locks body scroll) |

Build gotchas learned the hard way:

- **Never put handlebars expressions inside HTML comments** — they expand anyway and inject partial markup into the comment, breaking vite's HTML parser across every page including that partial.
- After building, grep the output for leftover `{{>` to catch unregistered partials.
- Non-module scripts (`/js/app.js`, `app/cart.js`, `glightbox`, …) are declared in `filesPathToExclude` (external); "can't be bundled without type=module" warnings for them are expected noise.
- Fixed-position elements (drawers, dropdown panels) must NOT be nested inside elements with `backdrop-filter`/`filter`/`transform` (e.g. the sticky nav's `backdrop-blur`) — that creates a containing block and traps them; render drawers after the nav instead.

## Page builder integration (optional)

- `sections/<group>/<name>.html` — reusable sections offered in the builder.
- `blocks/blocks.js` + `blocks/*.html` — blocks list.
- `js/vvvebjs.js` — editor hooks (e.g. landing uses `vvveb.getHtml.before` to strip sticky classes before save).
- `inputs/inputs.js` — custom property inputs.
- `components/*.js` — component definitions loaded by the editor AFTER the core framework files (see `Editor::loadThemeAssets()`); registrations with core ids (`html/container`, `html/button`, ...) override the Bootstrap defaults. Non-tailwind themes ship nothing here and keep the Bootstrap palette.

For Tailwind themes, copy `/js/vvvebjs/components-tailwind.js` into `components/tailwind.js` (landing-tailwind does this). It replaces the Bootstrap 5 palette with Tailwind equivalents. Constraint: properties that target `class` via `validValues` must list ONE class per value — the builder swaps them with `classList.remove(...validValues)`/`classList.add(...value.split(" "))`, which throws on multi-word tokens; coordinate multiple classes through an `onChange` handler instead.

## Pitfalls

- Demo content is intentional: attributes replace it at runtime, but keep it realistic for the builder and static preview.
- Don't rename files/routes casually — the filename IS the route mapping.
- Don't convert themes to PHP/Blade-style templating; logic belongs in `template/*.tpl` overrides (Vtpl syntax: CSS selector = value pairs) or plugins.
- Keep `data-v-save-global` targets consistent across pages of the same theme (same selector source).
- Email templates are separate plain-HTML files under `email/` and don't inherit site nav/footer.
- A loop template's leaves are claimed by its own bindings first; generic catch-all lines later in the tpl only hit nodes still holding their original text nodes. If a listing renders empty values, check an item marker is missing so no loop claimed the leaves (e.g. totals rows without `data-v-cart-total`).
- Empty `src=""` placeholder slots (product gallery thumbs) render broken-image icons and become broken lightbox entries — remove unused slots at load or ship exactly as many slots as images.
- `data-filter-*` names must exist in `$variableFilters` (`system/vtpl/vtpl.php`); it's `capitalize`, **not** `ucfirst`. Unknown filters fail silently (debug warning only).
- Product gallery needs the `.zoom` CSS ported (hover reveal of the tpl-set background image) plus a thumbnail-switching script when there's no bootstrap carousel; theme.js opens GLightbox over any img inside `.gallery`/`.carousel` containers.
