docs/THEMING_AND_TEMPLATES.mdTheming and Templates
Last updated: May 19, 2026 at 5:27 PM ET
Per-client branding, theme math, and the section-based page builder.
Related docs: README, ARCHITECTURE, ADMIN_GUIDE.
Reusable template v2 — default landing-page structure
Every new client (and any existing client that hasn't customised
their page in the builder yet) starts from this section order on
/<slug>/instant-quote. Anything is hidden via the visibility
toggle rather than deleted; the section type catalog stays
unchanged.
| Order | Section | Purpose |
|---|---|---|
| 1 | site_header |
Sticky top header — logo + phone |
| 2 | hero |
Form + headline + location line. Every variant ships the form. |
| 3 | trust_badges |
Logo / badge row (Google Reviews, BBB, etc.) |
| 4 | trust_strip |
Icon-based "5-Star Rated / Licensed & Insured / Warranty-Backed Work / Financing Available" row |
| 5 | credibility |
"Why you can trust the number" — moved up so the rationale runs before social proof |
| 6 | testimonials |
Real homeowner reviews |
| 7 | why_choose_us |
Benefit cards |
| 8 | how_it_works |
3-step process |
| 9 | services_materials |
Roofing systems we install |
| 10 | faq |
Common questions |
| 11 | final_cta |
Closing CTA |
| 12 | trust_section, what_happens_next |
Closing reassurance |
| 13 | site_footer |
Disclaimer + brand chrome |
Defaults live in:
lib/sections/meta.js— every section'sdefaultContentand the per-page section order inPAGE_DEFAULTS.lib/clientDefaults.js— the new-client onboarding form seeds (hero copy, trust items, etc.). These feed the same defaults through a different entry point.
Edits to either file change what NEW clients render. Existing clients keep their saved page sections — see "Existing client data" below.
Hero v2 — eyebrow, location line, headline, subhead
The hero template now renders a tiny location line under the
eyebrow ("Serving {Service Area}") that auto-fills from
client_branding.service_area. Admins can override it per page
via the section's location_line field; leaving it blank uses the
auto-built form. All five hero variants surface the line in the
right slot.
The default copy is:
- Eyebrow: "Instant Roof Replacement Estimate"
- Location: "Serving {Service Area}" (auto)
- Headline: "See your roof replacement price range in about a minute."
- Subheadline: "No phone tag. No sales pressure. No obligation. Just a fast estimate based on your roof details."
The form itself is the in-hero CTA. A separate sticky mobile CTA
("Start My Estimate") fades in once the user scrolls past the hero
and scroll-targets #quote — see "Sticky mobile CTA" below.
Trust strip — default items
DEFAULT_TRUST_ITEMS in lib/clientDefaults.js:
| Icon | Title | Subtext |
|---|---|---|
| star | 5-Star Rated | Verified homeowner reviews |
| shield | Licensed & Insured | Local, background-checked crews |
| badge | Warranty-Backed Work | Workmanship & material coverage |
| wrench | Financing Available | Simple monthly options |
Each item is fully editable per client through the page builder's trust_strip section. The defaults are what new clients see on first render.
Sticky mobile CTA
components/StickyMobileCta.jsx renders a fixed-bottom button on
mobile only (hidden at md+). It fades in once the user scrolls
past the hero — measured against the #quote section's bottom
position — and smooth-scrolls back to #quote on tap. Label
defaults to "Start My Estimate".
Only mounted on the instant-quote page from
app/[clientSlug]/[pageKey]/page.js. Respects iOS safe-area
inset and pointer-events-none until visible so it never
intercepts taps during the hero phase.
Reviews — extended schema
The review card schema (reviewSchema in lib/sections/meta.js)
now supports two optional fields on top of the existing
name / location / rating / quote:
project_type— short string ("Architectural shingle replacement", "Storm damage repair"). Rendered as a small brand-tinted chip on the card.image_url+image_alt— for an optional before/after photo thumbnail. Schema is in place; the Cards layout doesn't render the image yet — that's a follow-up in the testimonials component. The field is editable in the admin so content can be authored before the renderer ships.
Default headline: "Real Reviews From Local Homeowners". Admins can swap to "Trusted By Homeowners In {Service Area}" or anything else — defaults are intentionally generic.
FAQ — default questions
Four questions framed around buyer hesitation:
- Is this the final price?
- Do I have to talk to someone to see the estimate?
- Can I finance the project?
- How accurate is the estimate?
Answers are short and admin-editable. The accordion variant works well for these short bodies; the two-column variant is also supported.
Roofing systems visibility
The services_materials section's items array controls what
appears on the public page. What renders is fully admin-controlled
— admins can add, hide, reorder, or replace any system per client
through the page builder.
Defaults shown on every new client's page:
- Architectural shingles
- 3-tab shingles (via the asphalt grouping)
- Metal roofing
Optional (admin opts in by adding to the items list):
- Tile
- Cedar shake
- Flat / low-slope
These are display-only. The pricing engine still keeps the full
material catalog (MATERIAL_OPTIONS in lib/estimate.js) and
each client's client_pricing_rules.price_per_sqft map. Hiding
a system from the marketing page doesn't remove it from pricing.
Form-side material visibility (the dropdown the homeowner picks
from on Step 3) is configured separately via
client_pricing_rules.visible_materials — see FORM_FIELDS.md.
Existing client data
Updates to template defaults only affect clients that don't have
their own saved page_sections rows. Once a client's page is
saved through the page builder, those rows are the source of truth
and the defaults are no longer consulted.
To see the new defaults on an existing client:
- Open the page builder for that client.
- Delete the existing sections you want to refresh.
- Re-add them — they'll seed from the new
defaultContent.
Or apply the new defaults manually field-by-field. There's no bulk "reset to template" action today.
Template presets (future)
The structure above is designed to support named presets later — e.g. Premium Roofing, Storm Damage, Budget Replacement, Metal Roofing. Each preset would be a named bundle of:
- A section order (an entry in
PAGE_DEFAULTS) - Per-section
defaultContentoverrides - Optional theme preset (already supported via
lib/themePresets.js)
Nothing is built yet. When we add presets, the natural place is a
new lib/templatePresets.js modelled on lib/themePresets.js,
plus a new-client form picker that swaps in the chosen preset's
defaults before submit.
Thank-you page — conversion-focused defaults
The thank-you page is now the delivery of the tool's promise:
the homeowner just submitted the form, so the estimate is the
first thing they see and the next-step CTAs sit immediately under
it. Section order (PAGE_DEFAULTS["thank-you"]):
site_headerthank_you_hero— "Your Instant Roof Estimate Is Ready" + a one-line subhead. Tight by design so the estimate appears high on the page.quote_summary— the estimate card itself. Renders the schedule + call CTAs directly under the card.quote_details— "What Your Estimate Is Based On" grid.what_happens_next— 4-step roadmap with shorter, "you're in control" wording.testimonialsfaqfinal_cta— "Ready To Confirm Your Final Price?" with Schedule + Call buttons.trust_sectionsite_footer
Schedule + Call CTAs (estimate card)
QuoteSummary renders two CTAs under the estimate card:
- Primary — "Schedule My Free Inspection". Href is resolved
through this fallback chain:
- The client's
client_branding.calendar_url(admin-set). - The client's phone link (
tel:). - Hidden entirely if neither is configured.
- The client's
- Secondary — "Call Now". Uses the client's phone. Hidden when no phone is on file.
The labels are admin-editable per page via the schedule_label
and call_label fields on the quote_summary section's schema.
Calendar URL admin field
client_branding.calendar_url is added in migration 015 and
exposed in the admin Edit-client form's Overview tab alongside
phone / email / service area. Until migration 015 is applied:
- The form's "Calendar / booking URL" input still saves nothing on the deploy (the API tolerates the missing column with a log warning).
- The thank-you-page schedule CTA silently falls back to the phone link.
- The final_cta primary button on the thank-you page also falls back to the phone link.
After the migration is applied, paste the contractor's Calendly / GoHighLevel / HouseCallPro booking URL and the CTA deep-links to their booking flow.
Quote Summary extras
The estimate card now also surfaces (when present on the saved quote):
- Property address
- Roof size — square footage, rounded.
- Material — friendly label from the form. Hidden cleanly when the underlying value is missing.
The monthly-payment column collapses to a single-column layout when no monthly value is available, so we never render a "—" placeholder where a dollar amount belongs.
Fallback behaviour
| Missing data | What renders |
|---|---|
quote.monthlyPayment |
Monthly column hidden; price card spans full width |
quote.roofAreaSqft |
Roof-size row hidden |
quote.materialLabel |
Material row hidden |
quote.address |
Property row hidden |
brand.calendarUrl |
Schedule CTA falls back to phone link |
brand.phoneHref |
Call CTA hidden; if calendar is also missing, both CTAs hide |
quote is null entirely (no token in URL) |
QuoteSummary renders a friendly empty state with a link back to the form |
Page-specific default content
Some section types — currently just final_cta — ship different
starter copy on different pages. The mechanism is the optional
pageDefaultContent: { "thank-you": {...} } map on a section's
SECTION_META entry. When the default-section synthesiser
(lib/page.js#defaultSectionsForPage) seeds a section for the
thank-you page, it merges the page-specific override on top of
defaultContent. Use this anywhere a single section type wants
different starter strings per page; admins can still edit per
section after that.
Big picture
Every client gets the same template — same layout, same form, same sections — and customisation happens through three layers:
- Branding — colors, logo, contact info, license/warranty/financing
copy. Stored in
client_brandingandclient_assets. - Theme — derived automatically from the branding colors via
lib/theme.js#buildClientTheme. The output is a flat object of hex colors andr g bchannel strings, flattened into CSS variables that drive Tailwind tokens. - Page content — section-based, edited via the page builder.
Stored in
client_pages+page_sections. Legacy clients keep working fromclient_page_contentvia a synthesise-on-read fallback.
Logo
- Uploaded via the admin Assets section into Supabase storage.
- Persisted as a row in
client_assetswithasset_type = "logo". - Rendered in
SiteHeaderand (optionally)SiteFooterviacomponents/BrandLogoImage.jsx, which the dynamic page passes through the section registry. - If no logo is set, the header falls back to the client's company name as text.
Other supported asset types: favicon, hero, visual. The page
loader filters to is_active = true and orders by sort_order, so
soft-deleting an image without removing the row is supported.
Primary, secondary, accent, and footer colors
Admin sets up to four hex colors:
primary_color— typically the brand's main CTA color.secondary_color— usually the dark / anchor color (deep navy etc.).accent_color— pop color used sparingly.footer_color— optional override for the footer background.
The slot the admin types each color into is not authoritative.
buildClientTheme(branding) collects every supplied color into a
candidate pool and then assigns each role (CTA, dark, accent) to
whichever color scores best for that role. This means a Cobex-style
palette of navy + blue + green resolves to "blue CTA, navy dark,
green accent" regardless of which admin slot held which color. See
lib/theme.js for the scoring functions.
If only one color is provided, the missing roles are filled in by
darken/lighten variants of that color. If no color is provided, the
fallback is teal-700 (#0f766e), which is the original template
default.
Footer color override
If footer_color is set in client_branding, the footer background
uses it verbatim and the foreground text color flips automatically by
luminance. Without an override, the footer reuses the resolved dark
base color from the theme.
Theme presets
lib/themePresets.js defines a small list of designer-vetted palettes
the admin can apply with one click. Each preset is just a patch on the
form state's branding slice — no schema changes. Current presets:
- Recommended (Default) — calm teal.
- Bold Contractor — orange + slate + yellow.
- Premium Residential — teal + navy + sandy gold.
- Clean Modern — blue + slate + cyan.
- Trust & Finance — green + blue + gold.
Adding a new preset = one entry in the THEME_PRESETS array.
Button colors
Buttons inherit from the theme — primary CTAs use --brand-rgb with
--on-primary-rgb for the foreground; secondary uses --accent-rgb.
Both foregrounds are auto-flipped by luminance (light brand colors get
dark text, dark brand colors get white text). Components don't pick
button colors directly; they reference the Tailwind tokens
(bg-brand, text-on-primary, etc.) which are wired to the CSS
variables.
Header style
Currently a single header section type, site_header, that the admin
cannot remove (removable: false in lib/sections/meta.js). The
section renders the logo, the company name as a fallback, and the
phone number link. There are no variants on the header today — extending
it would mean adding variants in meta.js and branching in
components/sections/SiteHeader.jsx.
Footer style
Same shape as the header — one non-removable site_footer section. The
copy is editable (defaults to the disclaimer about estimates being
informational). Footer background follows the override-or-derive rule
above.
Trust badges
Two related section types:
trust_strip— icon-based row of 2–4 quick trust signals (5-star rated, licensed & insured, warranty, financing). Auto-populated from brand defaults when the items list is empty.trust_badges— visual logo / badge row for Google Reviews, BBB, Angi, certifications, awards. Each badge has an image, alt text, and optional click-through URL with per-item open-in-new-tab.
The two coexist on purpose; a page can have neither, one, or both.
Testimonials
Section type testimonials. Schema is a list of { author, body, rating } objects. Existing clients had their reviews migrated from
the legacy client_page_content.reviews field by the
synthesise-from-legacy fallback in lib/page.js.
Hero copy
The hero section drives the top of every landing page. Every hero
variant ships the live quote form — variants only change the layout
around the form, not whether it's visible. Available variants:
with-form— content left, form right (default).form-left— form left, content right.split-image— headline strip on top; image + form below.image-bg— full-bleed background image; form floats on top.centered— centered copy with the form stacked below.
Editable fields per the section schema: eyebrow, headline, subheadline, bullets (list), small reassurance line near the form, hero image URL, image alt text.
There's a separate thank_you_hero section type for the thank-you
page, with its own variants (centered, image-below, video-below,
split-media) and a CTA button list.
Financing copy
Two distinct surfaces:
- Trust copy —
financing_copyinclient_branding, shown alongside the warranty and licensed copy in the trust strip. - Monthly payment — calculated server-side via
lib/financing.js#monthlyPayment(principal)using a hard-coded default of 8.99% APR over 120 months. The result is cached on the quote row at submit time so the thank-you page renders instantly.
The financing defaults are not editable per-client today. Making them
editable means adding columns to client_pricing_rules (or a new
client_financing table) and reading them in
app/api/quotes/route.js.
Hero image settings (page builder)
The Hero section editor exposes per-page controls for how the hero
image is cropped, zoomed, overlaid, and sized — all without touching
code. The controls only render when the hero variant uses the image
(image-bg for background, split-image for the side panel); other
variants ignore them harmlessly.
Visual editor
The page-section editor surfaces these controls through a custom
visual panel (app/admin/clients/[clientId]/pages/[pageKey]/HeroImagePanel.jsx)
that bypasses the generic per-field renderer. Each control writes
directly into the section's content blob using the stable keys
listed below, so saved values from earlier dropdown-driven editors
continue to load and render unchanged.
The panel is organised into four collapsible groups, each with its own Reset action:
- Image framing — desktop and mobile side-by-side, each with:
- a 3×3 position grid (click a cell to anchor the image)
- a zoom slider (70% → 160% in 5% steps; 70%–95% zooms out,
100% maps to CSS
cover, 105%–160% zooms in)
- Overlay — overlay color picker with hex input + four quick swatches (Black / Navy / Slate / Dark brand), independent desktop and mobile darkness sliders (0%–90%), and a 2×3 visual gradient picker that previews each option using the live overlay color.
- Hero height — pill-button presets for desktop (Compact /
Standard / Tall / Full screen) and mobile (Compact / Standard /
Tall). Selected option fills dark; tooltip shows the
min-heightvalue applied. - Readability warning — surfaces inline when the desktop
overlay drops below 35% (
checkHeroReadabilityincomponents/sections/Hero.jsx). Tells the admin which control to adjust without blocking the save.
Controls reference (stable keys)
| Field | Editor UI | Effect | Variants that honour it |
|---|---|---|---|
image_url / image_alt |
Image upload + alt-text field (existing) | The image and its alt text. | image-bg, split-image |
image_position_desktop |
3×3 position grid | CSS background-position on desktop. |
both |
image_position_mobile |
3×3 position grid | Same, applied at max-width: 768px. |
both |
image_zoom_desktop |
Slider (70%–160%) | CSS background-size: 100% → cover; <100% letterboxes with overlay-coloured gaps; >100% zooms in (cropped by overflow:hidden). |
both |
image_zoom_mobile |
Slider (70%–160%) | Same, mobile breakpoint. | both |
overlay_color |
Color picker + hex field + 4 swatches | Hex string (#000000 default). Drives the overlay layer's color. |
image-bg only |
overlay_opacity_desktop |
Slider 0%–90% | Scales the overlay layer's opacity. Stored as the underlying float (e.g. "0.55"). |
image-bg only |
overlay_opacity_mobile |
Slider 0%–90% | Same, mobile breakpoint. Defaults to 70%. | image-bg only |
overlay_gradient |
Visual preset grid with live previews | none / left-to-right / right-to-left / top-to-bottom / bottom-to-top / radial-center. left-to-right is the default — matches the previous hand-rolled gradient. |
image-bg only |
height_desktop |
Pill buttons | compact (28rem) / standard (36rem) / tall (44rem) / full-screen (100vh). |
image-bg only |
height_mobile |
Pill buttons | compact (22rem) / standard (30rem) / tall (38rem). |
image-bg only |
Defaults — existing pages stay unchanged
| Field | Default |
|---|---|
image_position_desktop / _mobile |
center |
image_zoom_desktop / _mobile |
100% (= cover) |
overlay_color |
#000000 |
overlay_opacity_desktop |
0.6 |
overlay_opacity_mobile |
0.7 |
overlay_gradient |
left-to-right |
height_desktop |
standard |
height_mobile |
standard |
Tuned so existing client pages render visually identical to the pre-controls version. Editing any control changes only that one dimension.
How it renders
components/sections/Hero.jsx builds two CSS-variable sets:
buildSectionCssVars(image)— emits--hero-bg-pos-d,--hero-bg-pos-m,--hero-bg-size-d,--hero-bg-size-m,--hero-min-h-d,--hero-min-h-mon the section element.buildOverlayStyle(image)— emits inlinebackgroundColor/backgroundImage(for gradients) +opacityon the overlay layer, plus a--hero-overlay-opacity-mvariable.
app/globals.css binds those variables to real CSS properties
(background-size, background-position, min-height, opacity)
and swaps mobile vs. desktop values via a single
@media (max-width: 768px) block. Fallbacks chain so older saved
sections without these fields still render cover / center —
matching the previous behaviour.
Storage
Every control lives in the page-section content JSON blob —
no database migration. The keys above are stored verbatim on
page_sections.content. Saved values survive section reorders,
section duplications, and page-builder previews.
Variant compatibility
- Form right / Form left / Centered — no background image rendered; image controls are inert (they save fine but don't show up in the rendered output).
- Image + form (split-image) — image is a side panel. Position + zoom apply to that panel. Overlay + height controls are inert because the image isn't a full-bleed background.
- Form over background (image-bg) — every control applies.
The editor schema lists every field regardless of variant; the field labels make the variant-specificity clear ("Overlay darkness (desktop)", etc.) and unused values are harmless storage.
Readability check
checkHeroReadability(content) in components/sections/Hero.jsx
returns { passes, message } — it flags an overlay below 35%
opacity as risky for white hero text on a busy photo. The visual
panel calls this helper directly and renders the message inline
as an amber alert inside the Overlay group when the threshold
trips.
Live preview
The page-builder iframe preview at
/admin/clients/[id]/pages/[key]/preview renders the same Hero
component the public page uses, so all image controls update the
preview as they change. The brand-preview card in the new-client
wizard does not render the hero image (it's a brand-only
preview), so use the page-builder preview to validate hero image
tweaks.
CTA sections
The final_cta section type renders a dark CTA panel at the bottom of
the page. It currently uses the resolved dark background color from
the theme and the primary brand color for the button. The dark hero
HUD SVGs, the dark CTA panel, and a few footer accents are
intentionally not themed yet — they're still hardcoded gradients
in the section components.
Client-specific customisations
What the admin can change without code:
- Company info (name, phone, email, service area).
- Colors (primary, secondary, accent, footer).
- License / warranty / financing trust copy.
- Review rating + count badge.
- Every per-client field on every section (text, images, button URLs, bullets, items lists, FAQ Q&A, before/after images, …).
- Section order, visibility, and variant.
- Adding or removing any optional section (everything except
site_headerandsite_footer). - Pricing rules (see PRICING_LOGIC).
- Webhooks + GTM container ID + notification email/phone.
- Page title and meta description.
What currently requires code changes
- The form fields.
roofAge,stories,complexity,materialoptions are defined inlib/estimate.js. Adding a new question means updating both the form and the pricing rules. - Pitch on the form. The pitch multiplier is fully wired into
calculateEstimate, but the public form doesn't yet collect pitch. Default ofstandardis applied server-side. - Section types. Adding a new section type means:
- Add an entry to
SECTION_METAinlib/sections/meta.js(schema, defaults, variants). - Build the React component in
components/sections/. - Register the component in
lib/sections/registry.js. - (Optional) Add the type to
PAGE_DEFAULTSif it should appear on new pages by default.
- Add an entry to
- The hardcoded hero / CTA gradients and SVGs. These were left out of the Phase 1 theme rollout. Phase 2 should drive them off the same CSS variables.
- Financing assumptions (APR + term) — see above.
- Estimate mode presets — adding a fourth preset means editing
ESTIMATE_MODE_PRESETSinlib/estimate.js.
Keeping the template reusable across roofing clients
Some rules to keep in mind when adding things:
- Never hardcode a client's name, slug, color, or copy. If the only difference between two clients is a string, that string belongs in the section content / branding row, not in the component.
- Defaults belong in
lib/clientDefaults.jsorlib/sections/meta.js. The new-client form seeds itself from these. Keeping the canonical template here means a brand new client ships with sensible content instead of empty placeholders. - Section content shape is owned by the section schema. If you
add a field to a section, add it to the
schemaarray inSECTION_META[type]. The admin editor renders fields generically from that schema; no per-section editor code needed. - Section components must tolerate missing content. Page content for old clients is synthesised from legacy data, which won't have every newly-added field. Default to a sensible empty state if a field is missing.
- The hero must always include the form on
instant-quote.app/[clientSlug]/[pageKey]/page.jsinjects the form for any hero variant. Don't gate that on a section flag — it's the whole product. - Avoid bundling the full section component code into the admin.
lib/sections/meta.jsis the client-safe copy of section metadata; the admin editor only imports from there. Keep the meta import-light (no React imports) so the admin bundle stays small. - When in doubt, render off the resolved theme tokens (
bg-brand,text-text,border-border, etc.) rather than hex literals. That way every client gets the new component automatically tinted.