Overview#
Brief is a website template for a solo lawyer or attorney. The demo content is for Pennington
Employment Law, the fictional Dayton, Ohio, practice of independent employment attorney Tessa Pennington, who
advises employees and senior staff on severance agreements, layoffs, wrongful termination, discrimination and
problems at work, mostly for fixed fees. This is the Nuxt version: Nuxt 4, Vue 3 single-file components,
TypeScript and Tailwind CSS v4, generated as a static site. This is version 1.0.1 (see
CHANGELOG.md). Live demo: freeframe.dev/templates/brief.
The sample copy is general information about employment law in Ohio and under federal law, written for a fictional practice. Before you publish, have a qualified lawyer in your jurisdiction review every legal statement, fee, time limit and regulatory detail, and replace the placeholders (registration number, insurance, complaints procedure) with your own.
Quick start#
Requirements: Node.js 24 or newer and pnpm (corepack enable pnpm, or npm install -g pnpm).
pnpm install
pnpm dev # http://localhost:3000, reloads as you editOther scripts:
| Command | What it does |
|---|---|
pnpm generate |
Generates the static site into .output/public/ |
pnpm preview |
Serves the last build locally |
pnpm build |
A Node.js server build in .output/ (pages are still prerendered) |
pnpm typecheck |
nuxt prepare and vue-tsc with Nuxt's strict settings |
pnpm lint |
ESLint (the flat config from @nuxt/eslint) |
pnpm build:demo |
The freeframe.dev demo build, copied to demo/ |
Pages#
| File | Route | Purpose | Notable sections and features |
|---|---|---|---|
app/pages/index.vue |
/ |
Home | Hero with the plain-English promise, portrait and "Book a free 15-minute call", credentials strip, four areas of help, four-step "how working together works", fixed-fee teaser, client reviews with a disclaimer, FAQ accordion, call to action |
app/pages/services.vue |
/services |
Services | Eight services with what's included, typical timeline and fee (<ServiceList>); filter chips by area with live counts and a shareable ?area= URL |
app/pages/fees.vue |
/fees |
Fees | Fee estimator (<FeeEstimator>: matter, complexity, employer contribution, payment plan), full fee table, staged lawsuit fees, employer contributions, payment plans and insurance |
app/pages/check.vue |
/check |
Check your options | Branching questionnaire with plain-English results (<OptionsCheck>), and a claim deadline calculator (<DeadlineCalculator>) |
app/pages/resources.vue |
/resources |
Guides | Featured guide, topic filters, search, empty state, shareable ?tag= and ?q= URLs (<GuideLibrary>) |
app/pages/article.vue |
/article |
Sample guide | Long-form article with breadcrumb, reading progress bar, sticky table of contents, not-legal-advice notices, author box and related guides |
app/pages/about.vue |
/about |
About | Story, three promises, credentials timeline, office illustration and hours, regulation, insurance, complaints and confidentiality (#regulation) |
app/pages/book.vue |
/book |
Book a call | Four-step booking flow with a live summary sidebar (<BookingFlow>), plus a short message form (<ContactForm>) |
app/error.vue |
404.html |
Not found | Struck-out page illustration and links back into the site |
Project structure#
brief/
nuxt.config.ts base path, <head> defaults, SITE_URL, prerendered routes, Tailwind
package.json scripts and dependencies
pnpm-workspace.yaml a standalone project (allowed build scripts, overrides)
tsconfig.json references the configs Nuxt writes to .nuxt/
eslint.config.mjs ESLint flat config
public/img/
favicon.svg logo mark, used as the favicon
og-image.svg social sharing image source (1200 x 630)
og-image.png the same image as PNG, referenced by og:image
tessa-portrait.svg illustrated portrait (480 x 560)
office.svg office and river illustration on the about page (640 x 460)
app/
app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with the page key (see "Links")
layouts/default.vue skip link, header, the page, footer and the back-to-top button
pages/*.vue one file per page (index, services, fees, check, resources,
article, about, book)
error.vue the 404 page
components/
SiteHeader.vue logo, main menu, phone number, Book button and mobile menu
SiteFooter.vue link lists, contact details, the not-legal-advice notice,
the regulatory statement and copyright
ServiceList.vue service cards and area filter
FeeEstimator.vue the fee estimator
OptionsCheck.vue the "what are my options?" check
DeadlineCalculator.vue the claim deadline calculator
GuideLibrary.vue guide cards, topic filter and search
BookingFlow.vue the four-step booking flow and summary
ContactForm.vue the message form on the booking page
FieldError.vue an inline form error
AppIcon.vue a line icon from app/data/icons.ts
BackToTop.vue the back-to-top button
composables/
usePageSeo.ts per-page title, description, canonical and Open Graph tags
useValidation.ts form validation rules and messages
useAsset.ts public/ paths with the base path
useYear.ts the copyright year
data/
matters.ts matters and fees, estimator levels, payment plan
check.ts the options check questions and results, deadline rules
booking.ts call types and booking availability
services.ts the service cards
guides.ts the guide library
areas.ts areas of work (the services filter)
icons.ts line icons as SVG markup
utils/
dates.ts date keys, federal holidays, deadline math, long dates
zone.ts Eastern-time math for booking slots
format.ts money(), plural(), makeRef() and setParams()
safeStorage.ts localStorage with an in-memory fallback
plugins/
reveal.client.ts scroll reveals
same-page.client.ts links to the page you're on start it afresh
stable-build.server.ts reproducible builds
router.options.ts query strings written like the HTML version's links
assets/css/main.css Tailwind source: fonts, brand tokens (@theme) and component classes
assets/fonts/ self-hosted Literata and Albert Sans (woff2) and OFL.txt
README.md, CHANGELOG.md, DOCS.mdCustomizing#
Brand and colors#
All colors are tokens in the @theme block of app/assets/css/main.css. Tailwind turns each one into utilities
(bg-ink, text-pine, border-line and so on), so changing a token changes it everywhere.
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f5f3ec |
Page background |
--color-paper-2 |
#ece9df |
Sunken panels, tags |
--color-card |
#fffdf8 |
Cards and inputs |
--color-line / --color-line-strong |
#dcd8ca / #bfb9a6 |
Hairlines and input borders |
--color-ink |
#13221c |
Text, primary buttons, dark sections |
--color-ink-2 / --color-ink-3 |
#1c3229 / #2c473b |
Raised surfaces and hovers on ink |
--color-slate |
#4c5852 |
Secondary text (AA on paper and card) |
--color-slate-soft |
#b6c4bb |
Secondary text on ink |
--color-pine / --color-pine-soft |
#1f5a43 / #dfe9e2 |
Links, icons and icon tiles |
--color-lime / --color-lime-soft / --color-lime-deep |
#d4eb5c / #edf6bd / #b8d13a |
The highlighter accent: marks, chips, buttons on ink |
The lime accent is a fill color. Don't use it for text on light backgrounds; use text-pine or text-ink.
To change the accent, for example to a warm coral:
@theme {
--color-lime: #ffb199;
--color-lime-soft: #ffe1d6;
--color-lime-deep: #f2896b;
}The SVG files in public/img/ and the inline SVGs (logo, page illustrations) use the hex values directly; search
for #d4eb5c and #13221c to update them too. The browser theme-color is in app.head.meta in nuxt.config.ts.
Typography#
Two variable fonts are self-hosted from app/assets/fonts/ and declared with @font-face at the top of
app/assets/css/main.css (each in a latin and a latin-ext file, the same files Google Fonts serves):
- Literata (
--font-serif, used throughfont-serifand the.displayclass) for headings, prices and quotes. Weights 300 to 600, italic 300 to 500, with an optical-size axis that.displayturns on. - Albert Sans (
--font-sans) for everything else. Weights 400 to 700, italic 400 to 600.
To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the two --font-*
tokens. Both tokens include system fallbacks.
Content#
Most text lives directly in the page files in app/pages/. The data behind the interactive features lives in
typed files in app/data/, imported with ~/data/<file>:
| File and export | What it holds | Used by |
|---|---|---|
matters.ts MATTERS |
Each matter's name, area, timeline, whether an employer contribution applies, three levels ([min, max] in US dollars) and three hints |
Fee estimator, options check results, booking topics |
matters.ts LEVELS |
["straightforward", "standard", "complex"], the ?level= values |
Fee estimator |
matters.ts PLAN, DEFAULT_CONTRIBUTION |
{ minFee: 1000, months: 3 }, and the starting contribution (500) |
Fee estimator |
check.ts CHECK_STEPS, CHECK_RESULTS |
The questions of the options check and the 15 result screens | Options check |
check.ts CHECK_KEY |
"brief-check-v1", the localStorage key |
Options check |
check.ts DEADLINE_RULES |
Claim types and their time limits in days or months | Deadline calculator |
booking.ts CALL_TYPES |
Booking call types: label, minutes, price, where | Booking flow |
booking.ts AVAILABILITY |
Time zone, weekly hours, notice, closed days | Booking flow |
services.ts SERVICES |
The service cards: id, area, icon, title, summary, what's included, timeline and fee | Services page |
guides.ts FEATURED, GUIDES, TOPICS |
The guide cards and the topic chips | Guides page |
areas.ts AREAS |
The four areas of work | Services filter |
TypeScript checks every entry (pnpm typecheck), so a typo in a new matter or result shows up in your editor.
To add a matter, add an entry to MATTERS:
vacation: {
name: "Vacation pay claim",
area: "Claims and disputes",
timeline: "1–2 weeks",
contribution: false,
levels: [[300, 300], [750, 750], [1000, 1400]],
hints: ["Checking your figures", "Plus a letter to your employer", "Several years of underpayment"],
},It then appears in the estimator's matter list and the booking topic list. Add a matching card to SERVICES in
app/data/services.ts with the same id (links such as /services#vacation, /book?topic=vacation and
/fees?matter=vacation#estimator use it), and a row to the fee table in app/pages/fees.vue.
Guides are entries in GUIDES with tags (topic ids from TOPICS), keywords (extra search words that
aren't on the card), a tone for the card header (pine, paper or lime), an icon and an href.
Text in .vue templates is HTML: entities such as ’ work as they are. A literal {{ in text must be
written as {{ "{{" }}.
Images and artwork#
All artwork is SVG, so it stays sharp and light. Files in public/ are copied to the site as they are; pages
reference them with asset() from useAsset() so the base path is included (:src="asset('img/office.svg')").
public/img/tessa-portrait.svg(480 x 560) is the portrait on the home and about pages, and is cropped to a circle or square on the article and booking pages. To use a photo, keep a 6:7 portrait crop and replace the file; theobject-cover object-topclasses keep the face in view in the small crops.public/img/office.svg(640 x 460) on the about page.- Inline SVGs: the logo mark in the header and footer, the document drawings in the call to action, options
check, featured guide and 404 page, and the icons. Icons drawn from data (service cards, guide cards) use
<AppIcon name="...">with the shapes inapp/data/icons.ts. public/img/og-image.svgis the source of the social image. Social networks need a PNG, so export it topublic/img/og-image.pngat 1200 x 630 after editing (any vector editor or a headless browser).public/img/favicon.svgis the favicon (set innuxt.config.ts).
Navigation, header and footer#
The header and mobile menu are in app/components/SiteHeader.vue, the footer in
app/components/SiteFooter.vue. Links are <NuxtLink to="/fees"> (no .html, no trailing slash), which adds
the base path. Header links pass :aria-current="current('/fees')", which marks the page you're on with
aria-current="page" (and draws the highlighter underline); on the article page, Guides stays marked. Other
links pass :aria-current="undefined" so Nuxt doesn't mark them on its own.
To add a page, create app/pages/<name>.vue with a <main id="main" tabindex="-1"> root and a usePageSeo()
call, add "/<name>" to routes in nuxt.config.ts (every page is prerendered from that list), and add links
to the header menu, the mobile menu and, if you like, a footer list.
The footer holds the not-legal-advice notice and the regulatory statement. Keep both, and replace the placeholder registration number with your own.
Links#
In the HTML version every link loads a page. Here links switch pages in the browser, so
app/plugins/same-page.client.ts makes a link to the page you're already on (the Services link on a filtered
services page, a "Help with" link in its footer) start that page afresh at the linked address, scrolled to the top,
as a page load would. Links that only jump to a #section of the current page are left alone.
Features#
Every feature is a component with its own state; each reads the URL and saved data in onMounted(), so the
generated HTML always matches the first render in the browser.
Fee estimator#
app/components/FeeEstimator.vue, on the fees page (#estimator, [data-estimator]).
- The matter list is built from
MATTERS. The three level cards (input[name="est-level"], values0-2) show the matter'shints. - When
minequalsmaxthe result shows a fixed fee with a "Fixed fee" tag; otherwise a range with a "Range: fixed in writing before work starts" tag. - The breakdown always lists the fee, any employer contribution and what you pay.
- My employer is contributing only appears for matters with
contribution: true(severance agreements and exit negotiations). The amount (defaultDEFAULT_CONTRIBUTION) is deducted from the fee, never below zero. - I'd like to spread the cost shows
PLAN.monthsequal payments when the amount you pay (after any contribution) is at leastPLAN.minFee, and otherwise explains why it isn't available. - The matter and level are written to the URL (
/fees?matter=tribunal&level=complex) withsetParams(), so links such as "Estimate this fee" on the services page open the right estimate. Unknown values fall back to a severance agreement at the straightforward level.
The result is announced politely to screen readers (aria-live). Every control is a native select, radio,
checkbox or number input, so it works with a keyboard.
Options check#
app/components/OptionsCheck.vue, on the check page ([data-check]).
Each step in CHECK_STEPS has a question q, a hint (or "") and options. An option has either next
(another step id) or result (a key of CHECK_RESULTS). The first step is always start. A result has a tag,
title, summary, steps, an urgent message (or null), a service (a MATTERS key, or null) and a
deadline (a DEADLINE_RULES key, or null).
The result screen shows the summary and next steps, an amber "Time-sensitive" box when urgent is set, the
recommended service with its lowest fee and links to the services page, estimator and booking
(/book?topic=<service>), a "There may be a time limit" prompt whose link preselects the right claim type in the
calculator (the component emits deadline, and app/pages/check.vue calls the calculator's select()), and a
collapsible recap of the answers. Back and Start again buttons let visitors change their answers.
To add a branch, add a step and point an option at it:
"transfer-stage": {
q: "Has the transfer happened yet?",
hint: "",
options: [
{ label: "Not yet", result: "general" },
{ label: "Yes", result: "general" },
],
},Each question is a <fieldset> with a <legend> that receives focus when it appears; answers are radio buttons
styled as cards. A missing answer shows an inline error with role="alert".
Deadline calculator#
app/components/DeadlineCalculator.vue, on the check page (#deadline, [data-deadline]).
Each claim type in DEADLINE_RULES has days or months. A rule in days counts from the date (300 days from
March 15 is January 9). A rule in months ends the day before the same date that many months later, or on the final
day of that month if it has no such date (deadlineFrom() in app/utils/dates.ts). The two 300-day rules follow
the EEOC charge deadline in Ohio; the two-year rules follow the Ohio Civil Rights Commission and federal wage
limits. The result shows the date, the number of days left ("1 day", "12 days"), and warnings when the date is 21
days away or less, today, or has passed. Dates must be within the last two years or the next six months.
This is an estimate only and says so on screen. It doesn't model continuing violations, tolling or late-claim exceptions, and it doesn't store anything.
Booking flow#
app/components/BookingFlow.vue, on the book page ([data-booking]).
- Call type from
CALL_TYPES./book?type=advicepreselects one. - Date and time: a month calendar (Monday first) and time slots generated from
AVAILABILITY: weeklyhourswindows in Eastern time,slotStepminutes apart, at leastminNoticeHoursfrom now, up todaysAheaddays ahead, skipping US federal holidays (worked out for any year byfederalHolidays()inapp/utils/dates.ts) and anycloseddates.demoBusySharehides a stable share of slots so the demo looks realistic; set it to0when you connect a real calendar. - Details: name, email, optional phone, topic (
/book?topic=settlementpreselects it), the conflict-check fields (employer, required; anyone else involved, optional), an optional deadline, a short description and a required acknowledgment that booking doesn't create a client relationship. - Confirmation with a summary, a short reference (see Forms), what to do to change the time and a Save a calendar hold (.ics) button.
Times are always generated in America/New_York (app/utils/zone.ts), so they follow US daylight saving time. If
the visitor's device is in another time zone, each slot also shows their local time, and so do the summary and
confirmation.
The calendar is a table of buttons with a roving tabindex: arrow keys move by day and week, Home and End to the
start and end of the week, Page Up and Page Down by four weeks. Unavailable days are aria-disabled, the chosen
day is aria-pressed, and each day has a full label such as "Tuesday, October 20, 2026, 9 times available". Each
step's heading receives focus when the step opens.
Filters and search#
- Services (
app/components/ServiceList.vue): chips withdata-filtershow the cards in that area, update the counts and the "8 services shown" status, and keep the choice in?area=. - Guides (
app/components/GuideLibrary.vue): topic chips (data-tag) and the search box combine. Search matches every word against the card's text and itskeywords. Both are kept in the URL as?tag=and?q=. When nothing matches, an empty state offers to clear everything.
Chips are buttons with aria-pressed, and the result counts are in a polite live region.
Article reading aids#
On the article page (app/pages/article.vue), [data-progress] is the bar at the top of the window; it fills as
you scroll through [data-article]. The table of contents ([data-toc]) marks the section you're reading with
aria-current="true". Each <h2> in the article needs an id that matches a table of contents link and an entry
in the page's TOC list.
FAQ accordion, scroll reveals and motion#
The home page FAQ buttons (.acc-btn) toggle the panel named in aria-controls and update aria-expanded; any
number can be open. Elements with .reveal fade in as they scroll into view (app/plugins/reveal.client.ts), and
the hero highlighter (.draw-mark) draws in once. With "reduce motion" turned on, everything appears immediately.
A head script in nuxt.config.ts adds the js class to <html>, so content is only hidden for the reveal when
JavaScript runs. Don't put .reveal on an element with a :class binding: Vue would drop the is-visible class
the plugin adds.
Header, mobile menu and back to top#
The header gains a border once you scroll. Below 1024px the menu button opens #mobile-menu; Escape closes it and
returns focus to the button, and it closes when a link is chosen or the window grows past 1024px. The back-to-top
button (app/components/BackToTop.vue) appears after about one screen of scrolling and hides while the footer's
"Back to top" link is visible. To remove it, delete the file, its line in app/layouts/default.vue and the
.back-to-top styles in app/assets/css/main.css.
Forms#
There are three forms. None of them sends anything until you connect it, even though the booking confirmation and
the message form's thank-you read as if it had been. Both show a short reference (PE- and four characters) made
in the browser by makeRef() in app/utils/format.ts; if your form service or booking tool returns its own
reference, show that instead. They promise a reply or confirmation within one business day, as the rest of the
booking page does: change them all if yours differs.
| Form | Where | Validates |
|---|---|---|
Booking details ([data-booking-form]) |
Book page, step 3 (BookingFlow.vue) |
Name, email, optional phone (10+ digits), topic, employer, description (20+ characters), acknowledgment |
Message ([data-contact-form]) |
Book page (ContactForm.vue) |
Name, email, message (20+ characters) |
Deadline calculator ([data-deadline]) |
Check page (DeadlineCalculator.vue) |
A date within the last two years or the next six months |
Validation runs on submit and again as fields are corrected (app/composables/useValidation.ts). Each field
declares its rule with data-validate (required, email, phone-optional, message, consent,
date-event); the messages are in fieldMessage(). Errors appear under the field (<FieldError>), are linked
with aria-describedby, set aria-invalid="true", and focus moves to the first invalid field.
Connecting to Formspree#
Create a form at Formspree, then replace the marked comment in submit() in app/components/ContactForm.vue:
async function submit() {
if (!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" },
}).catch(() => null);
if (!res?.ok) {
alert("Sorry, your message could not be sent. Please email or call instead.");
return;
}
// ...then show the thank-you panel as before (name, reference, sent.value = true)
}The booking form has the same marked comment in submit() in app/components/BookingFlow.vue.
Taking real bookings#
The simplest route is a scheduling tool such as Cal.com or Calendly: replace the step 2 calendar in
BookingFlow.vue with the tool's embed, or send people straight to your booking link from step 1. If you keep this
calendar, post the details form to your form service (as above) and add the chosen slot:
const data = new FormData(form.value!);
data.append("call_type", type.value);
data.append("start", slot.value!.at.toISOString());Set AVAILABILITY.demoBusyShare to 0 in app/data/booking.ts, and add any other days off to closed.
Because inquiries can contain sensitive personal information, use a form service that stores data in a suitable
region, and say in your privacy notice how you handle it. The site is static, so there is no server code to
secure, but never put API keys or secrets in app/: everything there ends up in the browser.
Saved data (localStorage)#
| Key | Shape | Written by |
|---|---|---|
brief-check-v1 |
{ "answers": [0, 1], "date": "2026-10-15" } |
Options check, when a result is reached |
answers holds the chosen option index for each question in order. On load, replay() in OptionsCheck.vue
replays the answers through CHECK_STEPS; if any index is out of range, or the answers don't end in a result, the
saved value is ignored and the intro is shown. If you change the questions, change CHECK_KEY in
app/data/check.ts (for example to "brief-check-v2") so old answers are dropped. Start again removes the
key; visitors can also clear site data in their browser. Storage goes through safeStorage in
app/utils/safeStorage.ts: when the browser blocks it, the result is kept in memory for the page view instead.
Nothing else is stored, and nothing is sent anywhere.
SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which outputs the title,
meta description, a canonical link, Open Graph tags (og:title, og:description, og:type, og:site_name,
og:url, og:image) and twitter:card. The article passes type: "article"; app/error.vue passes noindex,
which outputs <meta name="robots" content="noindex"> and no canonical.
Canonical, og:url and og:image are absolute URLs built from runtimeConfig.public.siteUrl plus the base path.
Set your domain when building:
SITE_URL=https://www.example.com pnpm generateor change the fallback (https://pennington-law.example) in nuxt.config.ts.
Suggestions: add a public/robots.txt and a sitemap (hand-written in public/, or a sitemap module), and add
LegalService structured data (schema.org JSON-LD, with useHead()) to app/pages/index.vue with your real name,
address, phone and business hours. The template doesn't include structured data because it would describe a
fictional practice. Check your state bar's rules on advertising, reviews and how fees must be published.
Accessibility#
Built in:
- Skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines; colors meet WCAG AA (body text
slateonpaperis about 7:1). - Native form controls with labels, hints linked by
aria-describedby, inline errors and focus management. - Accessible patterns: mobile menu (
aria-expanded, Escape), accordion, chips (aria-pressed), live regions for counts and the estimate, keyboard calendar, focus moved to new steps and results. prefers-reduced-motionturns off reveals, the highlighter animation and smooth scrolling.
When editing, keep one <h1> per page, give every new input a <label>, and keep text on lime dark
(text-ink).
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (fees.html,
linked as /fees), 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 /fees to fees.html on their own need that rewrite
(Vercel: "cleanUrls": true in vercel.json); most serve 404.html for unknown URLs. Serve the site over HTTPS,
especially once forms are connected.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/brief pnpm generatepnpm 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.
Tailwind scans app/ for class names (and nuxt.config.ts, for the <body> classes). Classes that only appear
inside TypeScript strings must be written out in full (for example "tag tag-amber"), not assembled from pieces.
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#
- New Tailwind classes have no effect. Make sure the class appears in full in a file under
app/. Restartpnpm devif you added a new folder. pnpm lintfails with a missing.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) once;nuxt preparewrites it.- An option in the check leads nowhere. Every
nextmust name a key ofCHECK_STEPS, and everyresulta key ofCHECK_RESULTS. - The check keeps showing an old result. It's saved in
localStorage. Click Start again, or changeCHECK_KEYafter changing the questions. - The booking calendar has no free days. Check
AVAILABILITY.hours(day 0 is Sunday),minNoticeHours,daysAheadandclosed, and thatdemoBusyShareis below 1. - Booking times look an hour out. Slots are in
America/New_Yorkand follow US daylight saving time. ChangeAVAILABILITY.timeZoneandzoneNameif you practice elsewhere. - A new page shows the 404 page after
pnpm generate. Add its route toroutesinnuxt.config.ts. - A form shows its thank-you message but nothing arrives. It sends nothing until you connect it; see Forms.
- The social preview has no image. Build with
SITE_URLset to your real domain soog:imageis an absolutehttps://URL toog-image.png.
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. Literata and
Albert Sans are included under the SIL Open Font License (app/assets/fonts/OFL.txt). The practice, people,
reviews and fees are fictional.