Templates
Templates are versioned Handlebars HTML files (templates/<doc_type>/<version>/template.html) that render a validated dataset into the document a guest or agent sees. One template per document type renders every variant of that type: the reference proposal template renders escorted touring, FIT, cruising, and hybrid trade documents — and all four create-action variants — from one file. Variation is expressed through data and display flags, not template forks.
The canonical section id registry
Every top-level section in a template carries a registered id. These ids are a contract: the QA harness asserts the presence or absence of each section per dataset and per action, the blueprint's section toggles map onto them, and PDF page-break rules target their CSS classes. Templates must use these exact ids:
| Document group | Section ids |
|---|---|
| Guest documents (proposal) | hero, personal-message, highlights, journey-overview, itinerary, services, flights, accommodations, stay-units, arrival-logistics, inclusions, pricing, price-lines, payment-cta, hold-banner, agent-banner, travel-protection, terms, specialist, trust-pillars, footer, action-dock |
| Finance documents (invoice family) | invoice-header, bill-to, line-items, totals, payment-schedule, agent-card, commission-summary, distribution-guard |
| Booking confirmation | confirmation-header, what-happens-next, trip-summary, financial-summary, how-to-pay |
| Final travel documents | trip-summary, travelers, service-confirmations, important-info, emergency-contacts |
Regulatory certificates (supporting_document_example) sit outside this registry — their layouts are prescribed per scheme.
The _display flag convention
Document actions control section visibility by injecting boolean flags into the dataset's _display object at generation time (see the blueprint mapping). Templates honour two guard patterns, depending on the section's default:
- Default-hidden sections (
hold-banner,agent-banner,price-lines) render only when a flag is explicitly true:
- Default-shown content sections (pricing, inclusions, terms, and so on) render unless a flag is explicitly false. Because an absent flag must mean "shown", the guard tests for explicit
falserather than truthiness:
The flags are visible in the rendered output. These are sections of the same proposal template rendered under different actions:

hold-banner — hidden by default, switched on by the option-hold action
agent-banner — hidden by default, switched on by the trade action
pricing on a standard proposal — package price with payment call to action
price-lines on an agent proposal — the line-level breakdown trade partners seeHere is a real default-hidden section from the reference proposal template — note the registered id, the _display guard, and the inner data guard:
Itinerary mode: which sections render
The proposal template selects its itinerary presentation from meta.itineraryMode together with data presence:
#itineraryrenders whenitinerary.days[]is non-empty and the mode is notservices— coveringdays,both, and the absent-mode fallback.#servicesrenders wheneverservices[]is non-empty. Cruise documents carry the cruise service entry alongside port-call days, so both sections can appear together.#journey-overviewrenders only when the mode isservicesandserviceDates[]is non-empty.
This is why the mode is explicit in the data rather than inferred: a hybrid document that happens to carry both arrays renders predictably, and the QA harness can assert exactly which sections appear for each dataset.
Empty-section collapse
Independently of display flags, a section with no backing data never renders — header included. The hold banner above collapses without pricing.holdUntil even when its flag is true; the pricing section renders only when the pricing object carries a renderable headline figure; an inclusions section without included or excluded items produces no output at all. This is why schema optionals are nullable by design: a sparse dataset yields a shorter document, never an empty band with a heading over nothing.
The two-layer CSS token system
Templates are styled exclusively through CSS custom properties, organised in two layers in assets/css/variables.css:
- Layer 1 — brand primitives. The raw brand values (palette, shadows). This is the only layer a rebrand replaces.
- Layer 2 — semantic API. The names templates and
main.cssactually consume. These names are identical across tenants, so templates stay brand-agnostic.
:root {
/* Layer 1 · brand primitives */
--flux-primary-600: #034955;
--flux-yellow-400: #FFBC42;
--flux-background: #F9FAF8;
--flux-black: #212121;
/* Layer 2 · semantic API — consumed by templates; do NOT rename */
--color-primary: var(--flux-primary-600);
--color-accent: var(--flux-yellow-400);
--color-canvas: var(--flux-background);
--color-ink: var(--flux-black); /* inverted bands: trust pillars, footer */
--color-text: var(--flux-black);
}
Templates never contain raw hex values; they reference Layer 2 tokens only (var(--color-accent)). When you rebrand, you replace Layer 1 values and the font definitions — every template picks up the new brand with zero template edits. See the rebranding guide.
Handlebars helpers: what templates may use
Templates may only use helpers that are available in all of the platform's render registries — the document renderer, the PDF renderer, and the QA harness all execute your template, and a helper missing from any one of them breaks that path. The QA suite includes a helper parity gate for exactly this reason.
The two patterns that matter most:
formatCurrency— the one sanctioned way to render money. Takes the raw numeric value and the ISO currency code:
- The
localeDateinline-partial pattern — locale-aware dates are implemented as inline partials declared once at the top of the template, taking the date through an explicitvaluehash argument and resolving the locale from@rootso they work at any block depth:
One constraint catches almost everyone once: there are no and / or / not helpers. A condition like "A and B" is expressed by nesting blocks:
Comparison helpers such as eq are available in subexpressions ({{#if (eq tour.departureType "Fixed")}}), which combined with nesting covers the practical cases.
Quality gates on rendered output
Every template change runs through the QA harness, which renders each dataset (and each action's display-flag set) at desktop and mobile viewports, asserts the expected and absent section ids, and scans the output for forbidden strings — undefined, [object Object], NaN, unrendered {{ braces, and invalid dates. A binding typo therefore fails CI rather than reaching a guest. Keep this in mind when adding bindings: every field a template references should exist in the schema, and every guard should tolerate null.
Locale-driven rendering
meta.locale selects more than date formats. Templates branch on it for spelling and phrasing conventions as well — for example, the reference pricing panel renders "per person, double occupancy" for en-US and "per person, twin share" otherwise. Currency formatting via formatCurrency is likewise locale-aware. The dataset never carries pre-formatted display strings for money (the one exception is tour.dates, a display date range, which the schema explicitly documents as such).
Related pages
- The data these templates bind: Document data reference
- How actions set
_displayflags: Blueprint - Selector-level expectations per section: QA test harness
- Forking and restyling templates: Fork playbook