Freeframe

Documentation

Bloom

v1.0.1Updated Oct 16, 2026A calm, elegant website for a hair salon and day spa, with online booking and gift cards.

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.

  1. Download bloom.zip from freeframe.dev/templates/bloom (the WordPress tab).
  2. In your WordPress admin go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip and click Install Now, then Activate. The theme is listed as Freeframe Bloom.
  3. 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.
  4. 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:

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

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

Pages#

Each page is a page pattern (in Patterns > 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.txt

Customising#

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:

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-services on the service menu's group, and ffd-service-group--<category> on each category group (ffd-service-group--hair, --colour, --nails, --skin, --massage). See Services filter.
  • ffd-team on the team grid's group, and ffd-team-member ffd-cats--hair_colour on each team card (ffd-cats--hair_colour becomes data-cats="hair colour"). See Team filter.
  • ff-hidden on the mobile menu: the script turns it into the hidden attribute.
  • ff-no-current on the mobile menu's booking button (see Navigation, header and footer).
  • reveal fades a block in as it scrolls into view.
  • link-card on a group makes the whole group clickable: the first link inside it stretches over the group (the 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
js
"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:

  1. 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." },
  2. Add its name and desc to freeframe_bloom_script_strings() in inc/setup.php so they can be translated (without them the English text is shown).

  3. 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.

  4. 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 value must equal the service id; the script reads the name, length and price from BLOOM.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:

  1. Add an entry to BLOOM.team. days uses 0 for Sunday to 6 for Saturday; cats lists the categories they do; level is stylist, senior or director:

    js
    { "id": "maya", "name": "Maya Reid", "role": "Stylist", "level": "stylist", "cats": ["hair"], "days": [3, 4, 5, 6], "bio": "…", "specialties": ["Curly cuts"] },
  2. Add assets/img/team/maya.svg. The booking wizard shows team/<id>.svg, and the theme only passes the SVG files in assets/img/team/ and assets/img/looks/ to the script (freeframe_bloom_script_settings() in inc/setup.php), so keep the portrait an SVG with the member's id as its name, or change the IMG('team/' + o.id + '.svg') line in main.js.

  3. Add the name (and the role, if it is new) to freeframe_bloom_script_strings().

  4. 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), keep team-card reveal ffd-team-member and set ffd-cats--hair (several categories joined with _, for example ffd-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.svg in an Image block next to a paragraph with a link ("Bloom", labelled "Bloom Hair & Spa, home" for screen readers); the footer shows logo-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.svg with 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, source og-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing.
  • Favicon: assets/img/favicon.svg is used until you set a Site Icon in Settings > General.

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

The header and footer are template parts (parts/header.html, parts/footer.html) showing the header and footer patterns. Edit them in Appearance > Editor > Patterns > Header / Footer.

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=balayage ticks a treatment (it must be a service id). The service menu and the signature treatments use these links.
  • /book/?stylist=amara preselects a team member (it must be a team id). 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 with open: null has 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 whose cats include 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 (in BLOOM.team order) 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:

js
// 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 class is-closed when the salon is closed.
  • In the .hours-table (Visit & contact page), the <tr data-day="N"> for today gets the class is-today (plum, medium weight) and aria-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.

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) and aria-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 in BLOOM.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 or custom). A custom amount must be a whole number from £25 to £500.
  • Design: radios name="design" (petal, sage, noir); the preview gets the class gift-<design>.
  • To, recipient email, from, your email, message: the message has a 160-character counter.
  • Delivery: now or later; 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:

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

  2. 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">
  3. In assets/js/main.js, replace the lines after the comment "Send new FormData(contact) to your endpoint here." (the contact.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).

  4. For a booking request by email, post the wizard's state from confirm():

    js
    var 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 in BLOOM.looks, without duplicates, and writes the cleaned list back if anything was dropped.
  • readDetails() requires an object, keeps name (up to 80 characters), email (up to 120, and only if it looks like an email) and phone (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):

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

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

To deploy, zip the theme folder (named freeframe-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:

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

Save 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:

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.services entry and the checkbox in the wizard's Custom HTML block; the checkbox value must equal the id.
  • The wizard stops working after adding a treatment or opening a link. A checkbox value has no matching service in BLOOM.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 as constructor, 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 in BLOOM.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, and ffd-team-member plus ffd-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; update BLOOM.hours when 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>.svg or assets/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, add add_filter( 'run_wptexturize', '__return_false' ); to a child theme's functions.php.
  • The pages look unstyled or new classes do nothing. The stylesheet only contains classes it found when it was built. Run pnpm build after editing patterns, then reload.
  • "This block contains unexpected or invalid content". A pattern or page was edited by hand with markup that doesn't match the block. Click Attempt recovery, or re-insert the section from Patterns > Bloom.
  • The wizard, gallery or forms disappear after saving a page. Users without the unfiltered_html capability (on multisite, everyone but super admins) can't save forms and SVG in Custom HTML blocks: WordPress strips them. Edit those pages as an administrator (super admin on multisite).
  • The header menu shows other pages, or an old menu. A Navigation block without a chosen menu shows the most recent one. Select it and choose "Main menu" (or yours) in the block settings, in both the desktop and the mobile Navigation block.
  • 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.

Search documentation

Search the guides and every template's documentation