Overview#
Bloom is a calm, elegant website for a hair salon and day spa, built as a WordPress block theme. The
demo brand is Bloom Hair & Spa, a fictional independent salon at 48 Orchard Lane, Bristol. The
theme has a home page with a before/after slider, a services page with a filterable menu and prices
by stylist level, a team page with a specialty filter, a five-step booking wizard that works out
real time slots from opening hours, each team member's working days and diary, and the length of
the treatments, a gift card builder with a live preview, a gallery with saved looks and a lightbox,
an about page, a visit and contact page with opening hours and a contact form, and a 404 page, plus
a live "Open now" badge and a newsletter form in the footer. 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/bloom.
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. Colours, the fonts and
font sizes are design tokens in theme.json: change them in Appearance > Editor > Styles and the
whole site follows.
All treatments, prices, team members, reviews and looks are placeholder content. Replace them with your salon's own information.
Quick start#
Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.
- Download
bloom.zipfrom freeframe.dev/templates/bloom (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 Bloom.
- Go to Appearance > Bloom setup and click Create the demo pages. This creates the Home, Services & prices, Team, Book online, Gift cards, Gallery, About and Visit & 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 salon's name in Settings > General (the demo uses "Bloom Hair & Spa" with the tagline "Hair, colour, nails & facials in Bristol"), 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 > Bloom pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Bloom.
| Page | Slug | Page pattern | Sections (pattern files in patterns/) |
|---|---|---|---|
| Home | / |
page-home.php |
home-hero (illustration, rating and a "next free slot" card), signature-treatments, before-after (slider), team-preview, reviews, gift-teaser, book-cta |
| Services & prices | /services/ |
page-services.php |
services-hero, service-menu (category filter and count, 22 treatments in five groups, "Book" links), price-levels (prices by stylist level), good-to-know |
| Team | /team/ |
page-team.php |
team-hero, team-grid (specialty filter and count, six team cards with working days and "Book with" buttons), levels, join-the-team |
| Book online | /book/ |
page-book.php |
book-hero, booking-wizard (five steps and a running summary) |
| Gift cards | /gift-cards/ |
page-gift-cards.php |
gift-hero, gift-card-builder (amount, design, message and delivery with a live preview), gift-faq |
| Gallery | /gallery/ |
page-gallery.php |
gallery-hero, looks-gallery (filters, saved looks, twelve looks, lightbox) |
| About | /about/ |
page-about.php |
about-hero, story, values, milestones, press-quote |
| Visit & contact | /contact/ |
page-contact.php |
contact-hero (open-now badge), visit (map, address, opening hours with today highlighted, contact form) |
| 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-bloom/
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
and settings, browser bar colour and favicon, title separator, 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 "Book now" button
inc/site-setup.php Appearance > Bloom 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, the BLOOM data object (hours,
treatments, levels, team, looks, gift card designs), header, reveals,
opening hours, slider, filters, gallery, booking wizard, gift cards,
forms, back to top
assets/js/editor.js block editor tweaks
assets/icons/ SVG icons for the Icon block (including the star rating)
assets/img/ logos, favicon, before/after, salon and map artwork, social image
assets/img/looks/ gallery looks, one SVG per look id
assets/img/team/ team portraits, one SVG per team member id
assets/fonts/ the Jost and Bodoni Moda 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 colours are in theme.json (settings.color.palette) and in Appearance > Editor > Styles >
Colors > Palette:
| Colour | Slug | Default | Used for |
|---|---|---|---|
| Base (shell) | base |
#faf6f2 |
the page background (the stylesheet's shell) |
| Contrast (ink) | contrast |
#2a2127 |
body text, the footer background (the stylesheet's ink) |
| Cream | cream |
#fffdfb |
cards, chips, form fields |
| Blush | blush |
#f3e4dd |
soft panels, portrait backgrounds, selected options |
| Rose | rose |
#e8cfc4 |
deeper panels |
| Line | line |
#e3d3ca |
borders and dividers |
| Mute | mute |
#685a5f |
secondary text (meets WCAG AA on shell, cream and blush) |
| Plum, Plum dark | plum, plum-dark |
#7a3e5b, #5e2c45 |
the accent: buttons and their hover, links, pressed chips, selected options, focus rings |
| Sage | sage |
#5a6954 |
the open-now badge |
| Gold | gold |
#a5804f |
decorative details |
| Error | error |
#a23a3a |
form errors |
| White, Black | white, black |
plain colours for your own blocks |
The theme's stylesheet reads these colours from the palette (--color-plum: var(--wp--preset--color--plum) and so on in src/theme.css), so changing Plum in Styles recolours
every button, link, chip and focus ring at once. Change Plum and Plum dark together so the hover
state stays in step. In theme.json:
{ "slug": "plum", "name": "Plum", "color": "#2f5d62" }Bloom has no dark mode. A few colours are written into files rather than read from the palette: the
browser bar colour (<meta name="theme-color"> in freeframe_bloom_head_meta() in
inc/setup.php), the three gift card designs (.gift-petal, .gift-sage, .gift-noir in
src/theme.css), the footer badge's dot and the error colour on dark backgrounds (footer .open-badge .open-dot, .field-error.on-dark), the hero and 404 illustrations (inline SVG in Custom HTML
blocks), the logos (logo.svg, logo-footer.svg, favicon.svg) and the artwork in assets/img/.
Typography#
Text uses Jost (variable, weights 300 to 600) and headings, prices and the wordmark use
Bodoni Moda (variable, weights 400 to 700, upright and italic), bundled in assets/fonts/ and
declared in theme.json (settings.typography.fontFamilies), so no request goes to Google. The
class font-display uses Bodoni Moda. 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--jost and
--wp--preset--font-family--bodoni-moda, 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 > Bloom.
The salon's name, phone number, email and address appear in the header, footer and several
sections. Search for +44 117 496 0123 (the footer and the contact-hero, visit, book-cta and
booking-wizard patterns) and +441174960123 (the tel: links in the same places, except the
contact-hero text, which isn't a link), hello@bloomhairspa.example
(footer and visit) and 48 Orchard Lane (footer, visit, contact-hero, home-hero, story
and milestones). The footer is edited once in the footer pattern; the sections are copied into
each page, so edit them on every page that has them.
The questions on the Gift cards 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, 404 |
the illustrations | inline SVG drawings |
before-after |
the slider | see Before/after slider |
gift-teaser |
the gift card artwork | a gift-card gift-petal card drawn by the stylesheet |
service-menu, team-grid |
the filter bars (buttons and count) | see Services filter and Team filter |
price-levels |
the prices by level table | a plain table: edit the rows by hand |
booking-wizard |
the whole wizard: steps, treatments, stylist list, dates and times, details, review, summary and confirmation | see Booking wizard |
gift-card-builder |
the whole builder: form, preview and confirmation | see Gift cards |
looks-gallery |
the whole gallery: filters, saved toggle, looks and lightbox | see Gallery and saved looks |
contact-hero |
the open-now badge | see Opening hours and open now |
visit |
the opening hours table, the contact form | see Opening hours and open now and Forms |
footer |
the open-now badge, the newsletter form | see Opening hours and open now and Forms |
header |
the menu button | a button driven by assets/js/main.js |
The service menu, the team cards, the "Good to know" and level cards, the reviews and the signature treatments 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. An underscore in a value stands for a space:
ffd-serviceson the service menu's group, andffd-service-group--<category>on each category group (ffd-service-group--hair,--colour,--nails,--skin,--massage). See Services filter.ffd-teamon the team grid's group, andffd-team-member ffd-cats--hair_colouron each team card (ffd-cats--hair_colourbecomesdata-cats="hair colour"). See Team filter.ff-hiddenon the mobile menu: the script turns it into thehiddenattribute.ff-no-currenton the mobile menu's booking button (see Navigation, header and footer).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 logo, the team preview cards).
Treatments, team and opening hours#
The data the scripts use is the BLOOM object at the top of assets/js/main.js (section 1, Data).
It is the same data as the HTML version's:
| Key | What it holds | Used by the script for |
|---|---|---|
salon |
name, address, phone, email, currency (GBP), locale (en-GB) |
price formatting in the booking wizard and gift cards; otherwise reference only |
hours |
seven entries, day 0 (Sunday) to 6, with open/close as "HH:MM" or null when closed |
the open-now badges, today's row, booking slots |
categories |
id, label, blurb for hair, colour, nails, skin and massage |
the services count ("5 treatments in Hair") |
services |
id, cat, name, mins, price, desc, optional signature |
booking durations, prices and summary |
levels |
stylist (×1.0), senior (×1.2), director (×1.4) with label and blurb |
level-based pricing, the stylist list |
team |
id, name, role, level, cats, days, bio, specialties |
booking stylists and availability, the lightbox credit |
looks |
id, cat, title, by (a team id), palette, kind |
the gallery lightbox |
lookCategories |
gallery category ids and labels | the lightbox's category label |
giftAmounts, giftDesigns |
preset amounts and designs | design labels in the gift card preview |
"services": [
{ "id": "signature-cut", "cat": "hair", "name": "Signature cut & finish", "mins": 60, "price": 68, "desc": "Consultation, wash with scalp massage, precision cut and a lasting blow-dry.", "signature": true },
…
],
"hours": [
{ "day": 0, "label": "Sunday", "open": "10:00", "close": "16:00" },
{ "day": 1, "label": "Monday", "open": null, "close": null },
…
]The pages are not built from this data: the service menu, the booking wizard's checkboxes, the team
cards, the gallery, the price table and the hours table are written out in the patterns, and the
prices, durations and working days in them are plain text. When you change the data, change the
patterns to match, and the other way round. Make the change to assets/js/main.js in a child
theme so a theme update doesn't undo it (see Child themes).
To change a price or a treatment's length, edit price or mins. The wizard's durations, prices
and totals follow; update by hand the "from £68" and "1 hr" text in the service menu, the wizard's
checkbox for that treatment (in the Book online page's Custom HTML block), the signature treatments
on the home page and the "from £252" under the before/after slider if they mention it.
To add a treatment, for example a fringe trim:
-
Add it to
BLOOM.services, in the place it should appear in the menu:js{ "id": "fringe-trim", "cat": "hair", "name": "Fringe trim", "mins": 15, "price": 12, "desc": "A quick tidy between cuts." }, -
Add its
nameanddesctofreeframe_bloom_script_strings()ininc/setup.phpso they can be translated (without them the English text is shown). -
On the Services & prices page, in the matching category group (
ffd-service-group--hair), duplicate a treatment row in List View and change its name, description, duration, price and its "Book" link to/book/?service=fringe-trim. -
On the Book online page, open the wizard's Custom HTML block and copy a checkbox inside the right category's
<fieldset>:html<li><label class="option"><input type="checkbox" name="services" value="fringe-trim" class="option-input"><span class="option-body"><span class="flex flex-wrap items-baseline justify-between gap-x-3"><span class="font-medium">Fringe trim</span><span class="text-sm">from £12</span></span><span class="text-sm text-mute">15 min</span></span></label></li>The checkbox
valuemust equal the serviceid; the script reads the name, length and price fromBLOOM.services.
To add a category, add it to BLOOM.categories, a filter button with a new data-filter value to
the service menu's filter bar, a group with the class ffd-service-group--<id> to the menu, and a
<fieldset> to step 1 of the wizard.
To add a team member:
-
Add an entry to
BLOOM.team.daysuses 0 for Sunday to 6 for Saturday;catslists the categories they do;levelisstylist,seniorordirector:js{ "id": "maya", "name": "Maya Reid", "role": "Stylist", "level": "stylist", "cats": ["hair"], "days": [3, 4, 5, 6], "bio": "…", "specialties": ["Curly cuts"] }, -
Add
assets/img/team/maya.svg. The booking wizard showsteam/<id>.svg, and the theme only passes the SVG files inassets/img/team/andassets/img/looks/to the script (freeframe_bloom_script_settings()ininc/setup.php), so keep the portrait an SVG with the member's id as its name, or change theIMG('team/' + o.id + '.svg')line inmain.js. -
Add the name (and the role, if it is new) to
freeframe_bloom_script_strings(). -
On the Team page, duplicate a team card in List View. Change the portrait, name, level badge, role and working days line, bio, specialties and the "Book with" button's link (
/book/?stylist=maya). In the card's Additional CSS class(es), keepteam-card reveal ffd-team-memberand setffd-cats--hair(several categories joined with_, for exampleffd-cats--hair_colour); set its HTML anchor to the id.
To change the opening hours, edit BLOOM.hours, then update the printed hours: the hours table in
the visit pattern's Custom HTML block (one <tr data-day="N"> per day, 0 = Sunday), the
"Tue–Sun · see opening hours" text in the two open-now badges (shown only until the script runs)
and the working days on the team cards if they change.
The salon's currency and number format are currency and locale in BLOOM.salon. The script
formats every amount it writes with them (prices, totals, the "£—" shown in the gift card preview
before an amount is valid, the custom amount error "Enter a whole amount from £25 to £500."); the
prices printed in the patterns have the pound sign written in.
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 |
|---|---|---|
looks/<look id>.svg (12 files) |
gallery (Custom HTML) and its lightbox | 4:5 (480 × 600) |
team/<member id>.svg (6 files) |
team cards and the home page's team preview (Image blocks), the wizard's stylist list | 5:6 (400 × 480) |
before.svg, after.svg |
before/after slider (Custom HTML) | both the same size (640 × 520) |
salon.svg |
about page story (Image block) | 4:3 (640 × 480) |
map.svg |
Visit & contact page (Image block) | 640 × 420 |
Two images are chosen by the script: the lightbox shows looks/<id>.svg and the wizard's stylist
list shows team/<id>.svg, from the URLs in window.freeframeBloomSettings.images. If you switch
those to photos, change the two IMG(...) lines in assets/js/main.js (in a child theme) as well as
the patterns.
- Logo: the header shows
assets/img/logo.svgin an Image block next to a paragraph with a link ("Bloom", labelled "Bloom Hair & Spa, home" for screen readers); the footer showslogo-footer.svg. Edit both in Appearance > Editor > Patterns > Header (and Footer). - Icons: the category icons and the star rating are Icon blocks. Select one and pick another
icon from the Bloom collection (scissors, droplet, nail polish, leaf, stones, stars 5) or
WordPress's own. The icons in the wizard's category headings are inline SVG in its Custom HTML
block. The arrow after links such as "See the full menu" is drawn by the class
link-arrow. - Map: replace
map.svgwith a map image of your location, or embed an interactive map with the Embed block or a Custom HTML block. - 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.
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 Services, Team, Gallery, Gift cards, About and Contact). 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. The header's "Book now" button and the mobile menu's "Book an appointment" button are Button 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 "Book now" is current on the Book online
page (freeframe_bloom_header_current() in inc/navigation.php). The mobile menu's button has the
class ff-no-current and is skipped, as in the HTML version.
The footer has the open-now badge, the Explore links (a List block: click a link to change it, press
Enter to add one), the address and contact links, the newsletter form and the social links. The
social links point to #: replace them with your profiles. The links to /book/,
/services/ and so on are written with the demo slugs: if you rename a page, update the links that
point to it. Keep the booking page's slug book: the script finds it by that slug (see
Booking wizard).
Features#
Booking wizard#
On the Book online page, the wizard is one Custom HTML block: [data-booking] holding a progress
list (.steps, one data-step-indicator per step), <form id="booking-form"> with five
<section data-step="1"> to data-step="5" steps, a running summary (<aside class="booking-summary">)
and the confirmation ([data-booking-done]).
| Step | What happens | Validation |
|---|---|---|
| 1. Treatments | checkboxes name="services", grouped by category |
at least one |
| 2. Stylist | radio list built by renderStylists(): "Anyone available" plus everyone who can do all the chosen treatments, with portrait, role and level |
none |
| 3. Date and time | 21 day buttons from today (renderDates()), then time buttons (renderSlots()) |
a time is chosen |
| 4. Your details | name, email, mobile, notes, first visit, remember my details, cancellation policy | name 2+ characters, valid email, 10 to 15 digit mobile, policy ticked |
| 5. Review | rows with "Change" buttons that go back to a step | none |
"Continue" validates the current step and moves on; on step 5 it becomes "Confirm booking" and calls
confirm(), which hides the wizard and shows the confirmation with the first name, a summary and a
demo reference (BLM- plus four random characters). "Book something else" ([data-restart])
starts again. Changing the treatments clears the chosen date and time. Nothing is booked or sent;
see Forms.
Preselect from links:
/book/?service=balayageticks a treatment (it must be a serviceid). The service menu and the signature treatments use these links./book/?stylist=amarapreselects a team member (it must be a teamid). The team cards' "Book with" buttons and the gallery's lightbox use these links. If that person can't do the chosen treatments, the wizard falls back to "Anyone available".
The theme tells the script where the booking page is: freeframe_bloom_script_settings() in
inc/setup.php passes the address of the page with the slug book (or /book/ if there is none)
as window.freeframeBloomSettings.pages['book.html'], which the lightbox's "Book with" link uses.
Accessibility: each step's heading receives focus when the step opens, the active step in the
progress list has aria-current="step", the date and time buttons use aria-pressed, unavailable
dates are disabled and labelled "unavailable", errors are written next to each field with
aria-invalid and focus moves to the first one, and the confirmation receives focus. Scrolling back
to the wizard is instant with reduced motion.
Time slots and availability#
slotsFor(date, serviceIds, stylist, now) in section 2 of assets/js/main.js returns the bookable
start times. The rules:
- Opening hours: from
BLOOM.hours[date.getDay()]. A day withopen: nullhas no slots (Monday in the demo). - Length: the sum of the chosen treatments'
mins. A slot must finish by closing time, so a 180-minute balayage on a Thursday (09:00 to 20:00) can start at 17:00 at the latest. - Step: start times every 15 minutes.
- Today: the earliest start is at least an hour from now, rounded up to the next 15 minutes (at 10:07 the first slot is 11:15).
- Who can do it:
eligibleMembers()returns the team members whosecatsinclude every chosen category. With "Anyone available", a start time is offered if any of them is free; the slot remembers who (data-member), and the summary and confirmation name them. - Working days: a team member is only considered on the days in their
days. - Diaries:
isBusy(memberId, dateKey, block)marks about one in five 30-minute blocks of each person's day as already booked, from a hash of the id, date and block, so every visitor sees the same diary. A person is free only if every block the treatments touch is free. - Mixed treatments: if nobody can do all the chosen categories (for example a cut and a gel
manicure), the wizard books the "Best available team" back to back.
teamPlan()takes the treatments in menu order and gives each one to the first team member (inBLOOM.teamorder) who does that category, works that day and is free for the whole treatment; a start time is only offered if every treatment can be covered.
To use real availability, replace isBusy() (or memberFree()) with a lookup of your booking
system's busy times:
// busy = { "theo|2026-10-08": [20, 21, 26] } -> 30-minute block numbers (block 20 = 10:00)
function isBusy(memberId, key, block) {
var list = busy[memberId + '|' + key];
return Boolean(list && list.indexOf(block) !== -1);
}Load busy before the wizard shows step 3, for example from a JSON endpoint. To change the slot
interval, edit the t += 15 loop in slotsFor(); to change the notice period, edit the + 60 in
the earliest line; to show more days, change the 21 in renderDates().
Level-based pricing#
priceFor(service, levelId) multiplies the base price by the team member's level multiplier,
but only for the hair and colour categories: nails, skin and massage cost the same with anyone.
Results are rounded to whole pounds.
| Treatment | Stylist | Senior (×1.2) | Director (×1.4) |
|---|---|---|---|
| Signature cut & finish (£68) | £68 | £82 | £95 |
| Balayage (£210) | £210 | £252 | £294 |
Until a stylist or a time (and so a team member) is chosen, the summary shows hair and colour
prices and the total "from" the stylist price. To change the levels, edit BLOOM.levels; to make
another category scale with level, add it to the check in priceFor() and to the matching scaled
and approx checks in paintSummary(). The "+20%" and "+40%" on the Services & prices page (the
price-levels table) and the Team page (the levels cards) are text: update them by hand.
Opening hours and open now#
Section 5 of assets/js/main.js reads BLOOM.hours:
- Every
[data-open-now]badge (in the footer and the Visit & contact page header) gets text such as "Open now · until 19:00" or "Closed · opens tomorrow at 09:00" in its[data-open-text]child, and the classis-closedwhen the salon is closed. - In the
.hours-table(Visit & contact page), the<tr data-day="N">for today gets the classis-today(plum, medium weight) andaria-current="date".
It runs on load and every minute. It uses the visitor's clock and time zone, so a visitor abroad
sees the status for their local time. The rows of the hours table are text; keep them in step with
BLOOM.hours.
Services filter#
On the Services & prices page, the group with the class ffd-services (read as data-services)
holds the filter bar (a Custom HTML block with buttons data-filter all, hair, colour, nails,
skin, massage and the count [data-services-count]) and one group per category with the class
ffd-service-group--<category>. Choosing a filter hides the other groups, sets aria-pressed and
updates the count ("5 treatments in Hair"), a polite live region; the count is the number of
treatment rows (list items) in the visible groups. Link to /services/?category=colour to open
with a category selected (unknown values are ignored).
The starting count text ("22 treatments") is in the Custom HTML block: update it when you add or remove treatments.
Team filter#
On the Team page, the group with the class ffd-team holds the filter bar (buttons
data-team-filter and the count [data-team-count], a live region, in a Custom HTML block) and the
team cards, each with ffd-team-member and ffd-cats--<categories> (for example
ffd-cats--hair_colour). A filter shows the cards whose categories include it and updates the count
("3 people"). Each card's button links to /book/?stylist=<id>. Update the starting count text
("6 people") when you add someone.
Gallery and saved looks#
On the Gallery page, the whole gallery is one Custom HTML block, [data-gallery], holding:
- filter buttons with
data-look-filter(all,colour,cuts,bridal,nails,skin) andaria-pressed, - a "Saved" toggle (
data-saved-toggle) and its count (data-saved-count), - one
<li data-look="<id>" data-cat="<cat>">per look, with an open button (data-look-open="N", the look's index inBLOOM.looks) and a heart button (data-save="<id>",aria-pressed), - the count (
data-gallery-count, a live region) and an empty message (data-gallery-empty) for when no saved looks match, - the lightbox, a
<dialog data-lightbox>.
Saved looks are stored in localStorage (see Saved data) and stay in
step across tabs through the storage event. The lightbox shows the look, who did it, its
category, "N of M" and a "Book with" link to the booking page with ?stylist=<id>. The left and
right arrow keys and the previous and next buttons step through the looks visible under the current
filter; Escape or a click on the backdrop closes it, and focus returns to the look.
To add a look, add it to BLOOM.looks (with by set to a team id and cat to a lookCategories
id), add its title to freeframe_bloom_script_strings(), add assets/img/looks/<id>.svg, and copy
an <li data-look …> in the gallery's HTML view: set data-look, data-cat, data-save, the
image src, the title, the "by" line and both aria-labels, and set data-look-open to the look's
index in BLOOM.looks (counting from 0). The palette and kind fields describe the artwork; the
script doesn't use them.
Gift cards#
On the Gift cards page, the builder is one Custom HTML block, [data-gift], with
<form id="gift-form"> and a preview ([data-gift-preview], sticky on large screens):
- Amount: radios
name="amount"(50, 75, 100, 150, 250 orcustom). A custom amount must be a whole number from £25 to £500. - Design: radios
name="design"(petal,sage,noir); the preview gets the classgift-<design>. - To, recipient email, from, your email, message: the message has a 160-character counter.
- Delivery:
noworlater; later shows a date field limited to today through one year ahead.
The preview and the total update as you type, and [data-preview-desc] gives screen readers a text
description of the card. On a valid submit, the script shows [data-gift-done] with a demo code
(BLOOM-XXXX-XXXX) and a summary, and moves focus to it. Nothing is charged; see Forms.
The preset amounts are radios in the Custom HTML block (they are not read from
BLOOM.giftAmounts): edit them there. To add a design, add a radio, a .gift-<id> style in
src/theme.css (then pnpm build) and an entry in BLOOM.giftDesigns for its label.
Before/after slider#
On the home page, [data-ba] (a Custom HTML block) stacks the after image and the before image
(inside [data-ba-before]) with a handle and a native range input ([data-ba-range]) on top. Its
value sets the --pos custom property, and the script sets aria-valuetext such as "Showing 50%
before". Because it's a native range input, it works with mouse, touch and the arrow keys, and it
gets a visible focus ring. To change the pictures, change the two <img> src values and their
alt text in the HTML view.
Questions#
The questions on the Gift cards 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 plus icon that turns
into a minus 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 border and shadow (is-scrolled) after
8 px of scrolling. Below 1024 px the menu button (#menu-toggle, a Custom HTML block) opens
#mobile-menu, swaps its icon, updates aria-expanded, 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, with a short stagger; anything already scrolled past is revealed too, so
a fast jump never leaves gaps. They are only hidden once JavaScript runs (html.js, added by
freeframe_bloom_theme_script()), so the content is there without it, and they appear immediately
with reduced motion.
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, hides while it would cover a sticky element marked data-back-to-top-hide,
and steps aside while an in-page link whose text contains "Back to top" is on screen (Bloom has
none of these by default). 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, plum), Outline (a plum outline, the secondary buttons), Light (on plum) (the gift card teaser's button). WordPress's own Outline style is replaced by the theme's |
Forms#
There are four forms, all in Custom HTML blocks. All validate in the browser and none send anything until you connect them.
| Form | Where | Validation |
|---|---|---|
Booking (#booking-form) |
Book online (booking-wizard) |
per step, see Booking wizard |
Gift card (#gift-form) |
Gift cards (gift-card-builder) |
custom amount, recipient name and email, your name and email, delivery date |
Contact (#contact-form) |
Visit & contact (visit) |
name 2+ characters, valid email, message 20+ characters |
Newsletter ([data-newsletter]) |
every page's footer | valid email |
Errors are written into the element with the matching data-error="<field name>" (linked to the
field with aria-describedby) and the field gets aria-invalid="true"; the first invalid field
receives focus. Errors clear as you type. The contact form accepts /contact/?topic=careers to
preselect a topic (appointments, gift-cards, events, careers, other); the Team page's
"Join the team" button uses it. The error messages are translated in
freeframe_bloom_script_strings(). Nothing is sent anywhere: they are demo forms until you
connect them. Each has a comment in assets/js/main.js at the place to connect it:
- booking: "Demo only. Send { services, stylist, date, time, details } to your booking system here."
in
confirm(), - gift card: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here.",
- contact: "Send new FormData(contact) to your endpoint here.",
- newsletter: before
nf.reset().
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 Visit & 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: field on fields, field-label on labels, btn-primary on the
submit button. The newsletter form can be replaced the same way in the footer pattern, or by your
email service's signup embed.
For bookings, most salons use a booking system (Fresha, Phorest, Timely, Square Appointments...): on the Book online page, delete the wizard's Custom HTML block and paste the provider's widget in a Custom HTML block. For gift cards, link the "Buy" step to your provider's gift card checkout.
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 Visit & contact page, open the form's Custom HTML block and add the endpoint to the form tag:
html<form id="contact-form" class="mt-8 grid gap-5 sm:grid-cols-2" novalidate="" action="https://formspree.io/f/your-form-id" method="post"> -
In
assets/js/main.js, replace the lines after the comment "Send new FormData(contact) to your endpoint here." (thecontact.reset()and success lines) so it posts the form and only shows the thank-you message when the service accepts it:js// Send new FormData(contact) to your endpoint here. var ok = $('[data-form-success]', contact); fetch(contact.action, { method: 'POST', body: new FormData(contact), headers: { Accept: 'application/json' } }) .then(function (res) { if (!res.ok) throw new Error('Send failed'); contact.reset(); ok.hidden = false; ok.focus(); }) .catch(function () { cErr('message', 'Sorry, that did not send. Please call or email us.'); });Change the success message's text in the Custom HTML block too (the demo text says nothing was sent).
-
For a booking request by email, post the wizard's state from
confirm():jsvar data = new FormData(form); data.append('treatments', selected().map(function (id) { return serviceById[id].name; }).join(', ')); data.append('with', state.member ? memberById[state.member].name : state.stylist); data.append('date', dateKey(state.date)); data.append('time', hhmm(state.slot)); fetch('https://formspree.io/f/your-form-id', { method: 'POST', body: data, headers: { Accept: 'application/json' } });A form service only delivers the request; it doesn't block the time in anyone's diary. For real bookings, send the same data to your booking system's API and use its availability in
isBusy(). The demo reference is random: replace it with the ID your booking system returns, or remove it. For gift cards, create a checkout session on your server (for example Stripe Checkout) and redirect to it instead of showing the demo code.
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 |
|---|---|---|
bloom-saved-looks |
array of look ids, for example ["honey-balayage", "french-bob"] |
when a heart is toggled |
bloom-details |
{"name": "…", "email": "…", "phone": "…"} |
on booking confirmation, only if "Remember my details on this device" is ticked |
The keys are the same as in the HTML version. They are validated when read:
readSaved()keeps only strings that are ids inBLOOM.looks, without duplicates, and writes the cleaned list back if anything was dropped.readDetails()requires an object, keepsname(up to 80 characters),email(up to 120, and only if it looks like an email) andphone(up to 30), and removes the key if nothing valid is left. Remembered details fill in step 4 and tick "Remember my details".
Confirming a booking with "remember" unticked removes bloom-details. All storage access is wrapped
in try/catch (the store helper), so private browsing just turns saving off. Nothing is sent
to WordPress. To reset, delete the keys in your browser's developer tools (Application, Local
Storage). If you rename look ids, saved looks with the old ids are dropped on the next visit.
SEO and sharing#
Page titles come from WordPress: the page title, an em dash and the site name (for example
"Gift cards — Bloom Hair & Spa"); 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. Salons benefit from
HairSalon or BeautySalon
structured data with your name, address, phone, opening hours and booking URL, 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", "Mobile" and "Footer" navigation, main, footer), one h1 per page,
visible focus rings, the menu button with aria-expanded and aria-controls, toggle buttons with
aria-pressed for the filters, saved looks, dates and time slots, disabled and labelled unavailable
dates, live regions for the services, team and gallery counts, wizard steps with focusable headings
and aria-current="step", focus on the booking and gift card confirmations, a text description of
the gift card preview, today's opening hours row marked with aria-current="date", a native
<dialog> lightbox with focus return, a native range input for the before/after slider with
aria-valuetext, the star rating labelled for screen readers, form errors linked with
aria-describedby and aria-invalid with focus on the first error, aria-current on the current
page's links, and reduced-motion support (no reveals, instant scrolling).
When you edit: keep one Heading 1 per page, label every field you add to a form
(<label for="...">) and give it a data-error element, keep text contrast at WCAG AA (Mute is
tuned for the light backgrounds; check it if you darken the panels), give images alt text (or mark
them decorative), and keep link text meaningful.
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 those in the
wizard's stylist list):
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-bloom, 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-bloom. All text in patterns
(including the Custom HTML blocks), templates and PHP goes through WordPress's translation
functions. The script's strings (opening hours messages, the booking wizard, the gift card preview,
counts, form errors, day and month names) and the salon's data (treatment names and descriptions,
category, level and design labels, team names and roles, look titles, the salon's name and
address) are translated in PHP (freeframe_bloom_script_strings() in inc/setup.php) and passed to
the script as window.freeframeBloomL10n; a translate() pass at the start of main.js replaces
the name, label, blurb, desc, role, title, street and city fields of BLOOM with
their translations. Create a translation with Loco Translate, or with WP-CLI and Poedit:
wp i18n make-pot wp-content/themes/freeframe-bloom wp-content/themes/freeframe-bloom/languages/freeframe-bloom.potSave freeframe-bloom-fr_FR.mo (for example) in wp-content/languages/themes/ or in the theme's
languages/ folder. Page content created by Bloom setup is copied into your pages in the site's
language at that moment; translate it in the editor like any other content. Prices are formatted
with BLOOM.salon.locale and currency (en-GB, GBP), and times use the 24-hour clock.
Child themes#
Use a child theme for changes you want to keep when Bloom is updated. Create
wp-content/themes/bloom-child/style.css:
/*
Theme Name: Bloom Child
Template: freeframe-bloom
*/Templates, parts, patterns and theme.json in the child theme override the parent's files of the
same name. To change the treatments, team, hours, availability 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 and settings (deregistering the script would drop them):
add_action( 'wp_enqueue_scripts', function () {
$script = wp_scripts()->query( 'freeframe-bloom' );
if ( $script ) {
$script->src = get_stylesheet_directory_uri() . '/assets/js/main.js';
}
}, 30 );The script's image URLs come from the parent theme's assets/img/looks/ and assets/img/team/
folders. 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 new treatment doesn't appear in the booking wizard. Add both the
BLOOM.servicesentry and the checkbox in the wizard's Custom HTML block; the checkboxvaluemust equal theid. - The wizard stops working after adding a treatment or opening a link. A checkbox
valuehas no matching service inBLOOM.services(check the spelling), or a?service=link names a service that has no checkbox. (Unknown?service=and?stylist=values, including names every JavaScript object has such asconstructor, are ignored.) - No times appear for a day. The salon is closed that day in
BLOOM.hours, nobody who can do the treatments works that day (days), or every slot is taken in the demo diary. Try "Anyone available". - A "Book with" link opens with "Anyone available". The
?stylist=id doesn't exist inBLOOM.team, or that person can't do the chosen treatments. - The services or team filter hides a group or card it shouldn't. Check its classes:
ffd-service-group--<category>on each category group, andffd-team-memberplusffd-cats--<categories>(joined with_) on each team card. - The "Open now" badge or today's row looks wrong. It uses the visitor's local time and
BLOOM.hours; updateBLOOM.hourswhen you change the printed hours. - A look or portrait is missing in the lightbox or the wizard. The file must be an SVG named
after the id:
assets/img/looks/<id>.svgorassets/img/team/<id>.svg. - Some apostrophes are curly. WordPress turns straight quotes and apostrophes in page text into
curly ones (
wptexturize), so a few differ from the HTML version. To keep them straight, addadd_filter( 'run_wptexturize', '__return_false' );to a child theme'sfunctions.php. - 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 > Bloom.
- The wizard, gallery or forms 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.
- Bloom 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 > Bloom pages.
- The forms don't send anything. They are demo forms; see Forms.
- 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, portraits, looks, logos and icons
are included under the same license. Jost and Bodoni Moda are licensed under the SIL Open Font
License 1.1 (assets/fonts/jost/OFL.txt, assets/fonts/bodoni-moda/OFL.txt). Bloom Hair & Spa,
its team and its clients are fictional.