Freeframe

Documentation

Handy

v1.0.1Updated Oct 9, 2026A high-trust home services site with an instant quote calculator and service-area checker.

Overview#

Handy is a ten-page website for a home services company: plumbing, electrical, heating and cooling, carpentry, painting and remodels. The demo brand is Brightline Home Services, a fictional company in Denver, Colorado. Highlights are an instant quote calculator that turns into a booking request, a ZIP code service-area checker with a coverage map, a before/after slider, a filterable project gallery with a lightbox, a reviews page with filters and sorting, and a sticky mobile call bar. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.0. It builds to static HTML files with no UI framework and one small script, and gives the same quotes as the HTML and Next.js versions for the same choices. Live demo: freeframe.dev/templates/handy.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:4321

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/handy into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File Notable sections and features
/ src/pages/index.astro Hero with ZIP checker, trust badges, services, process, reviews, projects, FAQ (<details>)
/services src/pages/services.astro Price tables for every service, the Brightline Care membership
/service src/pages/service.astro Water heaters in depth: what's included, pricing, before/after slider, FAQ
/quote src/pages/quote.astro Quote calculator with live estimate and breakdown, booking request form
/areas src/pages/areas.astro SVG coverage map, ZIP checker that highlights the matching area, cities and ZIP codes list
/projects src/pages/projects.astro Category filters, project cards, lightbox with previous/next
/reviews src/pages/reviews.astro Rating breakdown, service and star filters, sort
/about src/pages/about.astro Story, values, team and licenses
/contact src/pages/contact.astro Contact form, emergency line, hours
404 src/pages/404.astro Links back into the site (most hosts show 404.html automatically)

Every page shares the 24/7 emergency bar, sticky header, footer, mobile call bar and back-to-top button.

Project structure#

handy/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), header, <main>, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    skip link, 24/7 bar, logo, desktop and mobile navigation
│  │  └─ SiteFooter.astro    footer columns and the mobile call bar
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity, plus the service areas and quote price data
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (@theme tokens) and component styles
├─ public/img/               SVG artwork, favicon.svg, og-image.svg and og-image.png
├─ astro.config.mjs          site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

Pages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to the browser, plus one inline line in the layout that adds a js class to <html>. Layout.astro imports both scripts, and Astro bundles them into one small module.

Customising#

Brand and colours#

The design tokens are in the @theme block at the top of src/styles/global.css. Tailwind turns each --color-* token into utilities such as bg-navy-900, text-sun-400 and border-navy-100.

Token Default Used for
--color-navy-50 to --color-navy-950 #eef4fa to #0a1d31 Brand navy: header, footer, headings, buttons
--color-sun-100 to --color-sun-600 #fff4cc to #c98a00 Safety yellow: call-to-action buttons, highlights, map match
--color-ink-600, --color-ink-700 #4a5a6b, #33414f Body text
--color-paper #f7f5f0 Warm section backgrounds

The yellow is meant for fills behind navy text or for text on navy. It doesn't have enough contrast as text on white, so keep it off white backgrounds.

To change the brand navy, replace the scale:

css
@theme {
  --color-navy-700: #14532d;
  --color-navy-800: #0f3f22;
  --color-navy-900: #0b2e19;
  --color-navy-950: #071f11;
}

Change the whole scale (50 to 950) so tints and hover states stay in step. Tailwind rebuilds automatically in pnpm dev and pnpm build. A few colours are written directly in the markup: the logo mark in SiteHeader.astro, SiteFooter.astro and public/img/favicon.svg, the SVG artwork, and the accent in template.json. The browser theme colour is site.themeColor in src/lib/site.ts.

Typography#

Two Google Fonts are loaded by a <link> in src/layouts/Layout.astro:

html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&amp;family=Public+Sans:wght@400;500;600;700&amp;display=swap" />
  • --font-display is Archivo (headings, prices, the logo wordmark), used through the font-display class.
  • --font-sans is Public Sans (body text).

To swap a font, change the link and the family name in @theme:

css
@theme {
  --font-display: "Barlow Condensed", "Arial Narrow", ui-sans-serif, system-ui, sans-serif;
}

Content#

Most text is markup in the page files. The page title, description and current menu item are props on <Layout>:

html
<Layout title="Instant quote — Brightline Home Services" description="Get an instant price range..." current="quote">

The data the scripts use is at the top of src/scripts/main.ts:

Constant What it holds
AREAS Service areas: name, zips (array of 5-digit strings), tier ("core" or "extended"), eta
PRICES [low, high] starting range per service key
SERVICE_NAMES Display name per service key
SIZE, SIZE_NAMES Home size multipliers and labels (small, medium, large, xl)
SIZE_SCALED Services whose price scales fully with home size
OPTIONS Add-ons with a fixed add: [low, high] or a multiplier mult: [low, high]
URGENCY standard, nextday (add) and emergency (mult)
WINDOW_NAMES Arrival window labels used in the booking summary

To add a service area, add an entry to AREAS:

ts
{ name: "Parker", zips: ["80134", "80138"], tier: "extended", eta: "Next day" },

Then add the matching row to the "Cities and ZIP codes" list in src/pages/areas.astro and, if you want it on the map, a <polygon> with class="area-shape area-extended" (or area-core) and data-area="Parker" in the map SVG. The data-area value must equal the name exactly. The eta field isn't shown anywhere yet; the checker message comes from tier (core is "same-day", extended is "next-day").

Contact details appear in the header, footer and several pages. Search the project for (303) 555-0148, +13035550148 (used in tel: links), hello@brightline.example and 2150 Larimer Street, Denver, CO 80205.

Other content that is plain markup and must be edited by hand:

  • The price tables on services.astro and service.astro (they are not read from PRICES).
  • The service cards on quote.astro: each <input type="radio" name="service" value="…"> must match a key in PRICES (see Quote calculator).
  • Project cards and the lightbox data on projects.astro (see Project gallery).
  • Review cards and the rating breakdown on reviews.astro (see Reviews).

Text containing literal curly braces must be escaped in .astro files ({"{"}), because braces start an expression.

Images and artwork#

All artwork is SVG in public/img/. Replace any file with a photo (JPG or WebP) and update the src, keeping the same aspect ratio and the width and height attributes. Link images with url() so they work under a base path: src={url("/img/hero.jpg")}.

Files Size Ratio
hero.svg 1200×900 4:3
project-*.svg (8 files) 800×600 4:3
before.svg, after.svg 900×560 Both must be the same size
team-*.svg (4 files) 240×240 1:1
van.svg 320×160 2:1
favicon.svg 32×32 1:1

og-image.png (1200×630) is the social sharing image. Its editable source is og-image.svg: change the SVG, then export it again as a 1200×630 PNG with any image editor.

Decorative images use alt="". When you add a meaningful photo, write alt text that describes it.

The 24/7 bar, header and mobile menu are in src/components/SiteHeader.astro; the footer and the mobile call bar are in src/components/SiteFooter.astro. Every page gets them from Layout.astro.

  • Desktop menu: the <nav aria-label="Main"> list inside #site-header.
  • Mobile menu: the <nav id="mobile-menu" aria-label="Mobile"> list.
  • Footer: the link columns in SiteFooter.astro.

Menu links get aria-current="page" (which the styles highlight) when the page's current prop matches:

html
<a href={url("/quote")} class="btn btn-sun hidden sm:inline-flex" aria-current={current === "quote" ? "page" : undefined}>Free quote</a>

To add a page, create src/pages/financing.astro with <Layout title="…" description="…" current="financing"> around its content, then add a link with the matching aria-current expression to both menus. Always write internal links as href={url("/financing")} so they keep working under a base path. The mobile menu closes on Escape, on link click and when the window grows to the desktop breakpoint.

Features#

All interactive code is in src/scripts/main.ts, one commented block per feature. Each block looks for its elements by id or data-* attribute and does nothing if they're missing.

Quote calculator#

On /quote, the [data-quote] section holds the calculator form ([data-quote-calculator]) and the estimate panel ([data-estimate], [data-estimate-note], [data-breakdown]). Every change recalculates the range with estimate(state) and saves the choices (see Saved data).

How the range is worked out:

  1. Start from PRICES[service], for example plumbing is [129, 420].
  2. Apply the home size factor from SIZE. Services in SIZE_SCALED (painting, remodel, hvac) use the full factor. Repairs use only 30% of the difference: 1 + (factor - 1) * 0.3, so a large home adds 7.5% to a plumbing job instead of 25%.
  3. Add every fixed add-on: the checked options with an add value, in the order they're listed in OPTIONS, then the urgency's add (nextday adds [75, 75]).
  4. Multiply by every percentage: the checked options with a mult value, in OPTIONS order, then the urgency's mult (emergency multiplies by [1.5, 1.5]). Multipliers apply to the whole running total, add-ons included.
  5. Round the low figure down and the high figure up to the nearest $10.

The result doesn't depend on the order the visitor ticks the options in, and the HTML, Next.js and Astro versions give the same figures for the same choices.

Choices Estimate
Plumbing, 1,000–2,000 sq ft, standard $120 – $420
Plumbing, 2,000–3,000 sq ft, emergency $200 – $680
Painting, 2,000–3,000 sq ft, premium materials, haul-away, next day $830 – $4,350

To change prices, edit the data:

ts
const PRICES: Record<string, Range> = {
  plumbing: [149, 450],
  // ...
};

To add a service, add the key to PRICES and SERVICE_NAMES (and to SIZE_SCALED if it's whole-home work), then add a matching radio card to the calculator in src/pages/quote.astro:

html
<label class="option-card"><input type="radio" name="service" value="roofing" class="sr-only"> …</label>

To add an option, add it to OPTIONS and an <input type="checkbox" name="options" value="…"> to the form.

Things to know:

  • The breakdown lines are built from the data: addText() shows "+$75" or "+$90–140" and pctText() shows "+50%" or "+15–35%", so changing a number in OPTIONS or URGENCY updates the breakdown too.
  • The urgency cards in quote.astro also show "+$75" and "+50%" as text. Update them with the data.
  • Linking to /quote?service=hvac preselects a service. Unknown values are ignored.
  • The calculator radios and checkboxes are real form controls, so it works with the keyboard. The estimate has aria-live="polite", so screen readers hear the new range after each change.
  • The estimate panel (the sticky <aside>) has data-back-to-top-hide, so the back-to-top button hides rather than covering it.

Booking request#

Below the calculator, [data-request-form] collects name, phone, email, ZIP code, preferred date, arrival windows and a description. On a valid submit it hides the form and shows [data-request-success] with:

  • a demo reference, BL- plus four random digits ([data-request-ref]),
  • a summary ([data-request-summary]) with the service, date, chosen windows, the current estimate, whether the ZIP is in a service area (via findArea()), and the phone number.

The success panel receives focus so screen readers announce it. "Start another request" ([data-request-reset]) resets the form and shows it again. The request is not sent anywhere; see Forms.

The arrival windows are checkboxes inside <fieldset data-group="windows">, and at least one must be checked. Their labels in the summary come from WINDOW_NAMES; keep the keys in step with the checkbox values.

ZIP code checker#

Any <form data-zip-checker> with an <input> and a .zip-result element becomes a checker. It's on / and /areas. On submit it checks for five digits, then looks the ZIP up in AREAS:

  • core area: "Yes! We serve Denver (80205) with same-day service."
  • extended area: "Yes, we serve Golden (80401) with next-day service."
  • not found: a message asking the visitor to call.

The result element has role="status" and aria-live="polite". An invalid ZIP sets aria-invalid and returns focus to the input.

Coverage map#

On /areas, the SVG with class area-map contains one <polygon class="area-shape …" data-area="Name"> per area. When a ZIP matches, the script adds is-match to that polygon and has-match to the map, which fades the others and outlines the match (styles in global.css under .area-shape). The map is role="img" with a title and description; the text list below it is the accessible equivalent, so keep both up to date.

Before/after slider#

On /service, a [data-before-after] box contains the after image, the before image inside .before-after-clip, a handle and a transparent <input type="range"> covering the box. Moving the range sets the --pos custom property, which clips the before image. Because it's a native range input, it works with mouse, touch and the arrow keys, and it gets a visible focus ring. The script sets aria-valuetext such as "60% before, 40% after".

To reuse it, copy the whole [data-before-after] block, give the input a unique id and update its <label for>.

On /projects, the [data-gallery] section holds:

  • filter buttons with data-filter (all, kitchen-bath, heating-plumbing, electrical, outdoor) and aria-pressed,
  • project cards, <li data-project data-category="…">, each with a <button data-lightbox-open="N">,
  • a count ([data-gallery-count], aria-live="polite"),
  • a <dialog data-lightbox> and the lightbox data in a JSON script.

The JSON is written by Astro so the image paths include the base path:

html
<script type="application/json" data-lightbox-data is:inline set:html={JSON.stringify([
  { "src": url("/img/project-kitchen.svg"), "title": "Wash Park kitchen remodel", "meta": "Denver · 80209 · 7 weeks · $58,400" },
  ...
])}></script>

data-lightbox-open="N" is the index into that list, so card 0 opens the first entry. To add a project, add a card <li> with the next index and a data-category that matches a filter button, and an entry in the same position of the list, e.g. { "src": url("/img/project-roof.jpg"), "title": "Cedar roof repair", "meta": "Arvada · 80003 · 4 days · $6,800" }.

The lightbox is a native <dialog>: Escape closes it, focus moves to the close button on open and back to the card on close, the left and right arrow keys step through the projects that are visible under the current filter, and clicking the backdrop closes it.

Reviews#

On /reviews, [data-reviews] filters and sorts the cards in [data-review-list]. Each card is an <article data-review> with three attributes:

html
<article class="card reveal flex flex-col p-6 " data-review data-service="plumbing" data-stars="5" data-date="2026-09-28">
  • data-service matches an <option> in the service select ([data-review-service]).
  • data-stars is 1 to 5. The star buttons (data-stars-filter all, 5, 4, 3) filter exactly 5 or 4 stars, and "3" means 3 stars or fewer.
  • data-date is YYYY-MM-DD; "Newest" sorts on it, and "Highest" and "Lowest" break ties with it.

The count ([data-review-count]) is a live region; [data-review-empty] shows when nothing matches, and its [data-review-clear] button resets all filters. The rating breakdown bars and the average at the top are static markup, so update them when you change the reviews.

Header, mobile menu and scroll reveals#

The header gets an is-scrolled shadow after 8px of scrolling. The menu button toggles aria-expanded and its label. Elements with the reveal class fade in as they scroll into view; when one is revealed, all earlier ones are too, so a fast jump never leaves gaps. Reveals are skipped with reduced motion or without IntersectionObserver, and the content is only hidden after the inline script in the <head> adds the js class, so it stays visible without JavaScript. Elements with data-year show the current year.

Mobile call bar#

Below 1024px, .mobile-call-bar (in SiteFooter.astro) is fixed to the bottom of the screen with "Call now" and "Free quote" buttons. It has data-back-to-top-avoid, so the back-to-top button sits above it. To remove it, delete the <div class="mobile-call-bar …"> block. It's hidden when printing.

Back to top#

src/scripts/back-to-top.ts adds a round button after about one screen of scrolling. It scrolls to the top (instantly with reduced motion), then moves focus to <main id="main">. It lifts above toasts and anything marked data-back-to-top-avoid, hides while it would cover a fixed or sticky element marked data-back-to-top-hide, and steps aside while any link whose text contains "Back to top" (the footer link) is on screen. Restyle it with the .back-to-top rules in global.css; to remove it, delete the file, its import in Layout.astro and those rules.

Forms#

There are three kinds of form. All validate in the browser and none send anything until you connect them.

Form Where Validation
ZIP checker (data-zip-checker) /, /areas 5 digits; looks up AREAS only, nothing to send
Booking request (data-request-form) /quote Name, phone (10 digits), email, ZIP (5 digits), date (today or later), at least one window, details (20+ characters)
Contact (data-contact-form) /contact Name, email, optional phone (10 digits if filled), topic, message (10+ characters)

Validation is in wireForm() and check() in src/scripts/main.ts. It reads standard attributes (required, type="email", minlength) and a data-validate attribute:

data-validate Rule
phone At least 10 digits
phone-optional At least 10 digits if anything is typed
zip Exactly 5 digits
future-date Today or later; the script also sets the input's min to today

A <fieldset data-group="name"> requires at least one checked box. Errors appear under each field as .field-error, linked with aria-describedby, and the field gets aria-invalid="true". On a failed submit, focus moves to the first invalid field. Fields are rechecked on blur and while you correct them.

Connecting to Formspree#

wireForm(form, onValid) calls onValid(form) only after validation passes. The comment "To send for real, POST new FormData(form) to your form service here." marks the place. For the contact form, give the form an action in src/pages/contact.astro:

html
<form class="mt-6 grid gap-5 sm:grid-cols-2" data-contact-form novalidate action="https://formspree.io/f/your-form-id" method="POST">

and post it in the onValid callback of the "Contact form" block in main.ts:

ts
document.querySelectorAll<HTMLFormElement>("[data-contact-form]").forEach((form) => {
  wireForm(form, async () => {
    const ok = form.querySelector<HTMLElement>("[data-success]");
    const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
    if (!res.ok) return alert("Sorry, your message could not be sent. Please call us.");
    form.querySelectorAll<FormField>("input, select, textarea").forEach((f) => {
      f.value = f instanceof HTMLSelectElement ? f.options[0].value : "";
    });
    ok?.classList.remove("hidden");
    ok?.focus();
  });
});

Read the form data before resetting the fields, as above. For the booking request, do the same in the wireForm(reqForm, …) callback. The calculator choices are in a separate form, so add them to the data you send:

ts
const data = new FormData(form);
data.append("service", state.service);
data.append("size", state.size);
data.append("options", state.options.join(", "));
data.append("urgency", state.urgency);
data.append("estimate", money(r.low) + " – " + money(r.high));
await fetch("https://formspree.io/f/your-form-id", { method: "POST", body: data, headers: { Accept: "application/json" } });

To run your own server code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/request.ts with export const prerender = false, then post to it. The demo reference number is random; replace it with the ID your booking system returns, or remove it.

Saved data (localStorage)#

Key Shape Written
handy-quote-v1 {"service": "plumbing", "size": "medium", "options": [], "urgency": "standard"} On every calculator change

readQuote() validates the saved value on load: service, size and urgency must be keys of PRICES, SIZE and URGENCY, and options keeps only known, unique keys of OPTIONS. Anything else falls back to DEFAULT_QUOTE. Storage errors (private browsing, blocked storage) are ignored. A ?service= in the URL overrides the saved service.

To reset it, delete the key in your browser's developer tools (Application, Local Storage) or run localStorage.removeItem("handy-quote-v1"). If you rename services or change the shape, change QUOTE_KEY (for example to handy-quote-v2) so old values are ignored. To stop saving, remove the saveQuote(state); call in readForm.

SEO and sharing#

  • Each page passes title and description to <Layout>, which writes <title>, the meta description, og:title, og:description, og:site_name, a summary_large_image Twitter card and a theme-color.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://handy.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://www.your-company.com pnpm build
  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • For a sitemap, add @astrojs/sitemap (pnpm astro add sitemap) and a public/robots.txt. For a local business, consider a LocalBusiness (or HomeAndConstructionBusiness) JSON-LD block in index.astro with your name, phone, address, opening hours and service area.

Accessibility#

Built in:

  • A "Skip to content" link targeting <main id="main"> on every page, and one <h1> per page.
  • Landmarks: header, labelled nav elements, main and footer.
  • Visible focus styles (:focus-visible outline in global.css).
  • A menu button with aria-expanded, aria-controls and a changing label; Escape closes the menu.
  • Live regions for ZIP results, the estimate, the gallery count and the review count.
  • Form errors linked with aria-describedby and aria-invalid, focus on the first error, focus on success messages.
  • Toggle buttons with aria-pressed for the gallery and star filters.
  • A native <dialog> lightbox with focus return, and a native range input for the before/after slider.
  • Reduced motion: no reveal animations, no pulsing dot in the emergency bar, instant scrolling.

When editing, keep one <h1> per page, give every new input a <label>, keep yellow text off white, and write alt text for meaningful photos.

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as quote.html rather than quote/index.html (build.format: "file" in astro.config.mjs), and links point to /quote without a trailing slash. Tailwind scans src/, including the class names written in main.ts (such as text-emerald-700 in the ZIP checker).

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /quote serves quote.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /quote to quote.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/handy pnpm build. It becomes Astro's base, and every link written with url() includes it.

demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.

Troubleshooting#

  • A ZIP code says it's outside the area. It must be listed in AREAS[].zips as a five-character string, for example "80205", not 80205.
  • The map doesn't highlight a matching area. The polygon's data-area must equal the area name exactly, including spaces and capitals.
  • The calculator breaks after adding a service. Each value on the service radios must be a key in PRICES and SERVICE_NAMES. Clear handy-quote-v1 if an old saved value is in the way.
  • The lightbox shows the wrong project. The data-lightbox-open index must match the entry's position in the data-lightbox-data list.
  • The quote page shows $129 before the script runs and $120 after. The static text in [data-estimate] is only a placeholder; the script rounds to $10. Update the placeholder to match if you change prices.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and image paths with url() from src/lib/site.ts.
  • Two words run together after editing. Astro compresses whitespace in markup; when text continues on the next line before an inline element such as <strong>, keep them on one line or add {" "}.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. Brightline Home Services, its team and its customers are fictional. Archivo and Public Sans are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation