Freeframe

Documentation

Mend

v1.1.0Updated Oct 22, 2026A calm, clinical-notes website for an independent physiotherapy and sports injury clinic.

Overview#

Mend is a multi-page website template for an independent physiotherapy and sports injury clinic. The demo is Lavant Physiotherapy, a fictional clinic at 48 Whyke Road in Chichester, West Sussex, with five physiotherapists and a sports massage therapist. It is built with plain HTML, Tailwind CSS v4 and one vanilla JavaScript file (assets/js/main.js), with no other dependencies. This is version 1.1.0 (see template.json and CHANGELOG.md). Live demo: freeframe.dev/templates/mend.

The design is a clinician's notepad: biro-blue ink on warm paper, pale blue ruled lines, a red margin rule down the left of each section with index numbers (§ 01) and margin notes, and a yellow highlighter for whatever is selected. The way in is the home page's body map, "Where does it hurt?".

The demo never goes stale: opening hours, bank holidays, the booking diary, "next available appointment" and each clinician's "in clinic today" badge are worked out from today's date in UK time, so there are no fixed dates to update.

Quick start#

  1. Unzip the download.
  2. Open index.html in a browser. Everything works from the file system, with no server or build step.
  3. To preview over HTTP instead (closer to a real host), serve the folder, for example npx serve . or python3 -m http.server, and open the address it prints.

You only need Node.js (version 24 recommended) if you want to rebuild the CSS after adding Tailwind classes; see Building and deploying.

Pages#

File Purpose Notable sections and features
index.html Home "Where does it hurt?" body map (front and back) with a notes panel for the chosen area and a booking link that carries ?area=, open-now line, "when not to wait", a first-visit timeline, a seven-day diary strip of free initial assessments, the treatment index, patient stories, team and a visit section with hours
treatments.html Treatments Ten treatments as ruled entries with art, length, price, what's included, who offers them and the next free slot; filters in the left rail (?for=)
conditions.html Conditions Index of eight areas, then one section per area (?area= jumps to it) with a locator figure, common problems, suggested appointments, matching exercises and red flags; a sticky side index marks the area in view
exercises.html Exercise library 18 exercises filtered by area, level and equipment (?area=, ?level=, ?equipment=), a saved "my plan" with daily ticks, a rep and hold counter
team.html Our team A board of six portraits, then each clinician's profile with special interests, working days, "in clinic today", what they offer and their first free slot; filter by treatment (?treatment=)
book.html Book online The clinic's next seven days, then four steps: treatment, clinician, day and time, details with health screening; live summary; confirmation with a reference (?treatment=, ?clinician=, ?area=, ?day=)
prices.html Prices and insurance Self-pay table, packages with savings, a cost estimator (?treatment=), insurer checker (?insurer=), with a sticky side index
contact.html Contact Contact details beside the map, hours with bank holiday notice, contact form (?topic=), getting here, FAQ
404.html Not found Friendly message and links

Project structure#

mend/
  index.html ... contact.html, 404.html   the pages (header and footer repeated in each)
  assets/
    css/style.css        compiled Tailwind CSS (generated by npm run build, committed)
    js/main.js           all clinic data and interactions
    img/
      favicon.svg        logo mark: a page of notes with a red margin and a serif L
      treatment-room.svg treatments hero: couch, wall chart of the spine, foam roller and kettlebell
      exercise-sheet.svg exercises hero: a home exercise sheet on a clipboard with a stopwatch
      receipt.svg        prices hero: a receipt, card reader and appointment card
      clinic-front.svg   home "Visit us": the Georgian house with the green door
      spine-study.svg    lumbar spine and pelvis (conditions page, "Pain, explained simply")
      hamstring.svg      404 illustration
      map.svg            illustrated map (contact)
      team-*.svg         six illustrated portraits
      og-image.svg       social image source (1200 x 630)
      og-image.png       social image used by og:image
  src/input.css          Tailwind source: theme tokens and components
  package.json           npm scripts to rebuild the CSS
  README.md              short introduction
  DOCS.md                this guide
  CHANGELOG.md           version history
  LICENSE.txt            license (added to the download)

The body map is inline SVG in index.html. The treatment drawings (ART), the exercise figures (figureSVG()) and the small locator figures on the conditions page (locatorSVG()) are drawn by main.js, so they don't have separate files.

Customising#

Brand and colours#

All design tokens live in the @theme block at the top of src/input.css. The ones that matter:

Token Used for
--color-biro-* The ink colour: buttons (biro-600), links, headings (biro-900 = --color-ink), the dark bands and footer (biro-900), pale fills (biro-50)
--color-margin-* The red margin rule and "don't wait" warnings (margin-50 fill, margin-500 rule, margin-600/700 text)
--color-hi, --color-hi-soft Highlighter: selected body map region, pressed chips, today in the diary, the .hl swipe behind headline words
--color-ink, --color-body, --color-muted Heading, body and secondary text
--color-paper, --color-paper-dark, --color-sheet Page background, warm panels and white sheets
--color-rule, --color-rule-strong Ruled lines and borders

The component layer in the same file holds the few repeated pieces: .wrap (page width), .notes and .rail (the two-column notes layout with the red margin rule), .ruled (ruled-paper background), .idx, .kicker and .note (mono index numbers, labels and margin notes), .hl, buttons (.btn-ink, .btn-line, .btn-paper), .chip, .field, .tag, the booking pieces (.choice, .day, .slot, .step, .yesno), the body map (.region), .side-index and .timeline. Everything else is Tailwind utilities in the markup.

To change the ink colour, edit the biro scale and rebuild:

css
@theme {
  --color-biro-600: #2d5a4a; /* buttons and links */
  --color-biro-800: #1f4034; /* hover */
}

Keep biro-600 at a contrast of at least 4.5:1 against white, because button text is white, and keep text on the highlighter in --color-ink. The SVG files in assets/img/ use hex colours directly; search and replace them if you change the palette a lot.

Typography#

Headings use Piazzolla, body text uses Reddit Sans, and index numbers, labels and margin notes use Reddit Mono, loaded from Google Fonts with one <link> in the <head> of every page:

html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Piazzolla:ital,opsz,wght@0,8..30,400..600;1,8..30,400..500&amp;family=Reddit+Mono:wght@400;500;600&amp;family=Reddit+Sans:wght@400;500;600;700&amp;display=swap">

To swap fonts, change that link on every page and the --font-display, --font-sans and --font-mono tokens in src/input.css, then rebuild the CSS.

Content#

Page text is plain HTML in each file. Everything the scripts use is a constant at the top of assets/js/main.js:

Constant What it holds
CLINIC Name, time zone (Europe/London) and phone number
HOURS Opening hours by weekday (0 = Sunday), 24-hour strings; null means closed
CLOSE_ON_BANK_HOLIDAYS true to close on bank holidays in England and Wales
CLOSED_EVERY_YEAR Dates closed every year (Christmas Day, Boxing Day, New Year's Day)
EXTRA_CLOSED One-off closures, { date: 'YYYY-MM-DD', label: '...' }
TREATMENTS Treatments: name, minutes, price, tags, blurb, what's included, who it's for
TREATMENT_TAGS Filter names on treatments.html
CLINICIANS People: name, role, the treatments they offer, shifts by weekday
BOOKING Diary settings (see Booking diary)
BODY_AREAS Body map areas: label, intro, conditions, suggested treatments, exercise filter, red flags
EXERCISES, EXERCISE_AREAS, LEVELS, EQUIPMENT Exercise library and its filters
PACKAGES, PACKAGE_MONTHS Packages on prices.html
INSURERS Insurers: name, direct or claim, whether they need an authorisation number or GP referral

The opening hours are also written out in the HTML (every dl[data-hours] in the footer, the home page's "Visit us" section and the contact page). If you change HOURS, change those lists too.

Adding a treatment takes three steps: add an entry to TREATMENTS, add a drawing to ART with the same key (a 96 x 96 SVG fragment), and add the key to the treatments list of each clinician who offers it.

js
var TREATMENTS = {
  // ...
  hydro: {
    name: 'Hydrotherapy', minutes: 45, price: 55, tags: ['surgery', 'pain'],
    blurb: 'Exercise in a warm pool, where the water takes some of your weight.',
    includes: ['Sessions at a partner pool', 'A programme to carry on alone'],
    who: 'After joint replacements and for widespread pain.'
  }
};

Adding a clinician: add an entry to CLINICIANS, a portrait at assets/img/team-<id>.svg (used by the booking form) and a profile on team.html with data-clinician="<id>" (and a tile in the portrait board at the top). The script fills in the profile's [data-days], [data-in-clinic], [data-offers] and [data-clinician-next] elements.

js
pemberton: {
  name: 'Ada Pemberton', first: 'Ada', role: 'Physiotherapist',
  treatments: ['initial', 'followup'],
  shifts: { 2: ['09:00', '17:00'], 4: ['09:00', '17:00'] }
}

Body map areas, exercises and insurers follow the same pattern; the comments above each constant list the fields.

Images and artwork#

All artwork is SVG drawn for this template: no photos and no external images.

  • Portraits (team-*.svg) are 240 x 240. Replace them with square photos of the same name; they are shown in squares with a thin border.
  • The hero drawings (treatment-room.svg, exercise-sheet.svg, receipt.svg) are 640 x 480 and sit bottom-aligned in the right half of each hero; clinic-front.svg is 640 x 460 and map.svg 640 x 420. Keep similar proportions or adjust the width and height attributes on the <img> tags.
  • The body map is inline SVG in index.html. Each region is a <g class="region" role="radio" data-area="..."> whose key must match BODY_AREAS. It holds the region's shapes (.r-fill, drawn over both arms or legs where it applies), a pin (.r-pin, .r-pin-dot), a leader line and the label (.r-label, hidden below 640px). The figures underneath are plain outlines in an aria-hidden group, so nothing that looks clickable is decoration: every pin and shaded region is a control.
  • Exercise drawings come from the pose of each exercise: joint coordinates in a 160 x 100 box (floor at y = 90) for the torso, head and near and far arm and leg, plus optional props (equipment), ghost (the start position, drawn dashed), focus ([x, y, r], a highlighter mark on the working muscles) and marks (red-pencil arrows).
  • The social image is assets/img/og-image.png (1200 x 630), made from og-image.svg. After editing the SVG, regenerate the PNG (for example with Inkscape, or rsvg-convert -w 1200 og-image.svg > og-image.png).
  • The favicon is assets/img/favicon.svg.

The header (logo, numbered navigation, phone, "Book online" and the mobile menu) and the footer (with "When not to wait", hours, address and links) are repeated in every HTML file. To add a page, copy an existing page, then add a link to the desktop <nav aria-label="Main">, the mobile <nav id="mobile-menu"> and, if you like, the footer's "Useful links" on every page. Mark the current page with aria-current="page" on its links.

Features#

Opening hours and bank holidays#

renderHours() highlights today's row in every [data-hours] list, fills the open-now line ([data-open-status]: "Open now · until 8 pm" or "Closed now · opens tomorrow at 7:30 am") and writes the next closure into each [data-holiday-note] element. The number in data-holiday-note="21" is how many days ahead to look. It runs again every minute.

bankHolidays(year) works out the bank holidays in England and Wales for any year: New Year's Day, Good Friday and Easter Monday (from the date of Easter), the early May, spring and summer bank holidays, Christmas Day and Boxing Day, including the substitute weekdays when a holiday falls at a weekend. closedReason(key) combines those with CLOSED_EVERY_YEAR and EXTRA_CLOSED. For Scotland or Northern Ireland, add the extra days to bankHolidays(); for a one-off extra bank holiday, use EXTRA_CLOSED.

All times are in Europe/London (CLINIC.timeZone), whatever the visitor's own time zone.

Booking diary#

The diary is built from data, not stored appointments:

  1. For each clinician who offers the treatment, shiftOn() takes their shift for that weekday and clamps it to the opening hours (no shift on closed days).
  2. slotsFor() steps through the shift every BOOKING.slotStep minutes, keeps starts where the whole appointment fits, skips anything sooner than BOOKING.noticeMinutes from now and marks a share of slots as taken (BOOKING.demoBusyShare) using a stable hash, so the diary looks lived-in.
  3. firstAvailable() scans BOOKING.daysPerPage * BOOKING.pages days (14 by default) for the first free start.
Setting Default Meaning
slotStep 30 Minutes between start times
daysPerPage 7 Days shown at once in the day picker
pages 2 Pages of days (the diary covers 14 days, today included)
noticeMinutes 120 Earliest bookable start after now
demoBusyShare 0.45 Share of slots shown as taken; set to 0 when you connect a real diary
refPrefix 'LP' Prefix of booking and message references

Because everything is worked out from today, the diary always rolls forward. Any element with data-next-slot="<treatment>" shows the next free slot ([data-next-slot-text], [data-next-slot-who]) and its link is pointed at that treatment and clinician; the home and treatments pages use data-next-slot="initial".

Diary strips. renderDiaryStrips() fills every <ol data-diary-strip> with the next seven days. With a treatment key (data-diary-strip="initial" on the home page) each open day shows how many times are free and the first one, and links to book.html?treatment=initial&day=YYYY-MM-DD; with data-diary-strip="hours" (the booking page) it shows the opening hours. Closed days are hatched and say why: "Closed on Sundays", "Christmas Day", "The spring bank holiday" and so on. It runs again every minute.

To show real availability, replace slotsFor() with a call to your booking system's API, or link the "Book online" buttons to your practice management software's booking page instead.

Booking form#

book.html is one <form data-booking> with four <fieldset data-step> steps:

  1. Treatment: radio cards built from TREATMENTS.
  2. Clinician: "First available" plus everyone who offers the treatment, with their days and next slot.
  3. Day and time: seven days at a time (Earlier and Later move between the two pages). Days with no times are disabled and labelled Closed (with the reason for screen readers, such as "closed on Sundays" or "closed for Christmas Day"), Off (nobody working) or Full. Times are grouped into morning, afternoon and evening; with "First available" each time shows who it's with.
  4. Your details: name, email, phone, area (filled from BODY_AREAS), notes and the health screening.

The step list and the summary sit in the left rail (above the form on phones) and update as you go. URL parameters prefill it: ?treatment= (a TREATMENTS key, which skips to step 2), ?clinician= (a CLINICIANS key, used if they offer the treatment), ?area= (a BODY_AREAS key: it fills the area select and, on step 1, shows a note and marks that area's suggested treatments with "Suggested for the knee") and ?day= (a YYYY-MM-DD date inside the diary, used with ?treatment=: it opens step 3 on that day). Values are checked with own-key lookups and the date against the diary, so anything else is ignored.

Health screening. Four yes/no questions ([data-redflag="cauda" | "trauma" | "systemic" | "weakness"]) must be answered. Any "yes" shows [data-redflag-panel] with the advice from REDFLAG_ADVICE (A&E, 999 or NHS 111) and disables the submit button until the answer is changed. The optional checkboxes (pregnancy, recent surgery, blood thinners, pacemaker) add notes for treatments where they matter, such as shockwave or acupuncture. Review this wording with your own clinical team: it is general guidance, not a triage tool.

On submit, the form shows [data-confirm] with the first name, a reference like LP-4K7Q, the appointment, price and when reception will confirm (the end of the next open day, from confirmBy()).

Keyboard and screen reader notes: the step list uses aria-current="step", focus moves to each step's legend, errors are announced with role="alert", and the yes/no answers are real radio buttons.

Body map#

The home page opens with "Where does it hurt?": an inline SVG with front and back line drawings. The SVG is a role="radiogroup" ([data-body-map]); each region is a role="radio" with aria-checked and an aria-label that matches its visible label. Only the selected region is in the tab order: arrow keys move between areas (Home and End jump to the first and last), and Enter or Space selects. The buttons under the map ([data-area-btn]) do the same for touch screens and small displays, where the labels inside the SVG are hidden; on narrower screens choosing one scrolls to the notes.

Choosing an area fills the notes panel ([data-area-panel]) from BODY_AREAS: the title, intro and three common problems, "Book an assessment" (book.html?treatment=initial&area=<area>) and "Read about the knee" (conditions.html?area=<area>). ?area= selects an area on load (default lower-back) and is updated as you choose. Regions are shaded on hover and turn highlighter yellow when chosen (the yellow multiplies over the line drawing, so the anatomy stays visible).

Conditions pages#

conditions.html renders one <article data-area-section> per BODY_AREAS entry into [data-area-sections]: the intro, a locator figure with the area marked, common problems (how they often feel and what usually helps), the suggested appointments (booking links with ?area=), a link to the matching exercises and "Don't wait for physio if…" with the red flags. The index at the top and the side index ([data-area-link]) link to #<area>; an IntersectionObserver marks the area in view with aria-current="true". ?area= scrolls to that area on load.

Exercise library and my plan#

exercises.html renders EXERCISES as cards with a drawing, area, level, equipment, dose and a "How to do it" disclosure. Filters: the area chips (?area=), level (?level=) and equipment (?equipment=), all validated against EXERCISE_AREAS, LEVELS and EQUIPMENT.

"Add to my plan" ([data-plan-toggle], with aria-pressed) saves the exercise to the plan on the right. The library is a grid of ruled sheets; cards in the plan are marked "In my plan". Plan entries can be ticked off for today, moved up or removed; "Print" prints the page with the plan (the counter is hidden in print) and "Clear plan" empties it. The plan holds up to PLAN_MAX (12) exercises and is saved in localStorage (see Saved data).

Rep and hold counter#

The counter under the plan works with any exercise (plan exercises are listed first; "Start next exercise" picks the first one not yet ticked today). For rep exercises, "+1 rep" counts towards the target; for holds it runs a countdown, which can be paused. After each set it counts down the exercise's rest seconds (or you can skip the rest), and when all sets are done it ticks the exercise off in your plan. Progress is announced through a polite live region at the end of each set, not every second.

Team#

team.html profiles are static HTML; the script adds the weekly shift grid (Monday to Saturday), "In clinic today, until 5 pm" or "Next in clinic tomorrow, from 9 am", the treatments each person offers and their first free slot. The chips filter cards by treatment (?treatment=).

Prices, estimator and insurers#

prices.html builds the price table from TREATMENTS and the package list from PACKAGES (the saving is price x sessions - package price). The table sits in a scrolling wrapper with tabindex="0", role="region" and an aria-label, so keyboard users can scroll it if a long treatment name ever makes it wider than the screen; keep those attributes if you add columns. The estimator multiplies the chosen treatment by the number of sessions and, when "Use a package" is ticked, uses as many whole packages for that treatment as fit. ?treatment= preselects a treatment.

The insurer checker lists INSURERS and, for the one you pick, explains whether the clinic bills them directly or you pay and claim, and whether an authorisation number or GP referral is needed. ?insurer= preselects one.

Previewing another date#

Add ?now=YYYY-MM-DDTHH:MM (UK time) to any page to see it at another moment, for example book.html?now=2027-12-24T16:30 to see the diary around Christmas. Invalid values are ignored.

Scroll reveals, mobile menu and back to top#

Elements with the reveal class fade in as they scroll into view (everything above them is revealed too, so jumping down the page never leaves gaps). With prefers-reduced-motion, nothing animates. The mobile menu button has aria-expanded and closes with Escape. The back-to-top button is created by the last block in main.js; delete that block and the .back-to-top styles to remove it.

Forms#

Form Where Validates
Booking request book.html ([data-booking]) Treatment and time chosen; name, email, phone; all four screening questions answered and none "yes"
Contact contact.html (#contact-form) Name, email (format), phone if given, message (at least 10 characters)

Errors appear under each field and are linked with aria-describedby; the first invalid field gets focus. The demo forms don't send anything until you connect them. The contact form then shows its data-success-text ("Thanks, {name}. Your message is with us (reference {ref})...").

To connect a form service such as Formspree, give the contact form an action and send it from the submit handler in main.js (look for "Connect here"), showing the success message only when the service answers OK:

html
<form id="contact-form" action="https://formspree.io/f/your-id" method="POST" data-validate novalidate ...>
js
fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
  .then(function (res) {
    if (!res.ok) throw new Error('Send failed');
    ok.textContent = msg;
    ok.hidden = false;
    form.reset();
  })
  .catch(function () { ok.textContent = 'Sorry, that didn’t send. Please call us.'; ok.hidden = false; });

The booking form has its own "Connect here" comment in the submit handler. Post new FormData(bookingForm) along with the chosen slot (state.slot: date key, minutes and clinician id) to your booking system or form service, and show [data-confirm] only on success. Health information is sensitive: use a service that is suitable for it under UK GDPR, and keep the clinic's privacy notice up to date.

Saved data (localStorage)#

Key Shape Validated on read
mend-plan { "items": ["glute-bridge", "calf-raise"], "done": { "date": "2026-10-21", "ids": ["calf-raise"] } } Only known exercise ids, no duplicates, at most PLAN_MAX; ticks are kept only if date is today (UK time) and the id is in the plan

All access goes through one safeStorage object. When localStorage is unavailable (sandboxed iframes, strict privacy settings), it falls back to memory, so the plan still works for the page view. To reset, use "Clear plan" or remove the key in the browser's developer tools.

SEO and sharing#

Each page has its own <title>, meta description and Open Graph tags in the <head>. Before going live:

  • Set og:image to an absolute URL, for example <meta property="og:image" content="https://www.yourclinic.example/assets/img/og-image.png">, and add og:url with each page's address.
  • Add a sitemap.xml and robots.txt at the site root.
  • Consider Physiotherapy or MedicalClinic structured data (JSON-LD) with your address, phone and opening hours.

Accessibility#

Built in: a skip link, landmarks, one <h1> per page, visible focus outlines, aria-current in the navigation, an accessible mobile menu, a keyboard-operable body map (radio group with arrow keys, every drawn region a real control), focusable scrolling regions, real form controls with linked error messages, live regions for counts and the counter, and prefers-reduced-motion support. Colours meet WCAG AA contrast.

When editing: keep one <h1> per page, give new inputs a <label>, keep aria-controls and aria-labelledby pointing at ids that exist, and if you add body map areas, give each region an aria-label.

Building and deploying#

The compiled CSS is committed, so you only rebuild when you change src/input.css or add new Tailwind classes to the HTML or main.js:

sh
npm install
npm run build   # once
npm run watch   # rebuild on every change

To deploy, upload the folder to any static host: Netlify Drop, GitHub Pages, Cloudflare Pages or your own server. There is no server-side code. All links are relative, so it also works in a subfolder.

Troubleshooting#

  • The diary shows times on a day we're closed. Add the date to EXTRA_CLOSED, or check HOURS and the clinician's shifts (weekday 0 is Sunday).
  • A treatment has no times at all. Make sure at least one clinician lists its key in treatments, and that their shifts are long enough for the treatment's minutes.
  • Times are an hour out. All times are UK time. If your clinic is elsewhere, change CLINIC.timeZone and bankHolidays().
  • New Tailwind classes have no effect. Rebuild the CSS (npm run build). Classes used only in main.js must be written out in full there.
  • The exercise plan disappears after reloading. The browser is blocking storage (private mode or a sandboxed preview); the plan then lasts only for the page view.
  • The body map labels are missing on phones. That's intended: below 640px the labels inside the SVG are hidden, the drawing is cropped to the figures and the buttons under the map name the areas.
  • A new body map region can't be reached with the keyboard. Give it class="region" role="radio" tabindex="-1" data-area="<key>" and an aria-label matching its label, and add the key to BODY_AREAS.

License#

MIT. The code and all included artwork are free for personal and commercial use; see LICENSE.txt.

The fonts, Piazzolla, Reddit Sans and Reddit Mono, are licensed under the SIL Open Font License and served by Google Fonts.

Lavant Physiotherapy, its staff, patients, insurers and contact details are fictional. The health information is general and should be reviewed by a qualified clinician before you publish it.

Search documentation

Search the guides and every template's documentation