Freeframe

Documentation

Gather

v1.0.0Updated Nov 12, 2026A warm, modern church website with a live service countdown, sermon player, events calendar and visit planner.

Overview#

Gather is a website for a local church, built for Riverbend Church, a fictional non-denominational church in Lawrence, Kansas. It has a home page with this Sunday's times and a countdown to the next service, a plan-your-visit page, a sermon archive and sermon pages with an audio player, an events calendar with calendar downloads, a small-group finder and ministries, a giving page, an about page with the staff team, contact and prayer requests, and a 404 page. Every illustration, portrait and series cover is SVG, so there are no photos to replace. This is the Nuxt 4 version (Vue 3, TypeScript, Tailwind CSS v4, generated as a static site), 1.0.0. Sermons, events, groups, giving and staff live in typed data files in app/data/.

Live demo: freeframe.dev/templates/gather

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm).

sh
pnpm install
pnpm dev        # http://localhost:3000
Script What it does
pnpm dev Development server with hot reload
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 (@nuxt/eslint flat config)
pnpm typecheck nuxt prepare, then vue-tsc
pnpm build:demo Build for the freeframe.dev demo (base path /demos/nuxt/gather) into demo/

Add ?live=1 to any page (for example /?live=1) to see the "Live now" state of the countdown.

Pages#

Route File Purpose and notable features
/ app/pages/index.vue Hero with river illustration, "This Sunday" card with times and <CountdownBox>, three-step "I'm new" path, latest sermon with <AudioPlayer>, the next four events (with <EventDialog>), ministries, scripture and figures band, <NewsletterForm>
/visit app/pages/visit.vue What to expect, service times, kids' check-in steps and rooms, accessibility, SVG parking map, FAQ accordion, <VisitForm> with a live summary
/sermons app/pages/sermons.vue "Watch live" panel with a countdown and the latest message, then <SermonArchive>: series cards, search and series/speaker/book filters kept in the URL, sermon cards
/sermon app/pages/sermon.vue One sermon (/sermon?id=room-at-the-table): series cover, audio player, key verse, notes, discussion questions with a copy button, speaker card, the rest of the series, related sermons
/events app/pages/events.vue <EventsCalendar>: category filter, month calendar and list views, selected-day agenda; <EventDialog> with "Add to calendar" (.ics)
/groups app/pages/groups.vue <GroupFinder> (day, area, season of life, childcare), ministries for kids, youth, care and music (#kids, #youth, #care, #music)
/give app/pages/give.vue <GiveForm>: frequency, amount chips and your own amount, fund, cover-the-fee option, live total; budget bar, funds, FAQ
/about app/pages/about.vue Story timeline, beliefs (#beliefs), leadership, staff grid with SVG portraits
/contact app/pages/contact.vue Office details, urgent pastoral contact, <ContactForm>, <PrayerForm> (#prayer)
404 app/error.vue Signpost illustration and links back (generated as 404.html)

Project structure#

gather/
├── nuxt.config.ts              base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── package.json                scripts and dependencies
├── eslint.config.mjs           ESLint flat config (@nuxt/eslint)
├── public/img/                 favicon.svg, og-image.png (social image) and og-image.svg (its source)
└── app/
    ├── app.vue                 <NuxtLayout><NuxtPage /></NuxtLayout>, keyed so links to the same page restart it
    ├── error.vue               the 404 page
    ├── router.options.ts       writes query strings like the HTML version
    ├── layouts/default.vue     skip link, header, page, footer, back-to-top button
    ├── pages/                  one file per page (see Pages)
    ├── components/
    │   ├── SiteHeader.vue      top bar, sticky header, main and mobile menus
    │   ├── ServiceStatus.vue   "Next gathering … / Live now" line in the top bar
    │   ├── CountdownBox.vue    countdown to the next service / "Live now" card
    │   ├── AudioPlayer.vue     the sermon player
    │   ├── SermonArchive.vue   series cards, filters and sermon list (/sermons)
    │   ├── SermonCard.vue      a sermon in a grid
    │   ├── EventsCalendar.vue  month grid, day agenda and list view (/events)
    │   ├── EventCard.vue       an event date as a card button
    │   ├── EventDialog.vue     the event detail dialog and .ics download
    │   ├── GroupFinder.vue     group filters and cards (/groups)
    │   ├── NewsletterForm.vue, VisitForm.vue, GiveForm.vue, ContactForm.vue, PrayerForm.vue
    │   ├── FieldError.vue, SuccessBox.vue   a field's error message, a form's thank-you box
    │   ├── StaffPortrait.vue, SeriesArt.vue     SVG staff portraits and series covers
    │   └── SiteLogo.vue, SiteFooter.vue, AppIcon.vue, BackToTop.vue
    ├── composables/
    │   ├── useValidation.ts    form validation (see Forms)
    │   ├── useServiceState.ts, useThisSunday.ts   the next or current service, this Sunday's date
    │   ├── useEventDialog.ts   opens the event dialog from any card
    │   ├── usePageSeo.ts, useCurrentPage.ts, useMounted.ts, useTick.ts, useYear.ts
    ├── data/
    │   ├── church.ts           CHURCH, SERVICES, KEEP_DEMO_CURRENT, DEMO_ANCHOR
    │   ├── people.ts           PEOPLE, STAFF
    │   ├── sermons.ts          SERIES, BOOKS, SERMONS, PROGRESS_KEY
    │   ├── events.ts           EVENT_CATS, EVENTS
    │   ├── groups.ts           DAYS, AREAS, STAGES, GROUPS
    │   ├── giving.ts           GIVING
    │   ├── visit.ts            VISIT
    │   └── site.ts             NAV, MOBILE_NAV
    ├── utils/
    │   ├── dates.ts            day numbers, formatting and the church's time zone
    │   ├── demo.ts             calendar(): shifted sermon and event dates, every event occurrence
    │   ├── services.ts         serviceState(): the next or current service
    │   ├── ics.ts              buildIcs() and downloadIcs()
    │   ├── progress.ts         saved listening positions
    │   ├── art.ts              avatarSvg() and seriesArtShapes(): the SVG artwork
    │   ├── forms.ts            fieldMessage(), makeRef(), money()
    │   └── url.ts, safeStorage.ts
    ├── plugins/                scroll reveals, links to the same page, stable builds
    └── assets/
        ├── css/main.css        Tailwind entry: fonts, brand tokens (@theme) and component classes
        └── fonts/              the woff2 files and their licenses

Components, composables and utilities are imported automatically; data files are imported by path (import { SERMONS } from "~/data/sermons").

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.css. Every utility such as bg-plum or text-muted comes from them.

Token Default Used for
--color-paper #fbf6ee Page background
--color-shell #fffdf9 Cards and form fields
--color-sand #f4eadb Soft panels, filter bars
--color-line #e5d8c5 Hairlines and card rings
--color-ink #2b1b36 Text, the footer, dark bands and the audio player
--color-muted #5f5468 Secondary text
--color-plum / --color-plum-dark #5b2a6e / #431d52 Brand color, primary buttons, links
--color-lilac #efe5f5 Selected states, worship events
--color-apricot / -light / -dark #f6a96b / #fde3cc / #9a4716 Warm accent (backgrounds), tint, accent text
--color-sage / -light / -dark #c9dcc0 / #e6efe0 / #33603b Hills, care ministry, serve events, success
--color-river / -light / -dark #bcd9e8 / #e3f0f6 / #1f5577 River, kids' ministry and events
--color-rose #b3364a Errors, "Live now", classes events

To change the brand color, edit the tokens; the dev server picks the change up at once:

css
@theme {
  --color-plum: #1f5c7a;
  --color-plum-dark: #164459;
}

Use apricot only as a background: for text on light backgrounds use apricot-dark, which passes WCAG AA. The SVG artwork (hero, map, portraits, series covers, logo) uses literal hex values; search app/ for #5b2a6e and #f6a96b to recolor it. Also change theme-color in app.head in nuxt.config.ts if you change the page background.

Component classes in main.css: .btn-primary, .btn-warm, .btn-outline, .btn-light, .btn-sm, .card, .card-hover, .eyebrow, .h-display, .h-section, .tag, .chip-dot, .link, .field, .field-label, .field-error, .option and .pill (radio and checkbox cards that highlight when checked), .check, .faq (the accordion) and .scrub (the player's slider).

Typography#

Urbanist (headings, font-display) and Nunito Sans (body text, font-sans) are self-hosted from app/assets/fonts/: the variable Latin woff2 files Google Fonts serves (SIL Open Font License), declared with @font-face at the top of main.css. They keep their real family names, so the tokens and the SVG text (font-family="Urbanist, sans-serif") use them directly:

css
--font-sans: "Nunito Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Urbanist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;

To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the names in @theme.

Content#

Page copy is in the page files in app/pages/. Everything that is listed, filtered or calculated comes from app/data/:

Export File What it holds
CHURCH church.ts Name, timeZone (IANA name, e.g. America/Chicago), timeZoneLabel, full address for calendar files, liveUrl, uidDomain
SERVICES church.ts The weekly schedule: day (0 = Sunday), start ("09:00"), minutes, name
KEEP_DEMO_CURRENT, DEMO_ANCHOR church.ts Moves demo dates forward (see Demo dates)
PEOPLE, STAFF people.ts Staff and speakers with role, email, bio and avatar; STAFF is the order on the about page
SERIES, BOOKS, SERMONS sermons.ts Sermon series (title, subtitle, art: table, sun, wheat or wave, blurb), book labels, every sermon
EVENT_CATS, EVENTS events.ts Event categories with their color classes, and events
DAYS, AREAS, STAGES, GROUPS groups.ts Small-group filter labels and groups
GIVING giving.ts Amount chips, default amount, min and max, card fee, funds
VISIT visit.ts Number of Sundays offered, maximum party size, kids' rooms by age
NAV, MOBILE_NAV site.ts Menu items

Add a sermon to SERMONS (order doesn't matter; lists sort by date). TypeScript checks the keys:

ts
{
  id: "a-new-sermon", title: "A New Sermon", series: "open-table", part: 6, speaker: "dana",
  date: "2026-10-18", book: "luke", passage: "Luke 10:38–42", duration: 2100, audio: "",
  summary: "One or two sentences for cards and the page description.",
  verse: { text: "Quoted text.", ref: "Luke 10:41 (KJV)" },
  notes: [["Heading", "One short paragraph."], ["Heading", "Paragraph."]],
  questions: ["First question?", "Second question?"],
},

speaker is a PersonId (a key of PEOPLE), series a SeriesId, book a key of BOOKS, and duration is in seconds. A new person or series also needs its id added to the PersonId or SeriesId type. The demo quotes the King James Version, which is in the public domain; check the license of any other translation before quoting it.

Add an event to EVENTS. Times are 24-hour, in the church's time zone; add weekly to repeat it every week until that date:

ts
{ id: "choir-night", title: "Choir Night", cat: "worship", date: "2026-11-05", weekly: "2026-12-17",
  start: "19:00", end: "20:30", place: "Main auditorium", summary: "Open rehearsal for anyone who sings." },

Add a group to GROUPS; day, area and stage must be keys of DAYS, AREAS and STAGES, and spots: 0 shows "Full: join the waitlist":

ts
{ id: "lunch-break", name: "Lunch Break", day: "wed", time: "12:15", area: "downtown", stage: "everyone",
  host: "Ana Ruiz", where: "Riverbend, Room 210", spots: 6, childcare: false, blurb: "A 45-minute study over lunch." },

Demo dates#

The demo is written for the week of DEMO_ANCHOR ("2026-10-11"). While KEEP_DEMO_CURRENT is true, every sermon and event date moves forward by whole weeks so that the latest sermon is always last Sunday and events are always upcoming. Weekdays never change. Set KEEP_DEMO_CURRENT = false in app/data/church.ts once you enter real dates, or your dates will be shifted too.

The shift is worked out by calendar() in app/utils/demo.ts, in the browser, from today's date in the church's time zone. Anything that depends on the clock or the address (dates, the countdown, "Live now", the calendar, sermon lists, filters read from the URL) is rendered once the page is running in the browser (in onMounted(), or behind useMounted()), so the generated HTML never holds a stale date and hydration never mismatches.

Images and artwork#

There are no photos. All artwork is SVG:

Artwork Where
Hero landscape (church, river, hills) Inline in app/pages/index.vue, 600×540
Welcome tent, small-group table, 404 signpost Inline in the visit and groups pages and app/error.vue
Parking map Inline in app/pages/visit.vue, 640×420, with a <title> and <desc>
Staff portraits <StaffPortrait as="span" :svg="avatarSvg('dana')" />, drawn by avatarSvg() in app/utils/art.ts from each person's avatar settings
Series covers <SeriesArt series="ruth" :v="2" />, 320×200, varied by v (the sermon's part)
Logo app/components/SiteLogo.vue, and public/img/favicon.svg

A portrait's avatar object takes skin, hair, style (short, long, bun, curly, coily, waves or bald), bg, shirt, and optional glasses and beard. avatarSvg(id) is decorative; avatarSvg(id, label) is an image with that alt text.

To use photos instead, replace <StaffPortrait> or <SeriesArt> with <img> tags (files in public/, with $config.app.baseURL in front of the path): portraits are square (1:1), series covers 16:10, and the hero frame about 10:9. Give every image meaningful alt text.

The social image is public/img/og-image.png (1200×630), rendered from og-image.svg. Edit the SVG, then export it to PNG at 1200×630.

The header and its menus are app/components/SiteHeader.vue, built from NAV and MOBILE_NAV in app/data/site.ts; the footer is app/components/SiteFooter.vue. To add a page, create app/pages/<route>.vue, add its route to routes in nuxt.config.ts and add it to NAV (and to the footer lists if you like). The current page's link gets aria-current="page" (useCurrentPage()); /sermon counts as Sermons.

Features#

Service status and countdown#

<ServiceStatus> in the top bar shows "Next gathering: Sunday at 9:00 am", or "Live now" with a "Watch" link while a service is running. <CountdownBox> (home page and the sermons page; dark for the plum panel) shows days, hours, minutes and seconds to the next service, and switches to a "Live now" card with a "Watch live" button linking to CHURCH.liveUrl. useThisSunday() gives the date of the next Sunday with a service still to come.

Everything comes from serviceState() in app/utils/services.ts, which reads SERVICES in CHURCH.timeZone, so a visitor in another time zone sees the right countdown for your church. Add a service to SERVICES:

ts
{ day: 3, start: "18:30", minutes: 60, name: "Midweek Worship" },

To preview the live state at any time, add ?live=1 to a URL. The ticking numbers are hidden from screen readers; a visually hidden sentence ("Starts in 5 days, 18 hours and 53 minutes.") gives the same information without announcing every second. Before the page is running in the browser, these show the plain service times.

Audio player#

<AudioPlayer :sermon="s" /> appears on the sermon page, on the home page (latest sermon) and in the "Watch live" panel on /sermons.

  • With a real file: set the sermon's audio to a URL ("/audio/room-at-the-table.mp3" for a file in public/audio/, or a full URL from your podcast host). The player then drives a real <audio> element: its duration comes from the file, and speed, seeking and progress use the media element.
  • Without a file (audio: "", the demo): playback is simulated over the sermon's duration with a timer, so every control works and a note under the player says so.

Controls: play/pause, a slider for scrubbing (5-second steps), back and forward 15 seconds, and a speed button cycling through 1×, 1.25×, 1.5×, 1.75×, 2× and 0.75×. Only one player plays at a time.

Keyboard, while focus is inside the player: Space (when not on a button) or K play/pause, J and L back and forward 15 seconds, arrow keys on the slider move 5 seconds, Home/End jump to the start or end. The slider has an aria-valuetext such as "12 minutes 5 seconds of 38 minutes 42 seconds", the play button's label switches between "Play" and "Pause", and speed changes are announced through a polite live region.

The listening position is saved (see Saved data); returning to a sermon shows "Resuming at 12:04" and sermon cards show "Resume at 12:04".

Sermon archive#

<SermonArchive> fills the series, speaker and book selects from the data and filters on every change. Search (after a short pause in typing, or on Enter) matches every word against the title, passage, summary, series and speaker. The filters are kept in the URL, so links can be shared: /sermons?series=open-table&speaker=dana&book=luke&q=table (names are case-insensitive; unknown values are ignored). Series cards toggle the series filter. The result count is a live region; with no results an empty state offers to clear the filters.

Sermon page#

/sermon?id=<id> shows one sermon; an unknown or missing id shows the latest sermon, with a notice for an unknown one. The page title and meta description follow the sermon. "Copy questions" copies the title, passage and numbered questions to the clipboard (it needs https or localhost; elsewhere it says copying failed). Related sermons are the rest of the series, then other sermons by the same speaker. Moving to another sermon starts the page again from the top.

Events calendar#

<EventsCalendar> shows every occurrence of EVENTS (weekly events are expanded by calendar()). The month view is a table with one button per day; choosing a day shows its events in the "Selected day" panel, with the next three events after it. On wide screens each day lists up to two events and a "+n more" button. The list view shows weekly events once in an "Every week" box, then upcoming events by month.

Category pills filter both views. State is kept in the URL: /events?view=list&cat=kids,serve&month=2026-11. Opening an event adds event=<id>-<date>, so a link opens straight to that event.

The detail is <EventDialog>, a native <dialog> (focus stays inside it and Esc closes it; focus returns to the button that opened it). <EventCard> and the calendar open it with useEventDialog().open(occurrence, button). "Add to calendar" downloads an .ics file built by buildIcs() in app/utils/ics.ts, with start and end times converted from the church's time zone to UTC, so it imports correctly into Google Calendar, Apple Calendar and Outlook wherever the visitor is.

Groups finder#

<GroupFinder> renders filter pills for day, area and season of life from DAYS, AREAS and STAGES, each with a live count; options that would give no results are disabled. "Childcare provided" is a checkbox. State is in the URL: /groups?day=tue,thu&area=west&stage=families&childcare=1. "Ask to join" links to /contact?topic=groups&group=<id>, which pre-fills the contact form.

Plan your visit#

<VisitForm> offers the next VISIT.sundays Sundays (computed in the church's time zone; today is skipped once the last Sunday service has started) and one radio card per Sunday service. Adults and children use steppers (typed numbers are kept within limits when the field changes); each child gets an age select, and the summary shows which room they go to from VISIT.rooms. Children in the last room (youth) count as an extra seat.

Giving#

<GiveForm> builds the amount chips from GIVING.amounts and the fund select from GIVING.funds. Typing your own amount clears the chips. "Cover the card fee" adds what is needed so that, after the fee in GIVING.fee (2.2% + $0.30 by default), the church receives the whole gift (feeFor()); the summary shows the total and what the church receives either way. /give?fund=pantry pre-selects a fund.

No payment is taken. See Connecting a payment provider.

Scroll reveal, header and back to top#

Elements with .reveal fade in as they scroll into view (app/plugins/reveal.client.ts); everything above the current element is revealed too, so jumping to an anchor never leaves gaps. A head script adds the js class to <html>, so content stays visible without JavaScript, and with reduced motion nothing is hidden or animated. Never put .reveal on an element that also has a :class binding: Vue would drop the is-visible class the plugin adds.

The header gets a border and shadow once the page scrolls. The mobile menu (#menu-toggle) closes with Esc, after following a link and when the window grows to desktop width. The back-to-top button (BackToTop.vue) appears after about one screen, scrolls up (instantly with reduced motion) and moves focus to the main content; it hides while it would cover the sticky gift and visit summaries (data-back-to-top-hide). To remove it, delete <BackToTop /> from app/layouts/default.vue and the .back-to-top styles in main.css.

In the HTML version every link loads a page. Here links are client-side navigations, so a link to the page already showing (Sermons on a filtered sermon archive, a footer link to the current page) restarts the page from the top (app/plugins/same-page.client.ts and the page key in app/app.vue), and filters that write the URL themselves (setUrl() in app/utils/url.ts) keep the router's history state.

Forms#

All forms use useValidation() (app/composables/useValidation.ts): errors appear on blur and submit and update as you type, the first invalid field is focused, each error (<FieldError>) is linked with aria-describedby, and a status line says "Please fix the highlighted fields." The rules come from each field's attributes:

Attribute Rule
required Must not be empty (checkboxes must be checked, selects must have a value)
type="email", type="tel" Must look like an email address / phone number
minlength Minimum length
data-label Name used in the error message
data-novalidate Field is skipped (used by filters)
Form Component Behavior
Newsletter (data-form="newsletter") NewsletterForm.vue (home) Replaced with a thank-you message that points to a confirmation email
Plan your visit (#visit-form) VisitForm.vue Validates contact details and each child's age; confirms the Sunday, time and seats, with a reference
Giving (#give-form) GiveForm.vue Validates the amount (min and max from GIVING), name and email; confirms the gift, fund and frequency, with a reference
Contact (#contact-form) ContactForm.vue Pre-filled from ?topic= and ?group=; thank-you message with a reference
Prayer (#prayer-form) PrayerForm.vue Request required; email becomes required when "I'd like a pastor to get in touch" is checked; the message reflects "Keep this private"

The forms don't send anything until you connect them, although their success messages read as if they had. The visit, giving and contact messages show a short reference (RC- and four characters) made in the browser by makeRef() in app/utils/forms.ts; show your form service's or payment provider's own reference instead if it returns one. Check that the promises in the messages match how your church works: a reminder email before Sunday, an emailed receipt for every gift, a reply within two business days (as the contact page says), and a confirmation email for newsletter sign-ups. The callback passed to useValidation() in each form is where to send it. For example, with Formspree in ContactForm.vue:

ts
const v = useValidation(async (form) => {
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  const name = (form.querySelector<HTMLInputElement>("#c-name")?.value ?? "").trim().split(" ")[0] ?? "";
  if (res.ok) done.value = { name, ref: makeRef("RC") };
});

Every field has a name, so FormData carries all the values; keep a name on any field you add. Prayer requests can be sensitive: send them to a service and inbox only your pastoral team can read, and honor the private field.

With pnpm build (a Node.js server), you can post to a server route of your own instead, for example server/api/contact.post.ts:

ts
export default defineEventHandler(async (event) => {
  const data = await readFormData(event);
  // Send data.get("email"), data.get("message")… to your inbox or CRM here.
  return { ok: true };
});

Connecting a payment provider#

The giving form never collects card details. To take real gifts, change the callback in GiveForm.vue to call your server, which creates a checkout session with your provider (Stripe Checkout, PayPal, or a church platform such as Tithe.ly or Planning Center Giving) and returns its URL:

ts
const res = await fetch("/api/create-gift", { method: "POST", body: new FormData(form) });
const { url } = await res.json();
window.location.href = url;

That endpoint needs a server (a server route with pnpm build, or a function on your host). Recurring gifts (frequency is weekly or monthly) map to subscriptions in most providers. Many church platforms also offer an embeddable giving form you can use instead of this one.

Saved data (localStorage)#

Key Shape Used for
riverbend-sermon-progress (PROGRESS_KEY) { "room-at-the-table": 724 } (seconds) Resume position for each sermon

readProgress() in app/utils/progress.ts keeps only ids that exist in SERMONS and positions between 5 seconds and 5 seconds before the end; anything else (broken JSON, arrays, unknown ids) is dropped. A position is removed when a sermon finishes. Storage goes through safeStorage (app/utils/safeStorage.ts): if the browser blocks it (private modes, sandboxed frames) or it is full, positions are kept in memory for the page view instead. To reset, run localStorage.removeItem("riverbend-sermon-progress") in the browser console, or change PROGRESS_KEY to stop using old values.

SEO and sharing#

  • Each page calls usePageSeo({ title, description, page }) (app/composables/usePageSeo.ts): the title and description, a canonical URL, Open Graph tags (with og-image.png, 1200×630) and a summary_large_image Twitter card. The sermon page passes getters, so its title and description follow the sermon shown. The 404 page passes noindex: true.
  • Set SITE_URL when building so canonical and image URLs are absolute and point at your domain (otherwise they use https://riverbend.example):
sh
SITE_URL=https://www.yourchurch.org pnpm generate
  • For search, consider Church structured data (a schema.org type) with your address and service times, added with useHead({ script: [{ type: "application/ld+json", innerHTML: … }] }) on the home page.

Accessibility#

  • Skip link, landmarks, one <h1> per page and a logical heading order.
  • aria-current="page" on the current navigation item; the mobile menu uses aria-expanded, closes with Esc and when the window grows to desktop width.
  • Visible focus outlines on every control; radio and checkbox cards show focus on the card.
  • The audio player is fully keyboard operable, with labeled buttons and a native range slider with spoken time.
  • The calendar is a table with a caption and column headers; day buttons have full-date labels with the number of events; the event detail is a modal <dialog>.
  • Filter results and counts are announced with role="status"; form errors are linked to their fields and the thank-you message is focused.
  • Illustrations have <title> text (the map also has a <desc>); decorative SVG is hidden from screen readers.
  • Color contrast meets WCAG AA; prefers-reduced-motion turns off reveals, the live pulse and the river shimmer.

When editing, keep one <h1> per page, give every new input a <label>, and don't use apricot for text.

Building and deploying#

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

nuxt generate prerenders every route in routes in nuxt.config.ts as <page>.html (sermons.html, linked as /sermons), 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 /sermons to sermons.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=/church 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#

  • The countdown is an hour off. Check CHURCH.timeZone is your church's IANA time zone (for example America/New_York), not a city name or an offset.
  • My sermons or events have the wrong dates. KEEP_DEMO_CURRENT is still true, so dates are moved forward. Set it to false in app/data/church.ts.
  • Dates and lists appear a moment after the page. That's by design: they depend on the visitor's clock or the address and are rendered in the browser (see Demo dates).
  • The player says playback is simulated. The sermon's audio is empty. Add a URL to an audio file; if it is on another domain, that server must allow it to be played (most podcast hosts do).
  • A new sermon, event or group doesn't type-check. Its series, speaker or cat must be one of the ids in SeriesId, PersonId or EventCategoryId; add new ids to those types.
  • "Copy questions" fails. The clipboard needs https or localhost.
  • A class has no effect. Tailwind only sees class names written out in full; build them with complete names ("bg-plum"), not by joining fragments.
  • Images or links break in a sub-folder. Build with DEMO_BASE_PATH, use NuxtLink for pages and $config.app.baseURL for files in public/.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.

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. Nunito Sans and Urbanist are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Names, places and people are fictional.

Search documentation

Search the guides and every template's documentation