Overview#
Accrue is a multi-page website template for an accounting firm, CPA practice or tax preparer. The demo business is
Fernley Whitcomb CPAs, a fictional seven-person firm on Cherry Street in Chattanooga, Tennessee, serving households
and businesses on both sides of the Georgia and Alabama state lines. This is the Nuxt version: Nuxt 4, Vue 3 single-file
components, TypeScript and Tailwind CSS v4, generated as a static site. It is version 1.0.0 of the Nuxt stack (see
CHANGELOG.md). See the template page at freeframe.dev/templates/accrue,
which links to the live demo.
The template has a tax deadline calendar worked out from rules (it never goes stale), a live countdown to the next
deadline, a tax-season document checklist that remembers checks, a fee estimator, consultation booking with a
rolling availability grid, and data-driven service and article pages. Every date and time is worked out in
America/New_York (Eastern time), whatever the visitor's own time zone is.
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000, reloads as you edit| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve 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 |
Build for /demos/nuxt/accrue into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Home: hero with a next-deadline card and countdown, firm figures, numbered list of services, who we serve (six industries), Tennessee tax note, testimonials, three getting-started steps with the first open consultation time, call to action |
/services |
app/pages/services.vue |
Service cards from SERVICES, "start from what's bothering you" guide, how every engagement works |
/service?s=<slug> |
app/pages/service.vue |
One service: name, intro, price line, art, what's included, who it's for, timeline, FAQ, related services; unknown slugs fall back to DEFAULT_SERVICE with a notice |
/pricing |
app/pages/pricing.vue |
Four bookkeeping packages, the fee estimator, individual return prices, other services, billing note |
/deadlines |
app/pages/deadlines.vue |
Next-deadline countdown, taxpayer-type filter, year switch, month-by-month list with "moved from" notes, holiday list (<DeadlinesBoard>), tax-season document checklist (<DocChecklist>, saved) |
/team |
app/pages/team.vue |
Two partners and five staff with illustrated portraits, credentials explained |
/insights |
app/pages/insights.vue |
Category filter (?cat=), featured latest article, article grid |
/article?a=<slug> |
app/pages/article.vue |
One article: title, dek, author, date, cover art, body, sidebar with call to action and three more articles; unknown slugs show the latest article with a notice |
/contact |
app/pages/contact.vue |
Book a consultation (<BookingForm>): service and meeting type, ten-day slot grid, details form with validation, success state with reference; office hours and live open/closed status, map, directions, client portal section (#portal) |
| 404 | app/error.vue |
"Misfiled" drawing and links to the most-used pages (generated as 404.html) |
Project structure#
accrue/
nuxt.config.ts base path, <head> defaults, SITE_URL, prerendered routes, Tailwind
app/
app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, keyed so a same-page link restarts the page
layouts/default.vue skip link, header, the page, footer, back-to-top button
pages/ one file per page: index, services, service, pricing, deadlines, team, insights, article, contact
error.vue the 404 page
router.options.ts writes query strings with encodeURIComponent, like the HTML version
components/
SiteHeader.vue top bar with the next deadline, logo, menu, mobile menu
SiteFooter.vue footer (copyright year filled in by the browser)
SiteLogo.vue the logo mark
DeadlinesBoard.vue the deadline calendar on /deadlines
DocChecklist.vue the saved document checklist on /deadlines
BookingForm.vue the consultation request on /contact
BackToTop.vue the back-to-top button
composables/
useNow.ts the Eastern-time clock (with ?now=), set after mount
usePageSeo.ts title, description, canonical and Open Graph tags per page
useAsset.ts adds the base path to files in public/
data/ content and settings: firm.ts, services.ts, deadlines.ts, checklists.ts,
estimator.ts, booking.ts, insights.ts
utils/
calendar.ts dates, Eastern "now", holidays, deadlines, booking slots, office status
estimate.ts the fee estimate and its URL parameters
safeStorage.ts localStorage with an in-memory fallback
plugins/
reveal.client.ts scroll reveals
same-page.client.ts a link to the page already showing starts it afresh, like a page load
stable-build.server.ts reproducible builds (drops the build timestamp)
assets/css/main.css Tailwind v4: @font-face, @theme tokens, a few utilities and components
assets/fonts/ Hahmlet and Geologica woff2 files with their OFL licenses
public/img/ favicon.svg, og-image.svg/.png, services/*.svg, articles/*.svg
package.json, pnpm-workspace.yaml, tsconfig.json, eslint.config.mjs
README.md, DOCS.md, CHANGELOG.mdPages are prerendered to static HTML. Everything that depends on the clock, the query string or saved data is
worked out in the browser after the page has mounted (see Dates and the static build).
Files in components/, composables/ and utils/ are auto-imported; data files are imported by path
(import { SERVICES } from "~/data/services").
Customizing#
Brand and colors#
Design tokens live in the @theme block in app/assets/css/main.css. The ones that matter:
| Token | Value | Used for |
|---|---|---|
--color-paper |
#f5f1e6 |
page background (oat linen) |
--color-paper-2 |
#ebe5d3 |
alternate bands, past deadlines |
--color-card |
#fffcf4 |
cards and forms |
--color-ink |
#1f2316 |
text and dark bands (olive black) |
--color-muted |
#575a48 |
secondary text on light backgrounds |
--color-sage |
#c9ceb1 |
secondary text on ink |
--color-olive |
#4f5b1c |
buttons, links, logo |
--color-olive-soft |
#e1e5c8 |
tints, highlighted cards |
--color-marigold |
#e8b13a |
highlighter strokes, accent buttons (always with ink text) |
--color-due |
#9a3b1e |
errors, "due soon" tags, moved-date notes |
To change the brand color, edit --color-olive (and --color-olive-deep for hover); pnpm dev picks it up at once:
@theme {
--color-olive: #24506b;
--color-olive-deep: #1a3b4f;
}The SVG files in public/img/, the logo in app/components/SiteLogo.vue and the inline drawings use the same hex
values directly; search and replace them if you change the palette. The browser theme color is the theme-color
meta tag (#1f2316) in nuxt.config.ts.
Components worth knowing in main.css: .folder (the file-folder card with a tab, the template's motif;
.folder-ink and .folder-olive variants), .mark (the marigold highlighter under a word in a headline),
.display and .section-title (headline sizes), .chip (filter buttons, styled by aria-pressed), .day-btn and
.slot-btn (booking grid), .receipt (zigzag edge under the estimator total), .check-row (checklist rows).
Typography#
Two fonts, self-hosted from app/assets/fonts/ with @font-face rules at the top of app/assets/css/main.css (the
same Latin files Google Fonts serves the HTML version, so lines wrap exactly the same):
- Hahmlet (variable, weights 500–700) for headlines, numbers in cards and the team initials:
font-serif. - Geologica (variable, weights 300–700) for everything else:
font-sans.
The faces use their real family names, so --font-sans and --font-serif in @theme say "Geologica" and
"Hahmlet" directly. Figures use tabular, lining numerals through the num utility.
To swap a font, put its woff2 file in app/assets/fonts/, change the @font-face rule (file, weight range) and the
family name in @theme.
Content#
Page text is in the page files under app/pages/. Everything data-driven is in app/data/:
| Constant (file) | What it holds |
|---|---|
FIRM (firm.ts) |
name, phone ((423) 555-0138), phoneHref, email (hello@fernleywhitcomb.example), timeZone (America/New_York), refPrefix (FW) |
SERVICES (services.ts) |
seven services keyed by slug: name, short, from (price line), art, intro, included[], forWho[], timeline[{ when, step }], faq[{ q, a }], related[] (slugs) |
SERVICE_ORDER (services.ts) |
display order: individual-tax, business-tax, bookkeeping, payroll, advisory, nonprofit, irs-notices |
DEFAULT_SERVICE (services.ts) |
individual-tax, shown when ?s= is missing or unknown |
TAXPAYER_TYPES (deadlines.ts) |
calendar filters: all, individual, employer, passthrough, ccorp, nonprofit |
DEADLINE_RULES (deadlines.ts) |
24 deadline rules (see Deadline calendar) |
CHECKLISTS, CHECKLIST_ORDER (checklists.ts) |
four lists: individual (11 items), selfemployed (11), business (11), nonprofit (9); item ids are unique |
ESTIMATOR (estimator.ts) |
the fee estimator's prices and limits (see Fee estimator) |
BOOKING (booking.ts) |
consultation slots, days shown, closures, meeting types (see Consultation booking) |
CATEGORIES (insights.ts) |
article categories: all, tennessee, individuals, self-employed, small-business, nonprofits, irs-notices |
ARTICLES (insights.ts) |
seven articles, newest first: slug, title, date (YYYY-MM-DD), cat, author, mins, art, dek, body[] |
The types (Service, DeadlineRule, Checklist, Estimator, Booking, Article) are at the top of each file. The
home page's numbered service list, the pricing packages and the footer service links are written in their files;
when you rename a service, update those too.
Adding a service:
// app/data/services.ts, inside SERVICES
'estate-tax': {
name: 'Trusts & estates',
short: 'Fiduciary returns and estate tax filings for executors and trustees.',
from: 'From $1,200 a return',
art: 'img/services/estate-tax.svg',
intro: '…',
included: ['…'], forWho: ['…'],
timeline: [{ when: 'Month 1', step: '…' }],
faq: [{ q: '…', a: '…' }],
related: ['individual-tax', 'advisory']
},Then add 'estate-tax' to SERVICE_ORDER and put the art in public/img/services/. It appears on /services, in
the booking form's service menu and at /service?s=estate-tax. Article bodies are arrays of blocks: { p: '…' },
{ h2: '…' }, { ul: ['…', '…'] } or { quote: '…' }. Keep ARTICLES newest first: the first one is the featured
article and the fallback.
Images and artwork#
All art is SVG drawn for this template; there are no photos. Image paths in the data are relative to public/
(img/...); components pass them through asset() (useAsset() in app/composables/useAsset.ts), which adds the
base path when the site is served from a sub-folder.
public/img/services/<slug>.svg: 480×300 (8:5). Replace with photos at the same ratio; cards crop withobject-cover.public/img/articles/<name>.svg: 640×400 (8:5); the article page shows it cropped to 16:7.- Team portraits are inline SVG in
app/pages/team.vue(each hasrole="img"and anaria-label). To use photos, replace each<svg>with an<img>at 6:7 (staff) or filling the 13rem column (partners), with real alt text. - The industry icons, hero rings, map and 404 drawing are inline SVG in their pages.
public/img/og-image.svgis the social image source; regenerateog-image.png(1200×630) after editing it (see SEO and sharing).public/img/favicon.svgis the favicon, and the same mark isapp/components/SiteLogo.vue.
Navigation, header and footer#
The header is app/components/SiteHeader.vue: a dark top bar (next deadline, client portal link, phone), the logo,
the main menu, the "Book a consultation" button and the mobile menu (#mobile-menu, toggled by #menu-toggle). Menu
items come from the NAV array at the top of the file; each has also, the other routes that mark it as the current
page (/service marks Services, /article marks Insights), using aria-current="page". To add a page, create
app/pages/<route>.vue whose root is <main id="main" class="flex-1"> and which calls usePageSeo({ title, description }), add the route to routes in nuxt.config.ts and an entry to NAV.
The footer is app/components/SiteFooter.vue. Its copyright year shows 2026 in the generated HTML and the current
Eastern year once the page has loaded.
Features#
Dates and the static build#
The site is generated as static HTML, which can't know the visitor's date or URL. Everything that depends on the
clock, the query string or saved data is read in onMounted():
useNow()(app/composables/useNow.ts) returns a ref with the current Eastern date and time ({ key: "2026-10-30", mins: 600 }), refreshed every 30 seconds and after every navigation, ornullwhile the page is prerendered and hydrating.- Pages that read the address (
?s=,?a=,?cat=,?type=, the estimator) readwindow.location.searchinonMounted().
While the clock is null, components show the text written in the HTML version (for example "Monday, November 2,
2026" in the countdown cards, "this week" for the first open time, 2026 for the year), so the generated HTML
matches the first client render and there is no hydration mismatch; the live values follow at once. The date logic
itself is in app/utils/calendar.ts and never runs during the build.
Deadline calendar#
/deadlines, app/components/DeadlinesBoard.vue, with the logic in app/utils/calendar.ts (deadlines(y),
businessDay(), holidays(y)). Each entry in DEADLINE_RULES is a rule, not a date:
{ id: 'individual', short: 'Individual returns', month: 4, day: 15, rule: 'federal',
who: ['individual'], title: 'Individual returns for {prev}, or an extension; …', forms: 'Form 1040, Form 4868' }For every calendar year the calendar builds the date from month and day, then moves it to the next business day:
rule: 'federal': past Saturdays, Sundays, federal holidays and DC Emancipation Day (April 16, observed on the Friday before or Monday after when it falls on a weekend). That is why, for example, April 15, 2028 (a Saturday) moves to Tuesday, April 18, 2028: Monday April 17 is the observed Emancipation Day.rule: 'tn': past Saturdays, Sundays, federal holidays (not Columbus Day or Emancipation Day) and Good Friday, for Tennessee franchise & excise deadlines.
{prev} and {year} in titles become the previous year and the calendar year. short is the label used in the top
bar and teaser. who lists the TAXPAYER_TYPES it applies to. Moved deadlines show a note such as
"Moved from Sat, Oct 31: weekend." or "Moved from Sat, Apr 15: weekend, then DC Emancipation Day on Mon, Apr 17."
holidays(y) computes, with fixed-date holidays observed on the Friday before a Saturday and the Monday after a
Sunday: New Year's Day, Martin Luther King Jr. Day (third Monday of January), Washington's Birthday (third Monday of
February), Good Friday (from Easter, anonymous Gregorian computus), DC Emancipation Day, Memorial Day (last Monday of
May), Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving (fourth Thursday of
November), the day after Thanksgiving, Christmas Eve and Christmas Day. Which ones move deadlines is set by
HOLIDAY_SETS in calendar.ts. (Inauguration Day, a DC holiday every four years on January 20, never lands on a
deadline in these rules, so it isn't included.)
The calendar opens on the year of the next deadline (the current Eastern year, or the next one once December 15 has passed); the year switch shows the current and the next year. Each entry is marked Passed, Next (the first one still ahead for the chosen filter), "in N days" (red within 30 days) or "in N days". A deadline counts as ahead until midnight Eastern on its due date. The side panel lists that year's holidays.
The filter chips (data-type-filter) and year buttons (data-year-offset="0" / "1") are buttons with
aria-pressed; the count line (data-dl-count) is a polite live region. Choices are written to the URL (?type=,
?year=) with history.replaceState; ?type= must be one of TAXPAYER_TYPES (own keys only), and ?year= this
year or next.
Expected values at 2026-10-30T14:00Z (10:00 AM Eastern, Friday, October 30, 2026):
- Count line: "24 deadlines in 2026 · 4 still ahead".
- Next deadline (everyone): Monday, November 2, 2026, "Third-quarter payroll return", Form 941 · IRS, moved from Sat, Oct 31; countdown 3 days, 14 hours, 0 minutes.
- Then Mon, Nov 16 (extended nonprofit returns, moved from Sun, Nov 15), Tue, Dec 15 (Q4 corporate estimate and Tennessee F&E estimate).
- With
?type=individual: "6 deadlines in 2026 · 0 still ahead"; next is Friday, January 15, 2027, "Fourth-quarter estimated tax for 2026". - In 2026, Jan 31 (Saturday) moves to Mon, Feb 2 and Mar 15 (Sunday) to Mon, Mar 16.
To turn a deadline off, delete its rule. To add a fiscal-year deadline, add a rule with its own month and day.
Countdown and next-deadline line#
Every page's top bar has [data-next-deadline] (in SiteHeader.vue), showing the next deadline for
everyone, for example "Next deadline Mon, Nov 2 · Form 941 (Q3) in 3 days"; before the page has loaded it is a
link to the calendar. The home page card ([data-deadline-teaser] in app/pages/index.vue) shows the next deadline's
date, label, detail, days and hours left, and the three after it. /deadlines has the full countdown
([data-dl-next]: days, hours, minutes until 11:59 PM Eastern on the due date, plus an sr-only sentence for screen
readers; the big numbers are aria-hidden). All of them refresh every 30 seconds, except when ?now= is set.
Document checklist#
/deadlines#checklist, app/components/DocChecklist.vue. Four lists from CHECKLISTS,
switched with the buttons [data-checklist-tab="individual|selfemployed|business|nonprofit"] (or ?list=<id> on
load). Each item is a real checkbox (visually hidden, with a drawn box) inside a <label>. The progress text
([data-check-progress], for example "1 of 11 ready") is a polite live region; the bar is decorative. "Clear this
list" ([data-check-reset]) unchecks the current list only. When every item is checked, [data-check-done] appears.
Checks are saved through safeStorage (see Saved data), so they survive reloads; when the
browser blocks storage they last for the page view. The items appear once the page has loaded and the saved checks
are known.
Fee estimator#
/pricing#estimator, in app/pages/pricing.vue, with the math in app/utils/estimate.ts (estimate(inp)).
Settings in ESTIMATOR:
| Setting | Value |
|---|---|
entities |
sole (return fee $0, extra $0), partnership ($1,440 a year), scorp ($1,560 a year + $40/month shareholder basis and reasonable-salary tracking), ccorp ($1,800 a year), nonprofit ($1,680 a year + $100/month restricted fund and grant tracking) |
tiers |
Foundation up to 100 transactions $350, Standard up to 250 $525, Expanded up to 500 $775, Full-charge up to 1,000 $1,150 |
overBlock, overBlockFee |
above 1,000: $95 for each further 250 transactions or part of 250 |
payrollBase, payrollPerEmployee |
$65 + $9 per employee, when there is at least one employee |
extraStateFee |
$45 a month for each state after Tennessee |
cleanupPerMonth |
$175 per month behind, a one-time fee shown separately |
limits |
transactions 0–2,000 in steps of 25, employees 0–60, states 1–6, cleanup 0–24 months |
defaults |
scorp, 200 transactions, 4 employees, 1 state, 0 months |
Monthly estimate = bookkeeping tier + payroll + extra states + entity extra + annual return fee ÷ 12, rounded to the dollar. The annual line is the monthly figure × 12.
Expected values:
- Defaults: $796/month (Standard $525, payroll for 4 employees $101, tracking $40, annual returns $130), "$9,552 a year", one-time cleanup "None: your books are current".
?entity=nonprofit&tx=1300&emp=12&states=3&cleanup=6: $1,843/month (Full-charge over 1,000 transactions $1,340, payroll for 12 employees $173, 2 additional states $90, restricted fund tracking $100, annual returns $140), cleanup "$1,050 once, for 6 months of catch-up".- Invalid parameters (
?entity=toString&tx=abc) fall back to the defaults: $796.
Once the page has loaded, and after every change, the URL is rewritten (?entity=&tx=&emp=&states=&cleanup=), so an
estimate can be shared (inputFromSearch() reads it back). The fields are filled from the state by fill() rather than
bound, as in the HTML version: a number typed past a limit stays in the field until it loses focus. The result panel is a polite live region. Reset restores
defaults. On wide screens the result panel is sticky and marked data-back-to-top-hide, so the back-to-top button
steps aside instead of covering it.
Consultation booking#
/contact, app/components/BookingForm.vue, with the slot logic in app/utils/calendar.ts (slotsFor(), slotTaken(),
bookingDays()). Settings in BOOKING:
| Setting | Value |
|---|---|
slots |
Monday–Thursday 9, 10, 11 AM, 1, 2, 3, 4 PM; Friday 9, 10, 11 AM, 1, 2 PM; Saturday 9, 10, 11 AM (tax season only). No noon slot: lunch is 12–1 and the grid shows a "Lunch, 12–1" divider |
taxSeason |
02-01 to 04-15 (Saturdays are offered in this window) |
minutes |
30 |
days |
10 open days, starting tomorrow (Eastern) |
demoTaken |
45: the share of slots shown as already booked, stable for each date and time (hash of the date and time) |
closures |
newYear, memorial, juneteenth, independence, labor, thanksgiving, dayAfterThanksgiving, christmasEve, christmas (observed dates) |
meetings |
office, video, phone |
Sundays, Saturdays outside tax season and closure days are skipped, so the grid always shows the next ten open days
and never goes stale. A day whose slots are all taken is disabled ("fully booked" for screen readers).
?service=<slug> preselects a service (own-key check; ?service=not-sure selects "Not sure yet"; anything else
leaves "Choose a service").
The first open time on the home and contact pages (firstOpenSlot() in calendar.ts, inside
[data-first-slot]) is the earliest free slot. At 2026-10-30T14:00Z the days are Nov 2–6 and Nov 9–13, 2026; the
first open time is Mon, Nov 2 at 9:00 AM, and Nov 2 has two free times (9:00 AM and 4:00 PM).
Validation (inline errors with aria-invalid): service required; a time required ("Choose a day and a time.");
name at least two characters; email must look like an address; phone optional but, if given, 10 digits (or 11
starting with 1). On success the form is replaced by [data-booking-success]: "Thank you, <first name>. Your
consultation request is in.", a reference FW- + five characters (a hash of name, email, date and time; for
"Junie Example", "junie@example.com", Nov 2 at 9:00 AM it is FW-BJRFB), the time, service and meeting type, and
what happens next. Focus moves to the success heading.
Office hours (inside [data-office-status]: "Open now" / "Closed now" / "Closed today
for …", worked out by officeStatus() in calendar.ts): Monday–Thursday 8:30 AM–5:30 PM, Friday 8:30 AM–3:00 PM,
Saturdays 9 AM–1 PM from February 1 to April 15. The table is in app/pages/contact.vue.
Service pages#
/service?s=<slug>, app/pages/service.vue. Valid slugs: individual-tax, business-tax,
bookkeeping, payroll, advisory, nonprofit, irs-notices. The slug is checked with hasOwn() (an own-key
check), so ?s=constructor is unknown. Missing → DEFAULT_SERVICE silently; unknown → DEFAULT_SERVICE with the
notice "We couldn't find a service called "constructor", so here is our most-booked one." The page title,
breadcrumb, booking link (/contact?service=<slug>) and related cards follow the chosen service. /services renders
the grid from SERVICES, followed by a dark "Not sure which?" tile that links to /contact?service=not-sure and
the estimator. The generated HTML of /service shows the headline written in the HTML version; the content follows once the
page has read ?s=. A link to another service on the same page ("Often paired with") starts the page afresh at
the top, like a page load (app/plugins/same-page.client.ts).
Insights and articles#
/insights (app/pages/insights.vue): the category chips ([data-cat-filter]) filter ARTICLES; with "All", the newest
article is the large featured card and the other six are in the grid; with a category, all its articles are in the
grid. The count reads "7 articles" or, for example, "2 articles in Small business". ?cat=<key> sets it on load and
is kept in the URL.
/article?a=<slug> (app/pages/article.vue): unknown or missing slugs show the newest article (with a notice when a slug
was given). "Keep reading" lists up to three other articles, same category first. Articles keep their real
publication dates (Feb 9 to Oct 19, 2026); they are past content and never move.
Header, menu, reveal and back to top#
The header is sticky and gains a shadow after scrolling (is-scrolled). The mobile menu (below 1024px) is opened by
#menu-toggle (aria-expanded, aria-controls="mobile-menu"), closes with Escape (focus returns to the button), on a
link click or when the window grows. Elements with .reveal fade in as they scroll into view
(app/plugins/reveal.client.ts; off with reduced motion; everything above the viewport edge is revealed too).
The back-to-top button is app/components/BackToTop.vue; remove it from app/layouts/default.vue (and the
.back-to-top styles) to drop it.
Previewing a date#
Add ?now=YYYY-MM-DDTHH:MM (Eastern wall-clock time) to any page to see the site at that moment: the top bar,
countdowns, calendar, office status, first open time, booking grid and copyright year all use it, and live refresh
stops. Examples: /deadlines?now=2028-04-01T10:00&year=2028 (April 18, 2028 shift), /contact?now=2027-02-25T09:00
(tax-season Saturdays in the grid).
Forms#
The only form that collects details is the consultation request on /contact (form[data-booking] in
app/components/BookingForm.vue). The fee estimator is a form for layout only; it never submits. The demo form
doesn't send anything until you connect it.
To connect it, for example to Formspree, post the form in onSubmit() before showing the success panel (look for
"To connect a real backend"):
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form.value!),
headers: { Accept: "application/json" },
});
if (!res.ok) {
// show an error message and keep the form
return;
}
sent.value = { ... }; // then show the success panel as the demo doesThe form fields are service, meeting, slot_date (YYYY-MM-DD), slot_time (HH:MM, Eastern), name, email,
phone, client (new or existing) and notes. Show the success state only when the service answers OK.
With a server build (pnpm build, see Building and deploying) you can post to a Nuxt
server route instead, for example server/api/consultation.post.ts with
export default defineEventHandler(async (event) => { const body = await readFormData(event); ... }), and pass the
details to your practice management system or email service. A scheduling system with real availability can
replace slotTaken() in calendar.ts.
Saved data (localStorage)#
| Key | Shape | Used by |
|---|---|---|
accrue:checklist |
{ "list": "individual", "ticks": { "individual": ["i-w2"], "selfemployed": [], "business": [], "nonprofit": [] } } |
the document checklist |
On read (readChecklist() in app/components/DocChecklist.vue), list must be an own key of CHECKLISTS, ticks lists that
aren't arrays are ignored, and only item ids that exist in that list are kept, so stale or tampered data can't break
the page. Before each change the saved value is re-read, so two open tabs don't overwrite each other's lists.
All storage goes through safeStorage (app/utils/safeStorage.ts, with readJSON() and writeJSON()): localStorage, or an in-memory stand-in when
the browser blocks storage (sandboxed iframes, strict privacy settings), in which case checks last for the page view.
To reset, use "Clear this list", or remove the key in the browser's developer tools (Application → Local Storage).
Nothing else is stored; the estimator keeps its state in the URL.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph tags (og:localeen_US, site name, the social image) and a Twitter card./serviceand/articlealso update the tab title for the chosen service or article. - Canonical and social image URLs are absolute:
SITE_URL(falling back tohttps://fernleywhitcomb.example) plus the base path. Set it when you build:
SITE_URL=https://www.yourfirm.com pnpm generate- After editing
public/img/og-image.svg, regenerateog-image.png(1200×630) with any SVG-to-PNG tool, keeping the Hahmlet and Geologica fonts available so the text renders.
Suggestions: add a public/robots.txt and a sitemap listing the routes in nuxt.config.ts; give each service and
article its own route if search traffic to them matters (the ?s= and ?a= pages share one route); add
AccountingService structured data (JSON-LD, with useHead()) with your real address and hours.
Accessibility#
- Skip link, landmarks (
header,navwith labels,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines (olive on light, marigold on dark).
- Filter chips, year switch, checklist tabs and booking days and times are buttons with
aria-pressed; booked times are disabled and announced as "booked". - Live regions: deadline count, checklist progress, estimator result and the chosen booking time.
- The countdown digits are
aria-hiddenwith ansr-onlysentence instead, so screen readers aren't flooded. - Form fields have labels,
aria-describedbyerror messages andaria-invalid; focus moves to the first problem. - Reveal animations, smooth scrolling and back-to-top scrolling respect
prefers-reduced-motion. - Text colors meet WCAG AA: use
text-muted(not lighter) for secondary text on paper, and never put marigold text on a light background.
When editing: keep one h1 per page, label every new input, and keep aria-controls/aria-labelledby ids in step.
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 (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 in vercel.json); 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:
DEMO_BASE_PATH=/firm 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). Keep each class name whole
(not built from pieces such as `bg-${color}`), or it won't be generated.
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 calendar shows the wrong year or "Passed" everywhere. The page uses the visitor's clock in Eastern time.
Check for a leftover
?now=in the URL; it freezes the date. - A deadline is on a different date than the IRS announced. Disaster relief and one-off IRS notices can postpone
deadlines for some areas. Those aren't rules; add a note to the page or temporarily change the rule's
monthandday. - The page briefly shows "Monday, November 2, 2026" or "this week". That is the text written for the generated HTML; it is replaced as soon as the page loads. If it stays, check the browser console for a JavaScript error.
- Checklist checks disappear after a reload. The browser is blocking storage (private mode, a sandboxed preview, strict privacy settings); checks then last for the page view only.
- Images or the favicon are missing when the site lives in a sub-folder. Build with
DEMO_BASE_PATHset to the folder, and pass new image paths throughasset()(useAsset()). - The booking grid always starts tomorrow. That is intentional (
bookingDays()starts at tomorrow). To allow same-day bookings, start the loop atnow.keyand skip times already past. - The office closes on a day the grid still offers. Add the holiday id to
BOOKING.closures, or for a one-off closure add a check for that date inslotsFor(). - A link like
/service?s=Payrollshows the fallback. Slugs are lowercase and exact:?s=payroll.
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. Fonts: Hahmlet
and Geologica are licensed under the SIL Open Font License (see app/assets/fonts/Hahmlet-OFL.txt and
app/assets/fonts/Geologica-OFL.txt). Fernley Whitcomb CPAs, its people, clients, testimonials, phone number
and email address are fictional placeholders.