Freeframe

Documentation

Compass

v1.0.0Updated Nov 12, 2026A warm, editorial site for a leadership coach or consultant, with a booking flow, a self-assessment quiz and client results.

Overview#

Compass is a website template for coaches and consultants. The demo content is for Bearing, the fictional leadership coaching practice of Nadia Whitcombe in Pittsburgh, who works with new managers, founders and senior leaders through 1:1 coaching, a group cohort and team workshops. The design is warm and editorial: Source Serif 4 headings with italic accents, Instrument Sans body text, an ivory, dusk-indigo and marigold palette and cut-paper SVG illustrations. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a fully static site, with no other dependencies. This guide covers version 1.0.0 (see template.json). Live demo: freeframe.dev/templates/compass.

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 build Node.js server build in .output/ (pages still prerendered)
pnpm preview Serve the last build locally
pnpm lint ESLint (flat config from @nuxt/eslint)
pnpm typecheck nuxt prepare and vue-tsc

Pages#

Route File Purpose and notable features
/ app/pages/index.vue Home: hero with portrait and intro-call CTA, press wordmarks, "who it's for" cards, count-up outcomes, programs preview, the four-step method, testimonial carousel, free guide sign-up, final CTA
/programs app/pages/programs.vue Foundations and Executive 1:1 packages, strategy session, First 90 Days cohort, team workshops, comparison table, FAQ accordion, "Recommended by your self-check" badges
/method app/pages/method.vue Interactive compass diagram with tabs, 60-minute session structure, six-month engagement timeline
/results app/pages/results.vue Summary stats, eight client stories with before/after metrics and filters by client type (?type=), pull quote
/about app/pages/about.vue Story, studio illustration, career path, credentials, values, personal notes
/resources app/pages/resources.vue Leadership self-check quiz, resource library with topic filters (?topic=), podcast, guide sign-up
/article app/pages/article.vue Sample long-form article with drop cap, illustration, pull quote, "try this week" box, author card, related links
/book app/pages/book.vue Four-step booking flow with a live summary sidebar (?type=strategy or ?type=team preselects a call type)
/contact app/pages/contact.vue SVG map, studio address, contact cards, validated contact form, other ways to work together
404 app/error.vue Wandering compass needle and links back into the site (generated as 404.html)

Project structure#

compass/
  app/
    app.vue                   <NuxtLayout><NuxtPage />, keyed so a link to the current page starts it afresh
    error.vue                 404 page
    router.options.ts         keeps query strings written like the HTML version (?type=founders)
    layouts/default.vue       skip link, header, page, footer, back-to-top button
    pages/                    one .vue file per page (see the table above)
    components/
      SiteHeader.vue          sticky header, main and mobile menus
      SiteFooter.vue          footer columns, studio address, the large Bearing wordmark, copyright year
      BrandMark.vue           the compass logo mark
      LineIcon.vue            draws an icon from app/data/icons.ts
      BackToTop.vue           back-to-top button
      CountUp.vue             count-up outcome numbers
      CohortDate.vue          First 90 Days start dates (rolling in the demo)
      TestimonialCarousel.vue home page carousel
      FaqList.vue             programs FAQ accordion
      RecommendedBadge.vue    "Recommended by your self-check" badge
      MethodCompass.vue       method diagram and tabs
      StoriesBrowser.vue      client stories with filters
      ResourceLibrary.vue     resource cards with topic filters
      SelfCheckQuiz.vue       leadership self-check
      BookingFlow.vue         four-step booking flow and calendar
      GuideForm.vue           free guide sign-up (home page and resources)
      ContactForm.vue         contact form
      FieldError.vue          a field's inline error message
    composables/
      useValidation.ts        inline validation for every form
      useChipFilter.ts        shared filter state and ?type= / ?topic= URLs
      usePageSeo.ts           per-page title, description, canonical URL and Open Graph tags
      useCurrentPage.ts       aria-current for menu links
      useAsset.ts             prefixes public/ files with the base path
      useYear.ts              the copyright year
    data/
      site.ts                 NAV and MOBILE_NAV menu items
      programs.ts             PROGRAMS (quiz result), COHORT_DATES and COHORT_MIN_WEEKS
      quiz.ts                 QUIZ_KEY, QUIZ_QUESTIONS, QUIZ_RESULTS
      booking.ts              CALL_TYPES and AVAILABILITY
      stories.ts              STORY_FILTERS and STORIES (results page)
      resources.ts            RESOURCE_FILTERS and RESOURCES (library)
      testimonials.ts         TESTIMONIALS (home page carousel)
      faq.ts                  FAQ (programs page)
      method.ts               METHOD_POINTS (method page tabs)
      icons.ts                ICONS: the line icon set
    utils/
      booking.ts              date, time-zone and slot math, .ics file
      quiz.ts                 scoring, reading and saving the self-check
      cohort.ts               cohort start dates
      safeStorage.ts          localStorage with an in-memory fallback
      makeRef.ts              short references like BL-7K2Q
    plugins/
      reveal.client.ts        scroll reveals
      same-page.client.ts     links to the page already showing start it again at the top
      stable-build.server.ts  reproducible builds
    assets/css/main.css       Tailwind entry: fonts, @theme tokens and component classes
    assets/fonts/             woff2 files and their license (OFL.txt)
  public/img/                 nadia-portrait.svg, studio.svg, guide-cover.svg, favicon.svg, og-image.png and og-image.svg
  nuxt.config.ts              base path, head defaults, SITE_URL, prerendered routes, Tailwind
  template.json               gallery metadata for freeframe.dev

Customizing#

Brand and colors#

All design tokens live in the @theme block of app/assets/css/main.css. Each token becomes Tailwind utilities (bg-dusk-800, text-marigold-700, border-linen-200 and so on).

Token group Used for
--color-ivory, --color-linen-50 to --color-linen-300 Page background, soft section backgrounds, borders
--color-stone-500 to --color-stone-800 Body copy and muted text
--color-dusk-50 to --color-dusk-950 The brand indigo: buttons, links, dark sections, the footer
--color-marigold-100 to --color-marigold-700 The warm accent: highlights, stars, accent buttons on dark backgrounds
--color-clay-100 to --color-clay-700 Secondary accent for tags and illustrations

To change the brand color, replace the dusk scale:

css
@theme {
  --color-dusk-700: #2f5d50;
  --color-dusk-800: #244a40;
  --color-dusk-900: #1a3730;
  --color-dusk-950: #12261f;
}

Keep the contrast rules noted in the comments: use marigold-700 (not 400/500) for marigold text on light backgrounds, and stone-600 or darker for small text. Reusable pieces such as .btn, .btn-primary, .card, .chip, .input, .eyebrow and .display are defined in the @layer components block of the same file.

The illustrations (public/img/*.svg), the logo (app/components/BrandMark.vue) and the inline SVGs in the pages use hex colors directly. If you change the palette, search for the old hex values (for example #2a3259 and #f0c464) and replace them. The theme-color meta tag in nuxt.config.ts sets the browser bar color.

Typography#

Two variable fonts are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css. They are the same woff2 files Google Fonts serves to the HTML version, so text measures exactly the same:

Family Token Used for
Source Serif 4 (weights 300 to 600, roman and italic, optical sizes) --font-serif Headings, numbers and quotes, through .display and font-serif
Instrument Sans (weights 400 to 700) --font-sans Body text, buttons and labels

To swap a font, put the new woff2 files in app/assets/fonts/, change the @font-face rules and the --font-* token. Inline SVGs that contain text (press wordmarks, the method compass, the podcast cover, the map and the footer wordmark) name the fonts too; quote family names that contain digits, as in font-family="'Source Serif 4', Georgia, serif".

Content#

Page copy is markup in each page's <template>. Repeated and interactive content lives in app/data/:

File What it holds
programs.ts PROGRAMS: name, price, link and summary of each program, used by the quiz result; COHORT_DATES and COHORT_MIN_WEEKS: cohort start dates (rolling in the demo, see below)
quiz.ts QUIZ_QUESTIONS, QUIZ_RESULTS and the storage key QUIZ_KEY
booking.ts CALL_TYPES (label, minutes, price, where) and AVAILABILITY (time zone, weekly hours, slot spacing, notice, booking window, closed dates)
stories.ts STORY_FILTERS (the chips) and STORIES (client stories with metrics)
resources.ts RESOURCE_FILTERS and RESOURCES (library cards)
testimonials.ts TESTIMONIALS for the home page carousel
faq.ts FAQ questions and answers on the programs page
method.ts METHOD_POINTS: the four method tabs and their panels
site.ts The NAV and MOBILE_NAV menus

To add a client story, add an object to STORIES:

ts
{
  id: "maya",
  tags: ["founders"],
  labels: [{ label: "Founder", className: "tag tag-marigold" }],
  program: "Executive 1:1 · 6 months",
  title: "Your headline",
  text: "Two or three sentences about what changed.",
  metrics: [
    { label: "Meetings per week", before: "30", after: "18", bars: [100, 60] },
    { label: "Direct reports", before: "9", after: "4", bars: [100, 44] },
  ],
  person: { initials: "MK", avatar: "bg-dusk-100 text-dusk-800", name: "Maya Kerr", role: "Founder, Example Co" },
},

tags must be values from STORY_FILTERS (managers, founders, executives, teams, career); a story can have several. The chip counts are worked out from the data. bars are the widths in percent of the before and after bars (the after bar is indigo when it is longer, marigold when shorter); leave them out to show only the numbers. Two metrics give a two-column row, three a three-column row. Resources work the same way with a single topic. To add a filter, add a { value, label } to the filters array and use that value in some items.

Programs and prices appear in app/pages/programs.vue, in the home page preview, in the comparison table and in PROGRAMS; keep them in step when you change a price.

Cohort dates. First 90 Days cohorts start on the second Wednesday of January, April, July and October. COHORT_DATES in app/data/programs.ts is null in the demo, which shows rolling dates: the first start at least COHORT_MIN_WEEKS (6) weeks away and the one after it, so the demo never advertises a cohort that has started. Set { next: "2027-01-13", after: "2027-04-14" } for your real dates. <CohortDate> (logic in app/utils/cohort.ts) shows them on the home page and the programs page:

html
<CohortDate format="long" fallback="January 13, 2027" />
<CohortDate which="after" format="month" fallback="April 2027" />

format is full (Wednesday, January 13, 2027), long, month (April 2027) or season (Winter 2027). The generated HTML shows fallback and the browser shows the computed date after the page loads, so there is no hydration mismatch.

Images and artwork#

All artwork is SVG, so there are no photos to license. To use real photos instead:

  • Portrait (public/img/nadia-portrait.svg, on home, about, article and book pages): replace with a 4:5 photo. To keep the arch shape, add arch object-cover to the <img> class; .arch rounds the top into a full arch.
  • Studio (public/img/studio.svg on about): any 10:7 landscape image.
  • Guide cover (public/img/guide-cover.svg): a 360 x 460 (roughly 4:5) cover of your own lead magnet.
  • The method compass (MethodCompass.vue), press wordmarks (home page), map (contact), podcast cover (resources), article illustration and the 404 compass are inline SVG. Replace the press wordmarks with your own logos, and only list publications that have really featured you.

Images in public/ are referenced with asset("img/...") (useAsset()) so they keep working under a base path. The social image is public/img/og-image.png (1200 x 630), rendered from public/img/og-image.svg; edit the SVG and export a new PNG of the same size. The favicon is public/img/favicon.svg.

The header lives in app/components/SiteHeader.vue and reads its links from NAV in app/data/site.ts; the mobile menu uses MOBILE_NAV, which adds Home first and "Book a free intro call" last. Each item's match lists the routes that mark it as the current page with aria-current="page" (the article belongs to Resources). The desktop menu appears from the lg breakpoint (1024 px); below that the menu button opens the mobile menu. The footer is app/components/SiteFooter.vue. To add a page, create app/pages/<route>.vue with a usePageSeo() call, add the route to routes in nuxt.config.ts and link it from NAV and the footer.

Features#

Booking flow#

/book renders app/components/BookingFlow.vue, a four-step flow:

  1. Call type: radio cards, one per key in CALL_TYPES. Link to /book?type=strategy (or team) to preselect a type; unknown values are ignored.

  2. Date and time: a month calendar (Monday first) and a list of time slots. Slots come from slotsFor() in app/utils/booking.ts, which applies AVAILABILITY:

    ts
    export const AVAILABILITY = {
      timeZone: "America/New_York",
      zoneName: "Eastern Time",
      hours: { 1: [["09:00", "12:00"], ["13:30", "17:00"]], /* ... */ 5: [["09:00", "12:30"]] },
      slotStep: 30,
      minNoticeHours: 24,
      daysAhead: 45,
      closed: ["2026-11-26", "2026-12-25" /* ... */],
      demoBusyShare: 0.35,
    };

    hours is keyed by weekday (0 is Sunday) in studio time; a slot fits when its whole length (minutes of the call type) ends before the window closes. demoBusyShare hides a stable share of slots so the demo looks realistic; set it to 0 once you connect real availability. Times are shown in the visitor's time zone (detected with Intl), with the studio time underneath and a "Show times in" selector to switch to studio time. Slots that cross midnight are labeled (+1 day).

  3. Details: a validated form ([data-booking-form]) with name, email, role, optional company, a message of at least 20 characters and a consent checkbox. Back keeps the chosen day and time.

  4. Confirm: a summary of the request with a reference (see Forms) and how to change or cancel, a Save a calendar hold (.ics) button that downloads an event file built in the browser (icsFor()), and Book another call.

The sidebar summary updates as choices are made. The calendar depends on today's date and the visitor's time zone, so it is worked out in the browser (onMounted); the generated HTML shows step 1.

Accessibility: each step's heading receives focus when the step changes, and the step list marks the current step with aria-current="step". Calendar days are buttons with full-date aria-labels that include the number of open times; only one day is in the tab order, the arrow keys move by day and week, Home/End go to the start/end of the week and Page Up/Page Down move four weeks. Unavailable days use aria-disabled="true". Time slots are native radio buttons inside a fieldset, and a missing date or time shows an inline role="alert" message and moves focus back to the calendar or the times.

Leadership self-check#

SelfCheckQuiz.vue on /resources#quiz has three views: intro, questions and result. Questions come from QUIZ_QUESTIONS, one at a time as a fieldset of radio buttons with a progress bar and Back/Next buttons; choosing nothing shows an inline message. Each answer is tagged with a result type; scoreQuiz() in app/utils/quiz.ts picks the type with the most answers, and ties go to the type listed first in QUIZ_RESULTS. The result shows the type, three steps to try, a bar for each type and the recommended program from PROGRAMS.

To change the quiz, edit the arrays: every question needs one answer per result type you want it to count toward, and each result's program must be a key of PROGRAMS. Link to /resources#quiz from anywhere to send people to it. The legend of each new question receives focus so screen reader users hear it, and the result heading receives focus when it appears.

On /programs, each program has a <RecommendedBadge program="..." />. It stays hidden unless the self-check result saved on this device recommends that program (foundations, executive, cohort or teams). Classes set on the component are passed to its <p>.

Method compass#

MethodCompass.vue on /method shows four tabs (standard role="tablist" with arrow-key, Home and End support) and one panel each, from METHOD_POINTS. The compass needle turns to the selected point by the shortest route and the matching point is highlighted; clicking a point in the drawing selects and focuses its tab. The drawing is decorative for assistive technology, which uses the tabs.

Filters#

StoriesBrowser.vue (/results) and ResourceLibrary.vue (/resources) use useChipFilter(): chip buttons with aria-pressed (all means everything), a live result count and an empty message. The selected filter is written to the URL (?type= on results, ?topic= on resources) with history.replaceState when a chip is picked, so filtered views can be shared and linked, for example /results?type=founders. Unknown values show everything. The URL is read after the page loads, so the generated HTML shows every item.

TestimonialCarousel.vue is a horizontal scroll-snap list, so swiping, trackpads and the arrow keys (once the list is focused) all work. The Previous/Next buttons scroll by one card and disable at the ends; the "1 / 6" counter is visual only. There is no autoplay. Slides and their "1 of 6" labels come from TESTIMONIALS.

FAQ accordion#

FaqList.vue renders FAQ: each question is a .acc-btn button with aria-expanded and aria-controls pointing to its answer. Set open: true on an item to open it by default; link adds a link at the end of the answer.

Count-up stats, scroll reveals and motion#

<CountUp :value="340" /> renders <span data-count="340">340</span> (add :decimals="1" for 6.5) and counts up once when it scrolls into view; the final value is already in the generated HTML. app/plugins/reveal.client.ts fades in elements with .reveal (add .reveal-delay-1 to -3 to stagger) and turns in the hero's .spin-in compass rose; everything above a revealed element is revealed too, and content rendered later is picked up. Don't put .reveal on an element that also has a :class binding: Vue would drop the is-visible class the plugin adds. The 404 needle wanders with a CSS animation. All motion is switched off when the visitor prefers reduced motion.

Header, mobile menu and back to top#

The header gains a border and shadow after scrolling. Below 1024 px the menu button (#menu-button) toggles #mobile-menu, updates aria-expanded and its label, and closes on Escape (returning focus to the button), when a link is chosen or when the window grows past 1024 px. BackToTop.vue appears after about one screen of scrolling, steps aside while the footer's own "Back to top" link is visible and moves focus to the main content. To remove it, delete <BackToTop /> from app/layouts/default.vue and the .back-to-top styles in main.css.

Forms#

There are four forms. None of them sends anything until you connect it, even though the success states read as if it had been. The contact form and the booking confirmation show a short reference (BL- and four characters) made in the browser by makeRef() in app/utils/makeRef.ts; if your form service or booking tool returns its own reference, show that instead. Check that the promises in those states (a reply within one business day, Nadia confirming each booking request personally, a confirmation email for the guide) match how you work.

Form File Validates
Guide sign-up app/components/GuideForm.vue (home page and resources) First name required, email format
Contact app/components/ContactForm.vue Name, email, optional phone (7+ digits if filled), topic, message of 20+ characters
Booking details app/components/BookingFlow.vue Name, email, role, message of 20+ characters, consent checkbox
Quiz app/components/SelfCheckQuiz.vue An answer is chosen before moving on

Validation is the useValidation() composable in app/composables/useValidation.ts, driven by data-validate attributes:

data-validate Rule
required Not empty (selects: an option chosen)
email Required and shaped like name@company.example
phone-optional Empty, or at least 7 digits
message At least 20 characters
consent A required checkbox that must be checked

The wording is in fieldMessage(). Errors appear under the field (FieldError.vue), are linked with aria-describedby, set aria-invalid="true" and clear as the visitor fixes them; the first invalid field receives focus.

Connecting to Formspree#

Each form's submit handler (onSubmit() in ContactForm.vue and GuideForm.vue) continues only when v.validate(form) passes; that is the place to send it. Create a form at Formspree, then in ContactForm.vue:

ts
async function onSubmit() {
  if (!form.value || !v.validate(form.value)) return;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form.value),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return alert("Sorry, something went wrong. Please email hello@bearing.example.");
  // ...then show the thank-you panel as before
}

For the guide sign-up you can post to your email tool (Kit, Mailchimp, Buttondown) the same way in GuideForm.vue. If you deploy with pnpm build (a Node.js server) instead of pnpm generate, you can post to a Nuxt server route instead (server/api/contact.post.ts with export default defineEventHandler(async (event) => { ... })).

Booking flow#

You have three options:

  • Replace it with an embed. In app/pages/book.vue, replace <BookingFlow /> with the inline embed code from Cal.com or Calendly (in a small component wrapped in <ClientOnly>). Keep the page header.
  • Keep the flow and send a request. In onDetails() in BookingFlow.vue, post the details plus the chosen time (type.value, s.at.toISOString()) to a form service before goTo(4), then confirm each request by hand. Change the step 4 notice to say a confirmation will follow.
  • Use a scheduling API. Load real free slots from the Cal.com or Calendly API on your own server and replace slotsFor() in app/utils/booking.ts with them; never put API keys in client code.

Saved data (localStorage)#

Key Shape Notes
compass-quiz-v1 { "answers": [0, 2, 1, 0, 3, 2], "date": "2026-10-12" } One answer index per question

readQuiz() in app/utils/quiz.ts ignores the value unless answers is an array with exactly one whole-number index per question, each in range. The result type is always recalculated from the answers, so editing QUIZ_RESULTS never leaves a stale result. Retake the self-check removes the key. To clear it manually, run localStorage.removeItem("compass-quiz-v1") in the browser console. Storage goes through safeStorage (app/utils/safeStorage.ts): if it is blocked, the quiz still works for the page view; it just isn't remembered. If you change the questions, bump QUIZ_KEY in app/data/quiz.ts (for example to compass-quiz-v2). The saved result is read after the page loads, so the generated HTML shows the quiz intro.

SEO and sharing#

Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the exact <title>, the description, a canonical link, the Open Graph tags (og:image is public/img/og-image.png) and a Twitter card. 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

The 404 page passes noindex: true. Consider adding a sitemap.xml and robots.txt to public/ listing your routes, and ProfessionalService or Person structured data (JSON-LD, with useHead()) on the home and about pages with your name, address and services.

Accessibility#

Built in: a skip link, semantic landmarks, one <h1> per page and a logical heading order, visible focus rings (:focus-visible in main.css), WCAG AA contrast for text, aria-current in the navigation, labeled form fields with inline errors, aria-pressed filter chips with a live result count, accessible tabs and accordion, a keyboard-operable calendar, a carousel that never moves on its own, alt text or aria-hidden on every image and SVG, and reduced-motion support everywhere.

When editing: keep one h1 per page, give every new input a <label>, keep the aria-controls ids of tabs and accordion buttons in step with their panels, keep alt text meaningful when you swap illustrations for photos, and check new color pairs for contrast (small text on ivory should be stone-600 or darker).

Building and deploying#

pnpm generate writes a fully static site to .output/public/: one .html file per page (programs.html, served at /programs), 404.html and the assets in _nuxt/. Deploy it anywhere:

  • Netlify / Cloudflare Pages / Vercel: build command pnpm generate, publish directory .output/public.
  • Any static host: upload the contents of .output/public/. Configure the host to serve programs.html for /programs (most do) and 404.html for unknown URLs.

To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/coaching pnpm generate. nuxt.config.ts passes it to app.baseURL; links get it automatically and public files through asset(). pnpm build:demo does this for the freeframe.dev demo and writes demo/, which you can delete.

Troubleshooting#

Problem Fix
No time slots on any day Check AVAILABILITY.hours uses weekday numbers 0 to 6 and "HH:MM" strings, the call length fits inside each window, and daysAhead is larger than minNoticeHours / 24
Times look an hour off around daylight saving changes Make sure AVAILABILITY.timeZone is an IANA name such as America/New_York, not a fixed offset
The quiz always shows an old result It is saved in localStorage; click Retake the self-check or remove compass-quiz-v1, and bump QUIZ_KEY when you change the questions
A filter chip shows 0 or the wrong items The chip's value must appear exactly (lowercase, no spaces) in the stories' tags or the resources' topic
A filtered link shows everything ?type= and ?topic= must be one of the filter values; unknown values are ignored on purpose
SVG text renders in the wrong font Quote font names that contain digits in font-family attributes, for example 'Source Serif 4'
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/
pnpm lint fails with a missing .nuxt/eslint.config.mjs Run pnpm install (or pnpm typecheck) once; nuxt prepare writes it

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. Instrument Sans and Source Serif 4 are included under the SIL Open Font License (app/assets/fonts/OFL.txt). Bearing and all people, clients, companies and publications named in the demo are fictional.

Search documentation

Search the guides and every template's documentation