Freeframe

Documentation

Handy

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

Overview#

Handy is a high-trust website for a home services company (plumbing, electrical, heating and cooling, carpentry, painting and remodels), built as a WordPress block theme. The demo brand is Brightline Home Services, a fictional company in Denver, Colorado. The theme has a home page with a ZIP code service-area checker, a services page with price tables, a service page (water heaters) with a before/after slider, an instant quote calculator that turns into a booking request, a service areas page with a coverage map, a filterable project gallery with a lightbox, a reviews page with filters and sorting, an about page, a contact page and a 404 page, plus a 24/7 emergency bar and a sticky mobile call bar on every page. It is version 1.0.0 (see template.json and style.css), works with WordPress 7.1 and PHP 7.4 or newer, and is the WordPress version of the HTML template of the same name. See it live at freeframe.dev/templates/handy.

Everything you see is made of core WordPress blocks arranged in block patterns, so you edit text, links, buttons, lists and images in the block editor without touching code. Colors, the fonts and font sizes are design tokens in theme.json: change them in Appearance > Editor > Styles and the whole site follows.

All prices, license numbers, reviews, projects and people are placeholder content. Replace them with your company's own information.

Quick start#

Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.

  1. Download handy.zip from freeframe.dev/templates/handy (the WordPress tab).
  2. In your WordPress admin go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip and click Install Now, then Activate. The theme is listed as Freeframe Handy.
  3. Go to Appearance > Handy setup and click Create the demo pages. This creates the Home, Services, Water heaters, Instant quote, Service areas, Projects, Reviews, About and Contact pages from the theme's page patterns, sets Home as the front page (Settings > Reading) and creates the header menu. Pages that already exist with the same slug are left alone, so it is safe to click it again.
  4. Set the site title to your company's name in Settings > General (the demo uses "Brightline Home Services"), then edit any page in Pages, and the header, footer and templates in Appearance > Editor.

You can also try the theme without installing anything: the Try it in WordPress button on freeframe.dev opens it in WordPress Playground (WordPress running in your browser) with the demo pages already set up.

To work on the theme's files locally you need Node.js 24 and pnpm; WordPress itself runs in WordPress Playground, so there is no PHP, web server or database to install:

sh
pnpm install
pnpm dev     # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400

pnpm dev signs you in as admin (password password) and creates the demo pages. The site is rebuilt from scratch every time you start it; your edits to theme files are live, edits made in the WordPress admin are not kept.

Pages#

Each page is a page pattern (in Patterns > Handy pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Handy.

Page Slug Page pattern Sections (pattern files in patterns/)
Home / page-home.php home-hero (hero with ZIP code checker and rating), trust-bar, home-services, how-it-works, home-reviews (rating breakdown and three reviews), home-projects, home-faq, emergency-cta
Services /services/ page-services.php services-hero (with links to each category), services-list (price tables for six services, the Brightline Care membership), emergency-cta
Water heaters /service/ page-service.php service-hero (breadcrumb, before/after slider), service-details (what's included, price table, FAQ, sticky sidebar), emergency-cta
Instant quote /quote/ page-quote.php quote-hero, quote-calculator (calculator, live estimate, booking request)
Service areas /areas/ page-areas.php areas-hero, area-finder (coverage map, ZIP code checker, cities and ZIP codes), emergency-cta
Projects /projects/ page-projects.php projects-hero, project-gallery (filters, project cards, lightbox), emergency-cta
Reviews /reviews/ page-reviews.php reviews-hero, reviews-list (rating breakdown, filters, sort, twelve reviews), emergency-cta
About /about/ page-about.php about-hero, story (with timeline), values, team, licenses, emergency-cta
Contact /contact/ page-contact.php contact-hero, contact-details (contact form, emergency line, hours)
404 any missing URL templates/404.html 404

Posts use templates/single.html; the blog, archives and search results use templates/index.html, templates/archive.html and templates/search.html.

Project structure#

freeframe-handy/
  style.css                 theme header (name, version, requirements, license); no styles
  theme.json                design tokens: palette, fonts, font sizes, spacing, layout widths, button
  functions.php             loads the files in inc/
  inc/setup.php             stylesheet and script, editor styles, the "js" class script, script strings,
                            browser bar colour and favicon, pattern categories
  inc/icons.php             registers assets/icons/*.svg for the Icon block
  inc/icon-labels.php       the icons' names shown in the editor
  inc/block-styles.php      button block styles
  inc/navigation.php        current-page marker (aria-current) on the header's quote button
  inc/site-setup.php        Appearance > Handy setup: creates the demo pages and the menu
  templates/                page.html (pages), page-with-title.html, single.html, index.html,
                            archive.html, search.html, 404.html
  parts/                    header.html and footer.html (they show the header and footer patterns)
  patterns/                 every section and page (PHP files with block markup)
  assets/css/theme.css      the compiled stylesheet (do not edit: built from src/)
  assets/css/editor.css     the same for the block editor (generated by src/editor-css.mjs)
  assets/js/main.js         the site's JavaScript: the block adapter, data (service areas, prices),
                            header, reveals, forms, ZIP checker, slider, quote, gallery, reviews,
                            back to top
  assets/js/editor.js       block editor tweaks
  assets/icons/             SVG icons for the Icon block (including the star ratings)
  assets/img/               hero, project, team, before/after and van artwork, logos, favicon,
                            social image
  assets/fonts/             the Public Sans and Archivo font files and their licenses (OFL.txt)
  src/theme.css             the Tailwind CSS source of assets/css/theme.css
  src/icons.css             icons drawn before or after text with CSS (generated)
  src/editor-css.mjs        writes assets/css/editor.css after each build
  languages/                translations of the theme (see Translations)
  blueprint.json            WordPress Playground setup used by `pnpm dev`
  screenshot.png            the picture shown in Appearance > Themes
  package.json              the CSS build and the local WordPress (Node.js tooling)
  readme.txt, README.md, DOCS.md, CHANGELOG.md, LICENSE.txt

Customising#

Brand and colours#

The colors are in theme.json (settings.color.palette) and in Appearance > Editor > Styles > Colors > Palette:

Colors Used for
Navy 50 to Navy 950 the brand navy: header text, headings, the emergency bar, the footer, navy buttons, borders, tints and focus rings
Sun 100 to Sun 600 the safety yellow: the main call-to-action buttons, highlights, stars, the emergency call to action, next-day areas on the map
Ink 600 secondary text
Contrast body text (also the page's text colour; the stylesheet's ink-700)
Paper warm section backgrounds
Base, White, Black the page background, and plain colors for your own blocks

The theme's stylesheet reads these colors from the palette, so changing Navy 900 in Styles recolors every navy button, heading and the footer at once. Change the whole navy scale (50 to 950) so tints and hover states stay in step. In theme.json:

json
{ "slug": "navy-900", "name": "Navy 900", "color": "#0f2a44" }

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.

Handy has no dark mode. A few colors are written into files rather than read from the palette: the browser bar colour (<meta name="theme-color"> in freeframe_handy_head_meta() in inc/setup.php), the plus icon of the questions and the map's fixed colors (background, river and workshop marker) in the area-finder pattern, the grey star in assets/icons/stars-4.svg and stars-3.svg, the logos (logo.svg, logo-footer.svg, favicon.svg) and the artwork in assets/img/.

Typography#

Text uses Public Sans (variable, weights 400 to 700) and headings, prices and the logo wordmark use Archivo (variable, weights 500 to 900), bundled in assets/fonts/ and declared in theme.json (settings.typography.fontFamilies), so no request goes to Google. The class font-display uses Archivo. To use other fonts, install them in Appearance > Editor > Styles > Typography > Fonts (the Font Library) and select them, or replace the fontFace entries in theme.json; the stylesheet reads --wp--preset--font-family--public-sans and --wp--preset--font-family--archivo, so rename those slugs only together with src/theme.css.

Font sizes are the presets in theme.json (xs 0.75rem to 9xl 8rem, the Tailwind scale). The stylesheet uses them too: changing Large in Styles changes every text-lg in the design.

Content#

Edit pages in Pages: click any text to change it, select a button to change its link, select an image and use Replace. Sections are groups; open List View (the stacked lines icon at the top left) to select a section, move it, duplicate it or delete it. To add a section from another page, use the block inserter (+) > Patterns > Handy.

The company name, phone number, email and address appear in the header, footer and several sections. Search for (303) 555-0148 and +13035550148 (used in tel: links: the header's emergency bar, the footer and its mobile call bar, the emergency-cta, home-hero, service-hero, service-details, story and contact-details patterns), hello@brightline.example and 2150 Larimer Street, Denver, CO 80205 (footer and contact-details). The footer and the mobile call bar are edited once in the footer pattern; the call to action is copied into each page, so edit it on every page that has it. The booking request's summary uses the phone number the visitor typed.

Questions (home page and the water heaters page) are Details blocks with the class faq: the summary line holds the question, the inside holds the answer. To add one, duplicate a Details block in List View.

Some content is in Custom HTML blocks, because blocks can't express it or a script drives it: edit those in the block's HTML view.

Pattern Custom HTML What it is
home-hero, area-finder the ZIP code checker forms see ZIP code checker
service-hero the breadcrumb, the before/after slider see Before/after slider
services-list (six), service-details the price tables plain tables: edit the rows by hand (they are not read from PRICES)
quote-calculator the calculator form, the estimate panel (aside), the booking request form and its success panel see Quote calculator and Booking request
area-finder the coverage map card (the SVG map and its legend) see Coverage map
project-gallery the whole gallery: filter buttons, count, project buttons and the lightbox see Project gallery
reviews-list the rating filter buttons, the count, the service and sort card, the empty state see Reviews
contact-details the contact form see Forms
header the menu button a button driven by assets/js/main.js

The cities and ZIP codes list on the Service areas page, the rating breakdowns, the opening hours and the review cards are ordinary blocks.

Blocks can't store data- attributes, so a few blocks carry a class (Block settings > Advanced > Additional CSS class(es)) that the script or stylesheet reads. The first part of assets/js/main.js (marked "WordPress: blocks → the data attributes this script expects") turns each class ffd-<name> into data-<name> and ffd-<name>--<value> into data-<name>="<value>" before the rest of the script runs:

  • ffd-quote on the quote section, ffd-reviews and ffd-review-list on the reviews section, ffd-review ffd-service--<key> ffd-stars--<n> ffd-date--<yyyy-mm-dd> on each review card (see Reviews).
  • ffd-back-to-top-hide on the water heaters page's sticky sidebar and ffd-back-to-top-avoid on the mobile call bar (see Back to top).
  • ff-aria-hidden on a decorative separator (the "·" in the home hero) hides it from screen readers.
  • reveal fades a block in as it scrolls into view.
  • link-card on a group makes the whole group clickable: the first link inside it stretches over the group (the logos, the home page's project cards, the related services).
  • ff-glow-left and ff-glow-right (the soft yellow glows behind the page headers) are drawn by the stylesheet.

Quote prices and services#

The quote calculator's data is at the top of assets/js/main.js (the "Data" block):

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
js
var PRICES = { plumbing: [129, 420], electrical: [119, 380], hvac: [99, 650], carpentry: [180, 900], painting: [450, 2400], remodel: [9800, 32000] };
var URGENCY = { standard: { label: __('Standard (within 3 days)') }, nextday: { label: __('Next-day visit'), add: [75, 75] }, emergency: { label: __('Emergency, within 2 hours'), mult: [1.5, 1.5] } };

Change a number and the estimate and its breakdown follow. Then update the text that repeats it by hand: the "+$75" and "+50%" on the urgency cards in the calculator's Custom HTML block, the placeholder estimate $120 – $420 in the estimate panel (shown until the script runs), and the price tables on the Services and Water heaters pages. Make the change in a child theme so a theme update doesn't undo it (see Child themes).

To add a service, add the key to PRICES and SERVICE_NAMES (and to SIZE_SCALED if it's whole-home work), add its name to freeframe_handy_script_strings() in inc/setup.php so it can be translated, then add a matching radio card in the calculator's Custom HTML block on the Instant quote page:

html
<label class="option-card"><input type="radio" name="service" value="roofing" class="sr-only">
  <span class="option-card-icon"></span><span><span class="block font-semibold text-navy-900">Roofing</span><span class="block text-xs text-ink-600">Repairs, gutters, flashing</span></span></label>

Add an <option value="roofing"> to the reviews page's service menu too if you want to filter reviews by it. To add an option, add it to OPTIONS and a <input type="checkbox" name="options" value="…"> to the calculator form.

Service areas#

The areas are the AREAS list at the top of assets/js/main.js, shared by both ZIP code checkers, the coverage map and the booking summary. To add one:

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

Then:

  1. Add a row to the "Cities and ZIP codes" list on the Service areas page (a List block: duplicate a row in List View).

  2. If you want it on the map, edit the map card's Custom HTML block and add a polygon and a label:

    html
    <polygon points="…" class="area-shape area-extended" data-area="Parker"></polygon>
    <text x="…" y="…" text-anchor="middle" class="area-label on-light">Parker</text>

    The data-area value must equal the name in AREAS exactly (spaces and capitals included), or the checker finds the ZIP but the map doesn't highlight it. Use area-core (navy) or area-extended (yellow, with on-light on its label). Also update the map's <desc> text, which describes the coverage to screen readers.

  3. Add the city to freeframe_handy_script_strings() in inc/setup.php so its name is translated in the checker's message.

The eta field isn't shown anywhere yet; the checker message comes from tier (core is "same-day", extended is "next-day"). The text that names the cities in the Service areas page header is ordinary content: update it too.

Images and artwork#

All artwork is SVG in assets/img/. Replace an Image block's picture with Replace in the block toolbar; pictures in Custom HTML blocks are <img> tags whose src you change in the HTML view (upload the photo to the Media Library and copy its URL). Keep the same aspect ratio:

Files Where Ratio
hero.svg home hero (Image block) 4:3 (1200 × 900)
project-*.svg (8 files) home projects (Image blocks), project gallery (Custom HTML) 4:3 (800 × 600)
before.svg, after.svg before/after slider (Custom HTML) both the same size (900 × 560)
team-*.svg (4 files) about page team (Image blocks) 1:1
van.svg about story and 404 page (Image blocks) 2:1
  • Logo: the header shows assets/img/logo.svg in an Image block next to a paragraph with a link ("Brightline" and "Home services" in two styles); the footer shows logo-footer.svg. Edit both in Appearance > Editor > Patterns > Header (and Footer).
  • Icons: icons are Icon blocks. Select one and pick another icon from the Handy collection (the theme's icons) or WordPress's own. The star ratings are single Icon blocks too: stars-5, stars-4, stars-3 and stars-5-large (the bigger one next to the 4.9 average); pick another to change a rating and update its label (block settings) so screen readers read the right number. Icons inside a button (the phone and calculator) or after a link (the arrows) are drawn by CSS: the class icon-before-phone or icon-after-arrow-right adds one (icon-size-20 makes it 20 px); the available names are in src/icons.css.
  • Social image: assets/img/og-image.png (1200 × 630, source og-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing.
  • Favicon: assets/img/favicon.svg is used until you set a Site Icon in Settings > General.

Decorative images have empty alt text. When you add a meaningful photo, write alt text that describes it.

The header and footer are template parts (parts/header.html, parts/footer.html) showing the header and footer patterns. Edit them in Appearance > Editor > Patterns > Header / Footer. Above the header is the 24/7 emergency bar with the phone number; after the footer comes the mobile call bar (see Mobile call bar).

The header has two Navigation blocks showing the same menu: the desktop one and the one in the mobile menu. Edit the menu once in Appearance > Editor > Navigation (the setup creates "Main menu" with Home, Services, Service areas, Projects, Reviews, About, Contact and Instant quote). The Home and Instant quote links have the class ff-mobile-only (link settings > Advanced), so they only show in the mobile menu, as in the HTML version (the desktop header has its own "Free quote" button). A Navigation block that doesn't point at a menu shows the most recently created one; to pin a menu, select the Navigation block and choose it in the block settings, for both blocks.

WordPress marks the menu link of the page being viewed (aria-current="page"). The theme also marks the header's buttons that link to the current page, so the "Free quote" button is current on the Instant quote page (freeframe_handy_header_current() in inc/navigation.php).

The footer's Services and Company columns are List blocks: click a link to change it, press Enter to add one. The links to /quote/, /services/#plumbing and so on are written with the demo slugs: if you rename a page, update the links that point to it.

Features#

Quote calculator#

On the Instant quote page, the quote section (class ffd-quote, read as data-quote) holds the calculator form ([data-quote-calculator]) and the estimate panel ([data-estimate], [data-estimate-note], [data-breakdown]), both Custom HTML blocks. Every change recalculates the range with estimate(state) in assets/js/main.js 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 every version of Handy (HTML, WordPress) gives 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

Things to know:

  • The breakdown lines are built from the data: addText() shows "+$75" or "+$90–140" and pctText() shows "+50%" or "+15–35%". The urgency cards in the form show "+$75" and "+50%" as text; update them with the data.
  • Links to /quote/?service=hvac preselect a service (the services pages use them). Unknown values are ignored.
  • The radios and checkboxes are real form controls, so the calculator works with the keyboard. The estimate has aria-live="polite", so screen readers hear the new range after each change.
  • The estimate panel (data-back-to-top-hide) is sticky on large screens; the back-to-top button hides rather than covering it.
  • See Quote prices and services to change prices or add a service.

Booking request#

Below the calculator, [data-request-form] (a Custom HTML block) 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] (the next Custom HTML block) 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 code 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 (morning, afternoon, evening, weekend).

ZIP code checker#

Any <form data-zip-checker> with an <input> and a .zip-result element becomes a checker. There is one in the home hero and one on the Service areas page (Custom HTML blocks). On submit it checks for five digits, then looks the ZIP code 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 code sets aria-invalid and returns focus to the input. The messages are translated in freeframe_handy_script_strings() (see Translations).

Coverage map#

On the Service areas page, the map card is a Custom HTML block: an SVG with the class area-map containing one <polygon class="area-shape …" data-area="Name"> per area, a <text class="area-label"> per name, and the legend below. When a ZIP code matches, the script adds is-match to that polygon and has-match to the map, which fades the others and outlines the match (styles under .area-shape in src/theme.css). The map is role="img" with a <title> and <desc>; the "Cities and ZIP codes" list below it is the accessible equivalent, so keep both up to date. See Service areas to add an area.

Before/after slider#

On the Water heaters page, a [data-before-after] box (a Custom HTML block) 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 change the pictures, change the two <img> src values and the after image's alt. To reuse the slider on another page, copy the whole Custom HTML block, give the input a unique id and update its <label for>.

On the Projects page, the whole gallery is one Custom HTML block, [data-gallery], holding:

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

The lightbox shows what is on the project buttons: before the gallery script runs, the adapter at the top of assets/js/main.js builds the list of projects from each button's image src, its first line (the title) and its second line (the details). data-lightbox-open="N" is the project's position in that list, so number the buttons 0, 1, 2… without gaps. To add a project:

  1. Copy an <li data-project …> in the HTML view and paste it at the end of the list.
  2. Set its data-category to one of the filter values and data-lightbox-open to the next number.
  3. Change the image src, the title, the details line (for example Arvada · 80003 · 4 days · $6,800) and the button's aria-label ("View larger: …").

Update the static count text ("Showing 8 projects") as well; the script rewrites it after the first filter click. To add a category, add a filter button with a new data-filter value and use it on the cards.

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 (and the previous and next buttons) step through the projects visible under the current filter, and clicking the backdrop closes it.

Reviews#

On the Reviews page, the section with the class ffd-reviews filters and sorts the review cards in the group with ffd-review-list. Each card is a Group block (an article) with these classes (Block settings > Advanced > Additional CSS class(es)):

card reveal flex flex-col p-6 ffd-review ffd-service--plumbing ffd-stars--5 ffd-date--2026-09-28
  • ffd-service--<key> matches an <option value> in the service menu: plumbing, electrical, hvac, carpentry, painting or remodel.
  • ffd-stars--<n> is 1 to 5. The rating buttons (data-stars-filter all, 5, 4, 3) filter exactly 5 or 4 stars, and "3" means 3 stars or fewer.
  • ffd-date--<yyyy-mm-dd> is the review date; "Newest first" sorts on it, and "Highest rated" and "Lowest rated" break ties with it.

To add a review: in List View, duplicate a review card inside the reviews grid, change its stars icon (stars-5, stars-4 or stars-3) and the icon's label, the title, the text and the name line, then change the three classes to the review's service, stars and date. A card without ffd-review is ignored by the filters; a card whose classes don't match a service or star value is only shown under "All".

The count ([data-review-count], a live region) is rewritten by the script; its starting text ("Showing 12 reviews") is in a Custom HTML block, so update it when you change the number of reviews. [data-review-empty] shows when nothing matches, and its "Clear filters" button ([data-review-clear]) resets everything. The service and sort menus, the rating buttons, the count and the empty state are Custom HTML blocks. The 4.9 average, the rating breakdown bars (their widths are classes such as [width:87.0%]) and the home page's reviews are ordinary blocks, so update them when you change the reviews.

Questions#

The questions on the home page and the Water heaters page are core Details blocks with the class faq. They open and close without JavaScript and are found by the browser's find-in-page. The round plus icon that turns into a cross is drawn by the stylesheet (.faq > summary::after in src/theme.css).

Header, mobile menu and scroll reveals#

The header (#site-header) sticks to the top and gets a shadow (is-scrolled) after 8 px of scrolling. Below 1024 px the menu button (#menu-button, a Custom HTML block) opens #mobile-menu, swaps its icon, updates aria-expanded and its label ("Open menu" / "Close menu"), and closes with Escape (returning focus to the button), when a link is clicked or when the window grows past 1024 px. Blocks with reveal fade in when scrolled into view; when one is revealed, all earlier ones are too, so a fast jump never leaves gaps. They are only hidden once JavaScript runs (html.js, added by freeframe_handy_theme_script()), so the content is there without it, and they appear immediately with reduced motion. The pulsing dot in the emergency bar stops with reduced motion too.

Mobile call bar#

Below 1024 px, the group with the class mobile-call-bar at the end of the footer pattern is fixed to the bottom of the screen with "Call now" and "Free quote" buttons. It has the class ffd-back-to-top-avoid, so the back-to-top button sits above it, and the footer has extra bottom padding (pb-24 lg:pb-0) so the bar never covers the copyright line. It's hidden when printing. To remove it, delete the group in Appearance > Editor > Patterns > Footer (and the footer's pb-24 class).

Back to top#

A button appears after scrolling about one screen down; it scrolls to the top (instantly with reduced motion) and moves keyboard focus to the main content. It lifts above anything marked data-back-to-top-avoid (the mobile call bar), hides while it would cover a sticky element marked data-back-to-top-hide (the quote estimate panel and the Water heaters sidebar), and steps aside while the footer's "Back to top" link is on screen. It is created by assets/js/main.js; to remove it, delete the "Back to top" part of that file and the .back-to-top styles in src/theme.css, then run pnpm build.

Block styles#

In the block settings' Styles panel:

Block Styles
Button Fill (default, the yellow), Navy (the "Call now" buttons), Outline (navy border on white), White (on yellow) (the emergency call to action's second button), Outline (on navy) (a light border for navy panels), Chip (a rounded filter chip). WordPress's own Outline style is replaced by the theme's

Add the class is-large to a Button block (Block settings > Advanced > Additional CSS class(es)) for the large size used in the calls to action.

Forms#

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

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

Validation is in wireForm() and check() in assets/js/main.js. 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. The error messages are translated in freeframe_handy_script_strings(). Nothing is sent anywhere: they are demo forms until you connect them.

Option 1: a form plugin#

The usual way on WordPress. Install a form plugin (WPForms, Gravity Forms, Fluent Forms, Contact Form 7, Jetpack Forms...), build the form there, then on the Contact page delete the form's Custom HTML block and insert the plugin's block or shortcode in its place. The plugin sends the messages and stores them. To make its fields look like the theme's, add the theme's classes where the plugin allows custom classes: input on fields, label on labels, btn btn-navy on the submit button.

For the booking request, a plugin form replaces the request form and its success panel; the calculator keeps working on its own, but its choices are not sent with the plugin's form. Many companies embed their field service or booking software's widget instead, in a Custom HTML block.

Option 2: a form service#

Keep the theme's form and send it to a service such as Formspree:

  1. Create a form on the service and copy its endpoint URL.

  2. Edit the Contact page, open the form's Custom HTML block and add the endpoint to the form tag:

    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">
  3. In assets/js/main.js, replace the contact form block (the comment "Contact form") so it posts the form and only shows the thank-you panel when the service accepts it. wireForm() calls this callback only after validation passes (the comment "To send for real" marks the place):

    js
    document.querySelectorAll('[data-contact-form]').forEach(function (form) {
      wireForm(form, function () {
        var ok = form.querySelector('[data-success]');
        fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
          .then(function (res) {
            if (!res.ok) throw new Error('Send failed');
            form.querySelectorAll('input, select, textarea').forEach(function (f) { f.value = f.tagName === 'SELECT' ? f.options[0].value : ''; });
            ok.classList.remove('hidden');
            ok.focus();
          })
          .catch(function () { alert('Sorry, your message could not be sent. Please call us.'); });
      });
    });

    Read the form data before resetting the fields, as above. Change the success panel's text in the Custom HTML block too (the demo text says nothing was sent).

  4. For the booking request, do the same in the wireForm(reqForm, …) callback. The calculator's choices are in a separate form, so add them to the data you send:

    js
    var 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));
    fetch('https://formspree.io/f/your-form-id', { method: 'POST', body: data, headers: { Accept: 'application/json' } });

    The demo reference number is random: replace it with the ID your booking system returns, or remove it.

Make these changes in a child theme (copy assets/js/main.js there and load it instead, see Child themes), or they will be overwritten when you update the theme.

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. Nothing else is stored, and nothing is sent to WordPress.

To reset it, delete the key in your browser's developer tools (Application, Local Storage). 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#

Page titles come from WordPress: the page title, an em dash and the site name (for example "Instant quote — Brightline Home Services"); the home page shows the site name and tagline (Settings > General). The theme doesn't output meta descriptions or Open Graph tags; use an SEO plugin such as Yoast SEO, Rank Math or The SEO Framework, and give it assets/img/og-image.png (or your own 1200 × 630 image) as the default social image. Local trades benefit from HomeAndConstructionBusiness (or Plumber, Electrician) structured data with your name, phone, address, opening hours and service area, which most SEO plugins can add. WordPress already generates a sitemap at /wp-sitemap.xml.

Accessibility#

Built in: a skip link to the main content (added by WordPress, styled by the theme), labelled landmarks (header, the "Main" and "Mobile" navigation, main, footer), one h1 per page, visible focus rings, the menu button with aria-expanded, aria-controls and a changing label, live regions for the ZIP code results, the estimate, the gallery count and the review count, filter buttons with aria-pressed, a native <dialog> lightbox with focus return, a native range input for the before/after slider with aria-valuetext, star ratings labelled for screen readers, form errors linked with aria-describedby and aria-invalid with focus on the first error and on success messages, aria-current on the current page's links, and reduced-motion support (no reveals, no pulsing dot, instant scrolling).

When you edit: keep one Heading 1 per page, keep yellow text off white, keep text contrast at WCAG AA, give images alt text (or mark them decorative), keep link text meaningful, update a star icon's label when you change the rating, and label any field you add to a form (<label for="...">).

Building and deploying#

You only need this if you change the theme's files. The stylesheet assets/css/theme.css is compiled with Tailwind CSS from src/theme.css and the classes used in patterns/, parts/, templates/, inc/ and assets/js/ (including the classes the script writes, such as text-emerald-700 in the ZIP checker):

sh
pnpm install
pnpm build   # compile assets/css/theme.css and assets/css/editor.css
pnpm watch   # recompile assets/css/theme.css on every change (run pnpm build before you commit)

Run pnpm build after adding a utility class to a pattern or template, otherwise the class has no style. Classes you type in the editor's Additional CSS class(es) field are only styled if the stylesheet already contains them.

To deploy, zip the theme folder (named freeframe-handy, without node_modules/) and upload it in Appearance > Themes, or copy the folder to wp-content/themes/ on your server.

Translations#

The theme is translation-ready with the text domain freeframe-handy. All text in patterns (including the Custom HTML blocks), templates and PHP goes through WordPress's translation functions; the script's strings (menu labels, service, size, option and urgency names, area names, ZIP code results, form errors, the booking summary and the counts) are translated in PHP (freeframe_handy_script_strings() in inc/setup.php) and passed to the script as window.freeframeHandyL10n. Create a translation with Loco Translate, or with WP-CLI and Poedit:

sh
wp i18n make-pot wp-content/themes/freeframe-handy wp-content/themes/freeframe-handy/languages/freeframe-handy.pot

Save freeframe-handy-de_DE.mo (for example) in wp-content/languages/themes/ or in the theme's languages/ folder. Page content created by Handy setup is copied into your pages in the site's language at that moment; translate it in the editor like any other content. The prices and the booking summary's date are formatted for the United States (en-US) in assets/js/main.js.

Child themes#

Use a child theme for changes you want to keep when Handy is updated. Create wp-content/themes/handy-child/style.css:

css
/*
Theme Name: Handy Child
Template: freeframe-handy
*/

Templates, parts, patterns and theme.json in the child theme override the parent's files of the same name. To change the prices, areas or form handling, copy assets/js/main.js to the child theme and point the theme's script at it. Changing the script's address keeps its translated strings (deregistering the script would drop them):

add_action( 'wp_enqueue_scripts', function () {
	$script = wp_scripts()->query( 'freeframe-handy' );
	if ( $script ) {
		$script->src = get_stylesheet_directory_uri() . '/assets/js/main.js';
	}
}, 30 );

The parent's stylesheet only contains the classes the parent uses: if your child theme's patterns use new utility classes, compile a stylesheet for the child (copy package.json and src/theme.css, point the @source lines at the child's folders) and enqueue it.

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 in AREAS exactly, including spaces and capitals.
  • The calculator stops working 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. Number the data-lightbox-open buttons 0, 1, 2… in order, without gaps or repeats, and keep each button's title and details as its first two lines.
  • A review never shows when filtering. Check its classes: ffd-review plus ffd-service--<key> with a key from the service menu, ffd-stars--<n> and ffd-date--<yyyy-mm-dd>.
  • The pages look unstyled or new classes do nothing. The stylesheet only contains classes it found when it was built. Run pnpm build after editing patterns, then reload.
  • "This block contains unexpected or invalid content". A pattern or page was edited by hand with markup that doesn't match the block. Click Attempt recovery, or re-insert the section from Patterns > Handy.
  • The forms or the map disappear after saving a page. Users without the unfiltered_html capability (on multisite, everyone but super admins) can't save forms and SVG in Custom HTML blocks: WordPress strips them. Edit those pages as an administrator (super admin on multisite).
  • The header menu shows other pages, or an old menu. A Navigation block without a chosen menu shows the most recent one. Select it and choose "Main menu" (or yours) in the block settings, in both the desktop and the mobile Navigation block.
  • Handy setup says the pages exist, but they are empty. It never overwrites existing pages. Delete (and empty the trash of) the pages with those slugs, or open each page and insert its page pattern from Patterns > Handy pages.
  • The forms don't send anything. They are demo forms; see Forms.
  • The theme can't be installed on an older WordPress. Handy uses the Icon block's icon collections from WordPress 7.1; update WordPress first.
  • A page shows the title twice or no title. Pages use a template without a title, because the page patterns start with their own heading. For a plain text page (privacy policy, terms), choose the Page with title template in the page's settings.

License#

MIT, which is compatible with the GPL that WordPress requires (see LICENSE.txt in the theme folder). Free for personal and commercial use. The illustrations, map, logos and icons are included under the same license. Public Sans and Archivo are licensed under the SIL Open Font License 1.1 (assets/fonts/public-sans/OFL.txt, assets/fonts/archivo/OFL.txt). Brightline Home Services, its team and its customers are fictional.

Search documentation

Search the guides and every template's documentation