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).
pnpm install
pnpm dev # http://localhost:4321With 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.jsonPages 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:
@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:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&family=Public+Sans:wght@400;500;600;700&display=swap" />--font-displayis Archivo (headings, prices, the logo wordmark), used through thefont-displayclass.--font-sansis Public Sans (body text).
To swap a font, change the link and the family name in @theme:
@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>:
<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:
{ 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.astroandservice.astro(they are not read fromPRICES). - The service cards on
quote.astro: each<input type="radio" name="service" value="…">must match a key inPRICES(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.
Navigation, header and footer#
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:
<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:
- Start from
PRICES[service], for example plumbing is[129, 420]. - Apply the home size factor from
SIZE. Services inSIZE_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%. - Add every fixed add-on: the checked options with an
addvalue, in the order they're listed inOPTIONS, then the urgency'sadd(nextdayadds[75, 75]). - Multiply by every percentage: the checked options with a
multvalue, inOPTIONSorder, then the urgency'smult(emergencymultiplies by[1.5, 1.5]). Multipliers apply to the whole running total, add-ons included. - 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:
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:
<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" andpctText()shows "+50%" or "+15–35%", so changing a number inOPTIONSorURGENCYupdates the breakdown too. - The urgency cards in
quote.astroalso show "+$75" and "+50%" as text. Update them with the data. - Linking to
/quote?service=hvacpreselects 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>) hasdata-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 (viafindArea()), 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>.
Project gallery#
On /projects, the [data-gallery] section holds:
- filter buttons with
data-filter(all,kitchen-bath,heating-plumbing,electrical,outdoor) andaria-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:
<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:
<article class="card reveal flex flex-col p-6 " data-review data-service="plumbing" data-stars="5" data-date="2026-09-28">data-servicematches an<option>in the service select ([data-review-service]).data-starsis 1 to 5. The star buttons (data-stars-filterall,5,4,3) filter exactly 5 or 4 stars, and "3" means 3 stars or fewer.data-dateisYYYY-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:
<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:
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:
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
titleanddescriptionto<Layout>, which writes<title>, the meta description,og:title,og:description,og:site_name, asummary_large_imageTwitter card and atheme-color. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://handy.example.com). Set it to your domain when you build:shSITE_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 apublic/robots.txt. For a local business, consider aLocalBusiness(orHomeAndConstructionBusiness) JSON-LD block inindex.astrowith 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, labellednavelements,mainandfooter. - Visible focus styles (
:focus-visibleoutline inglobal.css). - A menu button with
aria-expanded,aria-controlsand 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-describedbyandaria-invalid, focus on the first error, focus on success messages. - Toggle buttons with
aria-pressedfor 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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/quoteservesquote.html. - GitHub Pages: use the official Astro GitHub Action. For a
project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/quotetoquote.html, changebuild.formatto"directory"inastro.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[].zipsas a five-character string, for example"80205", not80205. - The map doesn't highlight a matching area. The polygon's
data-areamust equal the areanameexactly, including spaces and capitals. - The calculator breaks after adding a service. Each
valueon the service radios must be a key inPRICESandSERVICE_NAMES. Clearhandy-quote-v1if an old saved value is in the way. - The lightbox shows the wrong project. The
data-lightbox-openindex must match the entry's position in thedata-lightbox-datalist. - 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 withurl()fromsrc/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_URLto 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.