Overview#
Kinetic is a complete website for an independent personal trainer or a small personal training studio. The demo
content is for Fenline Performance, a fictional two-coach studio in Romsey, Cambridge (UK), run by Ffion Beckett
and Yusuf Dacre. It has a goal picker that recommends a programme, programmes and client stories filterable by goal,
progress charts, a pricing calculator, a booking flow with a week view and PAR-Q style health questions, and free
training tools (a plan builder, a one-rep max estimator and a pace calculator). Italic condensed Sofia Sans
headlines, a cobalt and flare palette on warm paper and SVG-drawn athletes give it an athletic-editorial feel. This is
the Next.js version: Next.js 16 (App Router, static export), React 19, TypeScript and Tailwind CSS v4, with no other
dependencies. This guide covers version 1.0.0 (see template.json). Live demo:
freeframe.dev/templates/kinetic.
The copy uses British English, pounds sterling, 24-hour times and the Europe/London time zone. All health content is
general and carefully worded; review it with a qualified professional before you publish your own site.
Quick start#
Requirements: Node.js 24 and pnpm.
pnpm install
pnpm devOpen http://localhost:3000. Pages reload as you edit. Other scripts:
| Script | What it does |
|---|---|
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types and run TypeScript |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with a live "next free consult" card, goal ticker, goal picker with five recommendation panels, programmes teaser, three client progress charts, how it works, testimonials, pricing teaser with the Kickstart offer and a health note |
/programmes |
src/app/programmes/page.tsx |
Seven programmes filterable by goal (?goal=), with what's included, prices, facts and booking links; comparison table |
/pricing |
src/app/pricing/page.tsx |
Kickstart intro offer (#kickstart), session packs, memberships (#memberships), cost calculator (#calculator), monthly vs pay-per-session table, FAQ (#faq), small print (#terms) |
/results |
src/app/results/page.tsx |
Six anonymised progress stories with SVG charts, filterable by goal (?goal=), review wall |
/tools |
src/app/tools/page.tsx |
Plan builder (#plan), one-rep max estimator (#one-rep-max), pace calculator (#pace) |
/about |
src/app/about/page.tsx |
Studio story, coach profiles (#ffion, #yusuf), approach, milestones, studio facts and safety note |
/contact |
src/app/contact/page.tsx |
Contact form, map, getting there, studio hours with open-now status, social links (#social), privacy note (#privacy) |
/book |
src/app/book/page.tsx |
Four-step booking flow with a week view, health questions and a confirmation with .ics download; accepts ?type=, ?coach= and ?goal= |
| 404 | src/app/not-found.tsx |
Friendly error page with links back into the site (noindex) |
Project structure#
kinetic/
src/
app/
layout.tsx <html>, skip link, header, footer, toast, page effects, back to top, site metadata
fonts.ts Sofia Sans and Sofia Sans Extra Condensed (next/font/local)
globals.css Tailwind entry: @theme tokens and component classes
icon.svg favicon (the slanted F logo)
opengraph-image.png social sharing image (1200 x 630)
page.tsx home page
not-found.tsx 404 page
programmes/ pricing/ results/ tools/ about/ contact/ book/ one page.tsx per route
components/
SiteHeader.tsx announcement bar, sticky header, main menu, mobile menu
SiteFooter.tsx footer with contact details, links and studio hours
PageHero.tsx the hero at the top of the inner pages
Icon.tsx line icons (<Icon name="calendar" />), <Stars /> and the logo mark
Hours.tsx open-now badge and studio hours list
GoalPicker.tsx home page goal picker and "next free consult" card
GoalFilter.tsx goal chips over a list (programmes and client stories)
ResultChart.tsx SVG progress charts
PricingCalculator.tsx pricing calculator
Tools.tsx plan builder, one-rep max estimator and pace calculator
Booking.tsx booking flow
ContactForm.tsx contact form
useValidation.tsx form validation hook and messages
Toast.tsx, BackToTop.tsx, PageEffects.tsx, Year.tsx small site-wide helpers
data/
studio.ts STUDIO, HOURS, CLOSED_DATES, DAY_NAMES
coaches.ts COACHES, GROUP_CLASSES, GROUP_SIZE
booking.ts SESSIONS, BOOKING, SESSION_OPTIONS, COACH_OPTIONS, HEALTH_QUESTIONS
goals.ts GOALS, GOAL_KEY and GOAL_PICKS (goal picker panels)
programmes.ts PROGRAMMES
pricing.ts PRICING (calculator prices)
results.ts RESULTS (chart data), STORIES and HOME_STORIES
plan.ts EQUIPMENT, FOCUS, PLAN_DAYS, SPLITS, SCHEMES, PLAN_KEY, ORM_TABLE, DISTANCES
nav.ts NAV (main menu)
lib/
time.ts dates and times in UK time, money(), hash()
schedule.ts openStatus(), hoursText(), slotsFor(), firstAvailable()
client.ts useNow(), toast(), store() and read() for localStorage
metadata.ts pageMeta() for per-page titles and descriptions
style.ts delay() and vars() for CSS custom properties
asset.ts asset() prefixes public/ files with the base path
fonts/ Sofia Sans woff2 files and their licence (OFL.txt)
public/img/ athlete pictograms, coach portraits, studio, map and the og-image.svg source
next.config.ts static export and base path
template.json, README.md, CHANGELOG.md, DOCS.mdCustomising#
Brand and colours#
All design tokens live in the @theme block at the top of src/app/globals.css. Tailwind turns each token into
utilities, so --color-cobalt-600 becomes bg-cobalt-600, text-cobalt-600 and so on.
| Token | Used for |
|---|---|
--color-cobalt-50 to --color-cobalt-950 |
Brand blue: buttons, links, heading accents, dark bands (900/950) |
--color-flare-50 to --color-flare-800 |
Energy colour: highlights, button shadows, stripes; 700/800 for text on light backgrounds |
--color-sun-* |
Small highlights (today's hours row, chart markers) |
--color-mint-* |
Success and "included" ticks, positive outcomes |
--color-ink, --color-body, --color-muted |
Heading, body and secondary text |
--color-paper, --color-mist, --color-line |
Page background, quiet fills and borders |
To change the brand colour, replace the cobalt scale (keep 600 dark enough for white text, at least 4.5:1):
@theme {
--color-cobalt-600: #0f766e; /* buttons and links */
--color-cobalt-700: #115e59; /* hover */
--color-cobalt-950: #042f2e; /* footer and dark bands */
}A few drawings use hex values directly (#2b3be0 cobalt, #ff5a33 flare, #0f1330 ink): the SVG files in
public/img/, the inline hero art in the pages, LogoMark in src/components/Icon.tsx and the chart colours in
src/components/ResultChart.tsx. Update them too if you change the palette.
Typography#
The fonts are self-hosted with next/font/local in src/app/fonts.ts, from the same woff2 files Google Fonts serves
(src/fonts/): Sofia Sans Extra Condensed (weights 600 to 900, upright and italic) and Sofia Sans (400 to 900, italic
400 to 700), each split into latin and latin-ext files. Every face keeps its plain family name, so the tokens in
globals.css refer to them directly:
--font-display: "Sofia Sans Extra Condensed", "Arial Narrow", "Roboto Condensed", "Helvetica Neue", sans-serif;
--font-sans: "Sofia Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-display is used by .display, .section-title, .card-title and buttons; --font-sans for everything else.
To swap a font, put its woff2 files in src/fonts/, point the localFont() calls in fonts.ts at them, change the
font-family declaration there and the token in globals.css. Condensed display faces such as Saira Extra Condensed
or Barlow Condensed suit the layout best; wider faces need smaller heading sizes.
Content#
Page text lives in the route files (src/app/**/page.tsx). Everything the interactive parts use, and the repeated
lists, live in typed data files in src/data/:
| Export | File | What it holds |
|---|---|---|
STUDIO |
studio.ts |
Name, time zone (Europe/London), zone name, phone, email and address (used in .ics files) |
HOURS |
studio.ts |
Opening hours by weekday (0 = Sunday, null = closed), 24-hour HH:MM strings |
CLOSED_DATES |
studio.ts |
Bank holidays and closures as YYYY-MM-DD |
COACHES |
coaches.ts |
Each coach's name, short name, role, portrait and shifts (1:1 hours by weekday) |
GROUP_CLASSES, GROUP_SIZE |
coaches.ts |
Small-group classes (weekday, start time, coach, name) and the class size |
SESSIONS |
booking.ts |
Bookable session types: label, minutes, price, price label, coaches, notice and whether health answers trigger a call-back |
BOOKING |
booking.ts |
Slot spacing, weeks shown and the demo "busy" share |
SESSION_OPTIONS, COACH_OPTIONS |
booking.ts |
The cards in steps 1 and 2 of the booking flow |
HEALTH_QUESTIONS |
booking.ts |
The seven health questions |
GOALS, GOAL_PICKS |
goals.ts |
The five goal ids and labels, and the goal picker's cards and panels |
PROGRAMMES |
programmes.ts |
The programmes on /programmes |
PRICING |
pricing.ts |
Calculator prices: pay per session, packs, membership rates |
RESULTS, STORIES, HOME_STORIES |
results.ts |
Chart data and the client stories on /results and the home page |
FOCUS, PLAN_DAYS, SPLITS, SCHEMES, EQUIPMENT |
plan.ts |
Plan builder rules and exercises |
The hours lists are built from HOURS, so they always match. Prices also appear as text on the pricing page, the home
page and in PROGRAMMES: keep them in step with SESSIONS and PRICING.
Add a coach. Add an entry to COACHES (and its id to the CoachId type), list them in the coaches of each
session they offer, add a card to COACH_OPTIONS and a profile in src/app/about/page.tsx:
maya: {
name: "Maya Lin",
short: "Maya",
role: "Coach",
img: "/img/coach-maya.svg",
shifts: { 2: [["16:00", "20:30"]], 4: [["16:00", "20:30"]], 6: [["08:00", "12:00"]] },
},Add a session type. Add it to SESSIONS (and its id to SessionId) and a card to SESSION_OPTIONS:
assessment: { label: "Fitness assessment", minutes: 60, price: 55, priceLabel: "£55", coaches: ["yusuf"], notice: 1440, screen: true },Add a programme. Add an object to PROGRAMMES. Its goals decide which filters it appears under and which "Good
for" tags it shows; the filter counts update automatically:
{
id: "teens",
img: "/img/pose-sport.svg",
format: "Max 4 · In studio",
title: "Teen athletes",
text: "Safe, coached strength training for 14 to 17-year-olds.",
included: ["Technique first", "Parent updates every month"],
goals: ["sport", "strength"],
price: "£65",
per: "a month",
facts: [["Length", "45 min"], ["Coach", "Yusuf"], ["Where", "Studio, Romsey"]],
book: { href: "/book?type=consult&goal=sport", label: "Book a free consult" },
ask: "sport",
},Add a client story chart. Add data to RESULTS (and the id to ChartId), then a story to STORIES:
plank: { label: "Plank hold", unit: "reps", x: ["Wk 1", "Wk 4", "Wk 8"], y: [20, 45, 70], better: "up" },unit can be kg, cm, reps, steps or time (values in seconds, shown as m:ss). With better: "down" the
axis is flipped so improvement still rises.
Images and artwork#
All artwork is SVG in public/img/ and drawn for this template:
pose-strength.svg,pose-fatloss.svg,pose-mobility.svg,pose-sport.svgandpose-postnatal.svgare 120 x 120 pictograms. On selected goal cards and programme headers they are turned white with a CSS filter (brightness-0 invert), so keep them single-colour plus flare accents.coach-ffion.svgandcoach-yusuf.svgare square portraits. Replace them with square photos (at least 480 x 480) and keep thealttext on the about page describing the person.studio.svgis 16:10 andmap.svgis 10:7. You can swap the map for an embedded map, but that adds external requests and cookies.- The home hero, page heroes and 404 art are inline SVG in the route files.
Reference files in public/ with asset() (src={asset("/img/map.svg")}) so they work under a base path. The social
image is src/app/opengraph-image.png (1200 x 630), rendered from public/img/og-image.svg; export the SVG to PNG at
1200 x 630 after editing it. The favicon is src/app/icon.svg.
Navigation, header and footer#
The header lives in src/components/SiteHeader.tsx (announcement bar, logo, main menu, "Book a free consult" button
and mobile menu) and the footer in src/components/SiteFooter.tsx. The main menu comes from NAV in src/data/nav.ts;
the mobile menu adds Home first and Book online last. To add a page, create src/app/<route>/page.tsx and add it to
NAV. The current page gets aria-current="page" automatically. The desktop menu appears from 1280 px (xl); below
that the menu button is shown. The footer's link columns are the TRAIN and STUDIO_LINKS arrays at the top of
SiteFooter.tsx.
Features#
Goal picker#
GoalPicker in src/components/GoalPicker.tsx shows five radio cards (input[name="goal"]) and the matching panel
([data-goal-panel="strength"] and so on) from GOAL_PICKS in src/data/goals.ts. The choice is saved under
kinetic-goal and preselected next time; /?goal=sport selects a goal too and wins over the saved one. The panels are
in an aria-live="polite" region and the cards are a native radio group (arrow keys move between goals).
Programme and story filters#
/programmes and /results use GoalFilter (src/components/GoalFilter.tsx): chip buttons ([data-filter-value],
aria-pressed) over items with data-goals. Each chip shows its count, and a status line (role="status") reads
"Showing 2 for strength". The choice is written to ?goal= with history.replaceState, so /programmes?goal=postnatal
opens filtered; unknown values fall back to "All". Choosing a goal also updates kinetic-goal. The page passes the
items (any element with a data-goals attribute) and the empty-state text.
Progress charts#
ResultChart in src/components/ResultChart.tsx draws a line chart from RESULTS as inline SVG when the site is
built, with grid lines, axis labels, an area fill and the final value highlighted. Each SVG has role="img" and an
aria-label summary such as "5 km time: 31:12 at Wk 1, 26:28 at Wk 12 (lower is better)." The line draws itself in
when the chart scrolls into view (the figure has data-reveal); with reduced motion it appears immediately.
Next free consult#
NextConsult (in GoalPicker.tsx) shows the first bookable consultation from the booking engine on the home page, for
example "Tomorrow 07:00 with Ffion". It is filled in by the browser, so the static page is never out of date.
Booking flow#
Booking in src/components/Booking.tsx has four steps and a confirmation:
- Session: cards from
SESSION_OPTIONS, one perSESSIONSkey. - Coach: no preference, Ffion or Yusuf (
COACH_OPTIONS). Coaches who don't offer the chosen session are disabled, and each card shows its next opening ([data-coach-next]). - Time: a Monday-to-Sunday week view with previous and next week buttons, for this week and the next
BOOKING.weeksAhead - 1weeks. It opens on the week of the first available time. - Your details: name, email, mobile, optional goal and notes, seven yes/no health questions and a consent box.
- Confirmation: a summary, the coach's portrait and an Add to calendar (.ics) button.
How times are built, in slotsFor() in src/lib/schedule.ts:
- A 1:1 session can start every
BOOKING.slotStepminutes inside the coach'sshiftsand the studioHOURS, and must finish by the end of the shift. - It can't overlap a small-group class the coach is leading (
GROUP_CLASSES, 60 minutes). - It must start at least
noticeminutes from now (2 hours for consults, 12 hours for training), in UK time. CLOSED_DATESand closed weekdays show "Studio closed" or "Closed".groupsessions use the fixedGROUP_CLASSEStimes and show the spaces left.- With "No preference", each time goes to the first coach in the session's
coacheslist who is free. BOOKING.demoBusySharehides a stable, pseudo-random share of times so the demo looks realistic. Set it to0when you connect real availability.
URL parameters preselect choices: /book?type=pt60&coach=yusuf&goal=sport. type must be a SESSIONS key, coach a
COACHES key that offers that session, and goal a GOALS key (it fills the goal field; otherwise the saved
kinetic-goal is used).
All dates and times are calculated and shown in STUDIO.timeZone (Europe/London), whatever the visitor's own time
zone. Time-dependent parts (next openings, the week view) are rendered in the browser after the page loads, so the
static export never shows stale times. The .ics file (fenline-session.ics) stores the start and end in UTC, so
calendars convert it correctly.
Accessibility: each step heading receives focus when the step opens, the progress bar marks the current step with
aria-current="step", step errors use role="alert", each day in the week view is a labelled group and every time is
a radio button with a full label (for example "Tuesday 20 October 2026, 07:30, Ffion").
Health questions#
Step 4 asks the seven PAR-Q style yes/no questions in HEALTH_QUESTIONS (name="parq1" to parq7). They are
required. As soon as they are answered, [data-health-outcome] shows one of three messages:
| Outcome | When | Result |
|---|---|---|
clear |
All answers are no | Booking confirms as normal |
talk |
Any yes, for a session with screen: true |
The button changes to "Send call-back request" and the confirmation explains that it is a request, not a booking, with no .ics file |
consult |
Any yes, for the free consultation (screen: false) |
The booking goes ahead, with a note that the consult is the right place to talk it through |
The wording is deliberately cautious: it asks people to talk to the coach first and to check with their doctor. Keep it that way, and have a professional review the questions for your country before you publish. Health answers are special category data under UK GDPR: if you connect this form, store them securely and only as long as you need.
Pricing calculator#
PricingCalculator (src/components/PricingCalculator.tsx) has a sessions-a-week stepper (1 to 4) and a 45/60-minute
toggle. costs() compares, per month (PRICING.weeksPerMonth = 52 / 12):
- Pay per session:
PRICING.payg[length]x sessions a month. - Session pack: the largest pack in
PRICING.packs[length]you'd use withinPRICING.packWeeks, priced per session. - Monthly membership:
PRICING.member[length][perWeek - 1]x sessions a week x 52 / 12, rounded to the pound.
The cheaper of pack and membership is marked "Best value", and the saving against pay per session is shown per month
and per year (two 60-minute sessions a week: £537, £459 and £433, a saving of £104 a month). The results list is
aria-live="polite". Change prices in PRICING and in the tables on the page.
Plan builder#
PlanBuilder in src/components/Tools.tsx builds an illustrative week from four radio groups: days a week (2 to 5),
goal, equipment (gym, dumbbells, bodyweight) and session length (30, 45 or 60 minutes, showing 3, 4 or 5
exercises). Training days come from PLAN_DAYS, the session for each day from SPLITS[goal][days], exercises from
FOCUS (each exercise is [gym, dumbbells, bodyweight]) and the sets and reps from SCHEMES or the session's own
scheme. Rest days are filled in automatically. The postnatal goal shows an extra safety note.
The choices are saved under kinetic-plan and restored on the next visit; without a saved plan, the goal comes from
?goal= or kinetic-goal. Print opens the browser's print dialog (the print styles hide the header, footer and
buttons); Reset clears the saved plan.
One-rep max and pace calculators#
OneRepMax estimates a one-rep max with the Epley formula (weight x (1 + reps / 30), or the weight itself for one
rep), rounded to 2.5 kg or 5 lb, and lists 60 to 95% training weights from ORM_TABLE. Reps are limited to 1 to 12;
80 kg for 5 reps gives 92.5 kg.
PaceCalculator turns a distance (5 km, 10 km, half marathon, marathon or a custom distance up to 250 km) and a finish
time into pace per km, pace per mile and speed, plus finish times for the standard distances at the same pace (5 km in
27:30 is 5:30 per km).
Studio hours and open-now status#
HoursList and OpenStatus in src/components/Hours.tsx highlight today's row and show "Open now · until 20:30" or
"Closed · opens tomorrow 06:00", from HOURS and CLOSED_DATES in UK time. They are filled in by the browser (the
static HTML has no "today") and refresh every minute.
Small extras#
- Mobile menu:
[data-menu-button]toggles#mobile-menu, updatesaria-expandedand its label, and closes on Escape (focus returns to the button), outside clicks, link clicks and when the window grows past 1280 px. - Scroll reveals:
PageEffectsfades in.revealand.reveal-xelements once; revealing one also reveals everything above it. Turned off with reduced motion. - Ticker: the scrolling goal band on the home page is decorative (
aria-hidden) and stops with reduced motion. - Back to top:
<BackToTop />inlayout.tsx; delete it and the.back-to-topstyles inglobals.cssto remove it.
Forms#
There are two forms, plus the one-rep max inputs. They validate in the browser with inline errors and don't send anything until you connect them.
Validation is the useValidation() hook in src/components/useValidation.tsx. A field is checked when it has
data-validate; the rules come from its attributes, and the wording is in fieldMessage():
| Attribute | Rule |
|---|---|
required |
Must be filled, ticked or chosen (data-required-msg sets a custom message) |
type="email" |
Must look like an email address |
data-phone |
At least 10 digits |
minLength |
Minimum length |
data-label |
The field name used in messages ("Your name is required.") |
Errors appear under the field (or under the group for radio buttons), are linked with aria-describedby, set
aria-invalid and focus the first problem; they clear as the visitor fixes them.
| Form | File | Validates |
|---|---|---|
| Contact | src/components/ContactForm.tsx |
Name, email format, optional phone (at least 10 digits if filled), interest, reply method, message of 10+ characters, consent |
| Booking | src/components/Booking.tsx |
Session, time, name, email, mobile, all seven health questions, consent |
Connecting the contact form#
To use Formspree (or any service that accepts a POST), send the data in onSubmit() in
ContactForm.tsx before showing the thank-you message:
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!validate(form.current)) return;
const data = new FormData(e.currentTarget);
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Sorry, that didn’t send. Please call 07700 900418.");
setName(String(data.get("name") ?? "").trim().split(" ")[0]);
setDone(true);
}(toast comes from @/lib/client.) Then change the success message so it says the message was sent.
If you remove the static export (see Building and deploying), you can post to a Route
Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request)) or call a Server
Action from onSubmit.
Connecting a booking system#
Connect a real booking system rather than a form service. Either replace <Booking /> in src/app/book/page.tsx with
your booking platform's embed, or keep this flow and:
- Load real open times (through your own server, so API keys stay private) and return them from
slotsFor()insrc/lib/schedule.tsin the same{ key, mins, coach }shape. SetBOOKING.demoBusyShareto0. - In
onSubmit()inBooking.tsx, POST the session,slot, the health answers and the form data to your server before showing step 5. - Show the confirmation only after your system confirms, and change its copy to match.
Saved data (localStorage)#
| Key | Shape | Written by | Validation on read |
|---|---|---|---|
kinetic-goal |
JSON string, one of "strength", "fatloss", "mobility", "sport", "postnatal" |
Goal picker and goal filters | Ignored unless it is a string that matches a GOALS key (isGoal()) |
kinetic-plan |
{ "days": 3, "goal": "strength", "equipment": "gym", "minutes": 45 } |
Plan builder | validPlan() checks every field against PLAN_DAYS, GOALS, EQUIPMENT and 30/45/60; an invalid value is removed |
Nothing else is stored; booking and contact details are never saved. To reset, use Reset on the plan builder or
clear the site's storage in the browser's developer tools. store() and read() in src/lib/client.ts wrap every
access in try/catch, so the site works when storage is blocked.
SEO and sharing#
Each route exports metadata built with pageMeta(title, description) from src/lib/metadata.ts, which sets the exact
<title>, the description, Open Graph tags (with en_GB locale) and a Twitter card; the image comes from
src/app/opengraph-image.png. The 404 page adds robots: { index: false }. Social networks need absolute image URLs,
so set your domain when building; it becomes metadataBase in layout.tsx:
SITE_URL=https://www.your-domain.co.uk pnpm buildAlso consider adding src/app/sitemap.ts and src/app/robots.ts listing your routes, and ExerciseGym structured data
(schema.org JSON-LD) on the home page with your real address, phone and opening hours. The template doesn't include
structured data because it would describe a fictional studio.
Accessibility#
Built in:
- Skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus styles on every control; the mobile menu exposes
aria-expandedand closes with Escape. - Native radio groups for the goal picker, session, coach, time and health questions; filters use
aria-pressedbuttons; the calculator, filters, booking and plan builder announce changes with live regions. - Charts have text summaries; decorative SVG is
aria-hidden; meaningful images havealttext. - Colour contrast meets WCAG AA for text; flare (
#ff5a33) is only used behind dark text or as decoration. prefers-reduced-motionturns off reveals, the ticker, floating cards, chart drawing and smooth scrolling.
When editing, keep one h1 per page, give every new input a <label>, keep aria-labelledby and
aria-describedby pointing at ids that exist, and check contrast if you change colours.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH. To host the site
in a sub-folder, build with that folder (DEMO_BASE_PATH=/kinetic pnpm build); links, scripts and asset() image URLs
all pick it up. Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository
into Vercel. Most hosts serve 404.html for missing pages automatically. demo/ is the freeframe.dev demo build and
can be deleted.
To use server features (Route Handlers, Server Actions), remove output: "export" from next.config.ts and deploy to
Vercel or any Node.js host with next build and next start.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API.
Replace both with typescript@7 once typescript-eslint supports it.
Troubleshooting#
| Problem | Fix |
|---|---|
| The week view says "No times left this week" | Times need notice minutes of warning and are limited to coach shifts. Check COACHES[...].shifts, HOURS and CLOSED_DATES, and remember the demo hides about 45% of times (BOOKING.demoBusyShare) |
| Times look an hour out | The site always uses STUDIO.timeZone. If your studio isn't in the UK, change it to your IANA zone (for example America/Toronto) and update STUDIO.zoneName and the "UK time" notes in the pages |
| The goal picker or plan builder opens with an old choice | It's restored from localStorage. Use Reset on the plan builder, or remove kinetic-goal / kinetic-plan in the browser's storage settings |
?goal= links don't filter |
The value must be one of the GOALS keys (strength, fatloss, mobility, sport, postnatal). Anything else shows all items |
| A new programme or story never shows under a filter | Its goals must use GOALS keys |
| The social preview has no image | Build with SITE_URL set to your domain so og:image is an absolute URL |
| Images or styles are missing under a sub-folder | Build with DEMO_BASE_PATH set to that folder and use asset() for files in public/ |
| Content stays invisible after editing | .reveal elements are hidden until PageEffects reveals them; check the browser console for errors |
License#
MIT, see the repository license (included as LICENSE.txt in the download). All artwork (the athlete pictograms,
portraits, studio illustration, map, icons and social image) is original to this template and covered by the same
license. Sofia Sans and Sofia Sans Extra Condensed are under the SIL Open Font License 1.1 (src/fonts/OFL.txt).
Fenline Performance, its coaches, clients, reviews and prices are fictional.