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.
- Download
handy.zipfrom freeframe.dev/templates/handy (the WordPress tab). - 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.
- 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.
- 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:
pnpm install
pnpm dev # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400pnpm 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.txtCustomising#
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:
{ "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-quoteon the quote section,ffd-reviewsandffd-review-liston 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-hideon the water heaters page's sticky sidebar andffd-back-to-top-avoidon the mobile call bar (see Back to top).ff-aria-hiddenon a decorative separator (the "·" in the home hero) hides it from screen readers.revealfades a block in as it scrolls into view.link-cardon 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-leftandff-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 |
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:
<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:
{"name": "Parker", "zips": ["80134", "80138"], "tier": "extended", "eta": "Next day"}Then:
-
Add a row to the "Cities and ZIP codes" list on the Service areas page (a List block: duplicate a row in List View).
-
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-areavalue must equal thenameinAREASexactly (spaces and capitals included), or the checker finds the ZIP but the map doesn't highlight it. Usearea-core(navy) orarea-extended(yellow, withon-lighton its label). Also update the map's<desc>text, which describes the coverage to screen readers. -
Add the city to
freeframe_handy_script_strings()ininc/setup.phpso 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.svgin an Image block next to a paragraph with a link ("Brightline" and "Home services" in two styles); the footer showslogo-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-3andstars-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 classicon-before-phoneoricon-after-arrow-rightadds one (icon-size-20makes it 20 px); the available names are insrc/icons.css. - Social image:
assets/img/og-image.png(1200 × 630, sourceog-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing. - Favicon:
assets/img/favicon.svgis 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.
Navigation, header and footer#
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:
- 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 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" andpctText()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=hvacpreselect 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 (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 (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>.
Project gallery#
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) andaria-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:
- Copy an
<li data-project …>in the HTML view and paste it at the end of the list. - Set its
data-categoryto one of the filter values anddata-lightbox-opento the next number. - Change the image
src, the title, the details line (for exampleArvada · 80003 · 4 days · $6,800) and the button'saria-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-28ffd-service--<key>matches an<option value>in the service menu:plumbing,electrical,hvac,carpentry,paintingorremodel.ffd-stars--<n>is 1 to 5. The rating buttons (data-stars-filterall,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:
-
Create a form on the service and copy its endpoint URL.
-
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"> -
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):jsdocument.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).
-
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:jsvar 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):
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:
wp i18n make-pot wp-content/themes/freeframe-handy wp-content/themes/freeframe-handy/languages/freeframe-handy.potSave 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:
/*
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[].zipsas a five-character string, for example"80205", not80205. - The map doesn't highlight a matching area. The polygon's
data-areamust equal the areanameinAREASexactly, including spaces and capitals. - The calculator stops working 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. Number the
data-lightbox-openbuttons 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-reviewplusffd-service--<key>with a key from the service menu,ffd-stars--<n>andffd-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 buildafter 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_htmlcapability (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.