Freeframe

Documentation

Kinetic

v1.0.0Updated Nov 12, 2026A bright, energetic personal trainer website with a goal picker, a week-view booking flow with health questions, a pricing calculator and free training tools.

Overview#

Kinetic is a complete website for an independent personal trainer or a small personal training studio. The demo content is for Foothill Performance, a fictional two-coach studio in Old Colorado City, Colorado Springs, Colorado, run by Ffion Beckett and Yusuf Dacre. It has a goal picker that recommends a program, programs 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 Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site, with no other dependencies. This guide covers version 1.0.0 (see template.json). Live demo: freeframe.dev/templates/kinetic.

The copy uses American English, US dollars, a 12-hour clock and the America/Denver time zone (Mountain Time). 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.

sh
pnpm install
pnpm dev

Open http://localhost:3000. Pages reload as you edit. Other scripts:

Script What it does
pnpm generate Static site in .output/public/
pnpm preview Serve the last build locally
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm lint ESLint
pnpm typecheck nuxt prepare and vue-tsc

Pages#

Route File Purpose and notable features
/ app/pages/index.vue Hero with a live "next free consult" card, goal ticker, goal picker with five recommendation panels, programs teaser, three client progress charts, how it works, testimonials, pricing teaser with the Kickstart offer and a health note
/programmes app/pages/programmes.vue Seven programs filterable by goal (?goal=), with what's included, prices, facts and booking links; comparison table
/pricing app/pages/pricing.vue Kickstart intro offer (#kickstart), session packs, memberships (#memberships), cost calculator (#calculator), monthly vs pay-per-session table, FAQ (#faq), small print (#terms)
/results app/pages/results.vue Six anonymized progress stories with SVG charts, filterable by goal (?goal=), review wall
/tools app/pages/tools.vue Plan builder (#plan), one-rep max estimator (#one-rep-max), pace calculator (#pace)
/about app/pages/about.vue Studio story, coach profiles (#ffion, #yusuf), approach, milestones, studio facts and safety note
/contact app/pages/contact.vue Contact form, map, getting there, studio hours with open-now status, social links (#social), privacy note (#privacy)
/book app/pages/book.vue Four-step booking flow with a week view, health questions and a confirmation with .ics download; accepts ?type=, ?coach= and ?goal=
404 app/error.vue Friendly error page with links back into the site (noindex), generated as 404.html

Project structure#

kinetic/
  app/
    app.vue                     <NuxtLayout> and <NuxtPage> (pages are keyed so a link to the open page restarts it)
    error.vue                   404 page
    router.options.ts           writes query strings the way the HTML version does
    layouts/default.vue         skip link, header, the page, footer, toast and back-to-top button
    pages/                      one .vue file per page (index, programmes, pricing, results, tools, about, contact, book)
    components/
      SiteHeader.vue            announcement bar, sticky header, main menu, mobile menu
      SiteFooter.vue            footer with contact details, links and studio hours
      OpenStatus.vue            open-now badge
      HoursList.vue             studio hours list with today's row marked
      GoalPicker.vue            home page goal picker
      NextConsult.vue           the hero's "next free consult" card
      GoalFilter.vue            goal chips over a list (programs and client stories)
      ResultChart.vue           a progress chart figure (the home page)
      PricingCalculator.vue     pricing calculator
      PlanBuilder.vue           training plan builder
      OneRepMax.vue             one-rep max estimator
      PaceCalculator.vue        running pace calculator
      BookingFlow.vue           booking flow
      ContactForm.vue           contact form
      FieldError.vue            an inline form error
      AppIcon.vue, LogoMark.vue, ToastMessage.vue, BackToTop.vue   icons, logo and small site-wide parts
    composables/
      useNow.ts                 the studio's date and time, updated every minute
      useValidation.ts          form validation and messages
      usePageSeo.ts             per-page title, description, canonical and Open Graph tags
      useAsset.ts, useCurrentPage.ts, useMounted.ts, useYear.ts   small 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)
      icons.ts                  ICONS (line icons for <AppIcon>)
    utils/
      time.ts                   dates and times in Mountain Time, money(), hash()
      schedule.ts               openStatus(), hoursText(), slotsFor(), firstAvailable()
      chart.ts                  chartSvg(): the progress charts as SVG
      safeStorage.ts            localStorage with an in-memory fallback, store() and read()
      client.ts                 makeRef(), param() and toast()
    plugins/
      clock.client.ts           starts the studio clock once the page has loaded
      reveal.client.ts          scroll reveals and chart drawing
      same-page.client.ts       links to the page already showing restart it at the top
      stable-build.server.ts    reproducible builds
    assets/css/main.css         Tailwind entry: fonts, @theme tokens and component classes
    assets/fonts/               Sofia Sans woff2 files and their license (OFL.txt)
  public/img/                   athlete pictograms, coach portraits, studio, map, favicon and the social image
  nuxt.config.ts                base path, head defaults, SITE_URL and the prerendered routes
  template.json, README.md, CHANGELOG.md, DOCS.md

Customizing#

Brand and colors#

All design tokens live in the @theme block in app/assets/css/main.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 color: 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" check marks, 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 color, replace the cobalt scale (keep 600 dark enough for white text, at least 4.5:1):

css
@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, app/components/LogoMark.vue and the chart colors in app/utils/chart.ts. Update them too if you change the palette.

Typography#

The fonts are self-hosted from app/assets/fonts/, the same woff2 files Google Fonts serves: 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. They are declared with @font-face at the top of main.css under their real family names, so the tokens refer to them directly:

css
--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 app/assets/fonts/, update the @font-face rules and the token. 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 page files (app/pages/*.vue). Everything the interactive parts use, and the repeated lists, live in typed data files in app/data/:

Export File What it holds
STUDIO studio.ts Name, time zone (America/Denver), 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 One-off closures as YYYY-MM-DD. December 24–28 and US federal holidays are worked out by rule in isClosedDate() (app/utils/schedule.ts), so they stay right every year
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 programs 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 app/pages/about.vue:

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

ts
assessment: { label: "Fitness assessment", minutes: 60, price: 85, priceLabel: "$85", coaches: ["yusuf"], notice: 1440, screen: true },

Add a program. 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:

ts
{
  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: "$99",
  per: "a month",
  facts: [["Length", "45 min"], ["Coach", "Yusuf"], ["Where", "Studio, Old Colorado City"]],
  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:

ts
plank: { label: "Plank hold", unit: "reps", x: ["Wk 1", "Wk 4", "Wk 8"], y: [20, 45, 70], better: "up" },

unit can be lb, kg, in, 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.svg and pose-postnatal.svg are 120 x 120 pictograms. On selected goal cards and program headers they are turned white with a CSS filter (brightness-0 invert), so keep them single-color plus flare accents.
  • coach-ffion.svg and coach-yusuf.svg are square portraits. Replace them with square photos (at least 480 x 480) and keep the alt text on the about page describing the person.
  • studio.svg is 16:10 and map.svg is 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 page files.

Files in public/ need the base path: in templates use useAsset() (const asset = useAsset(), then :src="asset('img/map.svg')"). The social image is public/img/og-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 public/img/favicon.svg, linked in nuxt.config.ts.

The header lives in app/components/SiteHeader.vue (announcement bar, logo, main menu, "Book a free consult" button and mobile menu) and the footer in app/components/SiteFooter.vue. The main menu comes from NAV in app/data/nav.ts; the mobile menu adds Home first and Book online last. To add a page, create app/pages/<name>.vue, add its route to routes in nuxt.config.ts (so it's generated) and add it to NAV. The current page gets aria-current="page" from useCurrentPage(). 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 in SiteFooter.vue.

Features#

Goal picker#

app/components/GoalPicker.vue shows five radio cards (input[name="goal"]) and the matching panel ([data-goal-panel="strength"] and so on) from GOAL_PICKS in app/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).

Program and story filters#

/programmes and /results use app/components/GoalFilter.vue: chip buttons ([data-filter-value], aria-pressed) over <article>s 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 (each with an id or key and its goals), the article classes and the content through the #item and #empty slots:

html
<GoalFilter label="Filter programs by goal" list-class="mt-8 grid gap-6" item-class="…" :labelled-by="(p) => `${p.id}-title`" :items="items">
  <template #item="{ item }">…</template>
  <template #empty>No programs match that goal yet.</template>
</GoalFilter>

Progress charts#

chartSvg() in app/utils/chart.ts draws a line chart from RESULTS as inline SVG when the site is generated, 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 "5K 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#

app/components/NextConsult.vue shows the first bookable consultation from the booking engine on the home page, for example "Tomorrow 7:00 AM with Ffion". It is filled in by the browser, so the static page is never out of date.

Booking flow#

app/components/BookingFlow.vue has four steps and a confirmation:

  1. Session: cards from SESSION_OPTIONS, one per SESSIONS key.
  2. 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]).
  3. Time: a Monday-to-Sunday week view with previous and next week buttons, for this week and the next BOOKING.weeksAhead - 1 weeks. It opens on the week of the first available time.
  4. Your details: name, email, cell phone, optional goal and notes, seven yes/no health questions and a consent box.
  5. Confirmation: a summary with a reference (like FP-7K2Q), the coach's portrait, what happens next and an Add to calendar (.ics) button; the .ics file carries the reference too.

How times are built, in slotsFor() in app/utils/schedule.ts:

  • A 1:1 session can start every BOOKING.slotStep minutes inside the coach's shifts and the studio HOURS, 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 notice minutes from now (2 hours for consults, 12 hours for training), in Mountain Time.
  • Federal holidays, December 24–28, CLOSED_DATES and closed weekdays show "Studio closed" or "Closed".
  • group sessions use the fixed GROUP_CLASSES times and show the spaces left.
  • With "No preference", each time goes to the first coach in the session's coaches list who is free.
  • BOOKING.demoBusyShare hides a stable, pseudo-random share of times so the demo looks realistic. Set it to 0 when 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 (America/Denver), whatever the visitor's own time zone. Time-dependent parts (next openings, the week view, the summary) are filled in by the browser after the page loads (useNow() in app/composables/useNow.ts), so the generated pages never show stale times. The .ics file (foothill-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 labeled group and every time is a radio button with a full label (for example "Tuesday, October 20, 2026, 7:30 AM, 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 before you publish. Health answers are sensitive personal data, and privacy laws vary by state: if you connect this form, store them securely, keep them only as long as you need and check which rules apply to you.

Pricing calculator#

app/components/PricingCalculator.vue has a sessions-a-week stepper (1 to 4) and a 45/60-minute toggle. It 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 within PRICING.packWeeks, priced per session.
  • Monthly membership: PRICING.member[length][perWeek - 1] x sessions a week x 52 / 12, rounded to the dollar.

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: $780, $667 and $641, a saving of $139 a month). The results list is aria-live="polite". Change prices in PRICING and in the tables on the page.

Plan builder#

app/components/PlanBuilder.vue 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 postpartum 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#

app/components/OneRepMax.vue 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; 185 lb for 5 reps gives 215 lb.

app/components/PaceCalculator.vue turns a distance (5K, 10K, half marathon, marathon or a custom distance up to 155 miles) and a finish time into pace per mile, pace per km and speed in miles per hour, plus finish times for the standard distances at the same pace (5K in 27:30 is 8:51 per mile).

Studio hours and open-now status#

app/components/HoursList.vue and app/components/OpenStatus.vue highlight today's row and show "Open now · until 8:30 PM" or "Closed · opens tomorrow 6:00 AM", from HOURS and CLOSED_DATES in Mountain Time. They are filled in by the browser (the generated HTML has no "today") and refresh every minute.

Small extras#

  • Mobile menu: [data-menu-button] toggles #mobile-menu, updates aria-expanded and 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: app/plugins/reveal.client.ts fades in .reveal and .reveal-x elements once; revealing one also reveals everything above it. Turned off with reduced motion.
  • Links to the open page: app/plugins/same-page.client.ts makes a link to the page already showing (the menu link on a filtered programs page, "Book a free consult" during a booking) start that page afresh at the top, like a page load.
  • Ticker: the scrolling goal band on the home page is decorative (aria-hidden) and stops with reduced motion.
  • Back to top: <BackToTop /> in app/layouts/default.vue; delete it, BackToTop.vue and the .back-to-top styles in main.css to 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 useValidation() in app/composables/useValidation.ts. 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, checked 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) with <FieldError>, are linked with aria-describedby, set aria-invalid and focus the first problem; they clear as the visitor fixes them.

Form File Validates
Contact app/components/ContactForm.vue Name, email format, optional phone (at least 10 digits if filled), interest, reply method, message of 10+ characters, consent
Booking app/components/BookingFlow.vue Session, time, name, email, cell phone, all seven health questions, consent

The confirmations read as if the message or booking went through: they show a short reference (FP- and four characters) made in the browser by makeRef() in app/utils/client.ts, and say what happens next (a reply within one business day, the coach confirming a booking by email and text, a call-back within one business day after a health "yes"). If your form service or booking system returns its own reference, show that instead, and change those promises to match how you actually work.

Connecting the contact form#

To use Formspree (or any service that accepts a POST), send the data in onSubmit() in app/components/ContactForm.vue before showing the thank-you message:

ts
async function onSubmit() {
  if (!v.validate()) return;
  const data = new FormData(form.value!);
  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 (719) 555-0146.");
  name.value = String(data.get("name") ?? "").trim().split(" ")[0] ?? "";
  reference.value = makeRef("FP");
  done.value = true;
  await nextTick();
  success.value?.focus();
}

(toast and makeRef are auto-imported from app/utils/client.ts.) With pnpm build (a Node.js server build) you can post to your own server route instead (server/api/contact.post.ts with defineEventHandler).

Connecting a booking system#

Connect a real booking system rather than a form service. Either replace <BookingFlow /> in app/pages/book.vue with your booking platform's embed, or keep this flow and:

  1. Load real open times (through your own server, so API keys stay private) and return them from slotsFor() in app/utils/schedule.ts in the same { key, mins, coach } shape. Set BOOKING.demoBusyShare to 0.
  2. In onSubmit() in BookingFlow.vue, POST the session, state.slot, the health answers and the form data to your server before showing step 5.
  3. Show the confirmation only after your system confirms, with its reference if it returns one.

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() in PlanBuilder.vue 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. safeStorage, store() and read() in app/utils/safeStorage.ts wrap every access in try/catch and keep an in-memory copy, so the site works when storage is blocked or full.

SEO and sharing#

Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the exact <title> and description copied from the HTML version, a canonical link, Open Graph tags (with en_US locale; og:image is public/img/og-image.png) and a Twitter card. The 404 page passes noindex: true. Social networks need absolute URLs, so set your domain when building; it becomes runtimeConfig.public.siteUrl in nuxt.config.ts:

sh
SITE_URL=https://www.your-domain.com pnpm generate

Also consider adding a sitemap.xml and robots.txt to public/ listing your routes, and ExerciseGym structured data (schema.org JSON-LD, with useHead()) 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-expanded and closes with Escape.
  • Native radio groups for the goal picker, session, coach, time and health questions; filters use aria-pressed buttons; the calculator, filters, booking and plan builder announce changes with live regions.
  • Charts have text summaries; decorative SVG is aria-hidden; meaningful images have alt text.
  • Color contrast meets WCAG AA for text; flare (#ff5a33) is only used behind dark text or as decoration.
  • prefers-reduced-motion turns 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 colors.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (pricing.html, linked as /pricing), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /pricing to pricing.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/kinetic pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support 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 Mountain Time, change it to your IANA zone (for example America/Chicago) and update STUDIO.zoneName and the "Mountain 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 program 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
A hydration warning appears in the console Anything that reads the clock, the URL or storage must run in onMounted() or an event handler, never while rendering
Images or styles are missing under a sub-folder Build with DEMO_BASE_PATH set to that folder and use useAsset() for files in public/
Content stays invisible after editing .reveal elements are hidden until app/plugins/reveal.client.ts reveals them, and Vue drops the is-visible class it adds when an element's :class binding changes, so keep .reveal off elements with changing classes; check the browser console for errors

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Sofia Sans and Sofia Sans Extra Condensed are licensed under the SIL Open Font License 1.1 (app/assets/fonts/OFL.txt). Foothill Performance, its coaches, clients, reviews and prices are fictional.

Search documentation

Search the guides and every template's documentation