Freeframe

Documentation

Brief

v1.0.0Updated Oct 15, 2026A calm, plain-English website for a solo lawyer or solicitor, with fixed fees, an options checker and booking.

Overview#

Brief is a website template for a solo lawyer, solicitor or attorney. The demo content is for Pennington Employment Law, the fictional Southampton practice of independent employment solicitor Tessa Pennington, who advises employees and senior staff on settlement agreements, redundancy, dismissal, discrimination and problems at work, mostly for fixed fees. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework and no runtime dependencies. It builds to plain static files. This is version 1.0.0 (see CHANGELOG.md). Live demo: freeframe.dev/templates/brief.

The sample copy is general information about employment law in England and Wales, 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 (npm install -g pnpm, or use npm with the same script names).

sh
pnpm install
pnpm dev          # http://localhost:4321, reloads as you edit

Other scripts:

Command What it does
pnpm build Builds the static site into dist/
pnpm preview Serves dist/ locally
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck astro sync && tsc --noEmit with TypeScript 7
pnpm lint ESLint (typescript-eslint and eslint-plugin-astro)

Pages#

File Route Purpose Notable sections and features
src/pages/index.astro / 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
src/pages/services.astro /services Services Eight services with what's included, typical timeline and fee; filter chips by area with live counts and a shareable ?area= URL
src/pages/fees.astro /fees Fees Fee estimator (matter, complexity, VAT, employer contribution, payment plan), full fee table, staged tribunal fees, employer contributions, payment plans and insurance
src/pages/check.astro /check Check your options Branching questionnaire with plain-English results, and a tribunal deadline calculator
src/pages/resources.astro /resources Guides Featured guide, topic filters, search, empty state, shareable ?tag= and ?q= URLs
src/pages/article.astro /article Sample guide Long-form article with breadcrumb, reading progress bar, sticky table of contents, not-legal-advice notices, author box and related guides
src/pages/about.astro /about About Story, three promises, credentials timeline, office illustration and hours, regulation, insurance, complaints and confidentiality (#regulation)
src/pages/book.astro /book Book a call Four-step booking flow with a live summary sidebar, plus a short message form
src/pages/404.astro 404.html Not found Struck-out page illustration and links back into the site

Project structure#

brief/
  astro.config.mjs        site URL, base path, static file output
  package.json            scripts and dependencies
  tsconfig.json           extends Astro's strict preset
  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 harbour illustration on the about page (640 x 460)
  src/
    pages/*.astro         one file per page (index, services, fees, check, resources,
                          article, about, book, 404)
    layouts/Layout.astro  <html>, <head> metadata, reading progress bar, header, footer
                          and the script imports
    components/
      SiteHeader.astro    logo, main menu, phone number, Book button and mobile menu
      SiteFooter.astro    link lists, contact details, the not-legal-advice notice,
                          the regulatory statement and copyright
    data/
      practice.ts         matters and fees, the options check, deadline rules, call types
                          and booking availability
      nav.ts              header, mobile menu and footer links
    lib/site.ts           site name, theme colour and the url() helper
    scripts/
      main.ts             all interactivity, one block per feature
      back-to-top.ts      the back-to-top button
    styles/global.css     Tailwind source: brand tokens (@theme) and component classes
  README.md, CHANGELOG.md, DOCS.md

Customising#

Brand and colours#

All colours are tokens in the @theme block at the top of src/styles/global.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 colour. 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:

css
@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 site.themeColor in src/lib/site.ts.

Typography#

Two Google Fonts are loaded by the <link> in src/layouts/Layout.astro:

  • Literata (--font-serif, used through font-serif and the .display class) for headings, prices and quotes.
  • Albert Sans (--font-sans) for everything else.

To swap them, change the Google Fonts URL in the layout and the two --font-* tokens in src/styles/global.css. Both tokens include system fallbacks, so the site still reads well if the fonts fail to load.

Content#

Most text lives directly in the page files in src/pages/. The data that drives the interactive features lives in src/data/practice.ts:

Export What it holds Used by
MATTERS Each matter's name, area, timeline, whether an employer contribution applies, three levels ([min, max] in pounds, excluding VAT) and three hints Fee estimator, options check results, booking topics
LEVELS ["straightforward", "standard", "complex"], the ?level= values Fee estimator
VAT_RATE 0.2 Fee estimator
PLAN { minFee: 1000, months: 3 } Payment plan in the estimator
CHECK_STEPS The questions of the options check Options check
CHECK_RESULTS The 15 result screens Options check
CHECK_KEY "brief-check-v1", the localStorage key Options check
DEADLINE_RULES Claim types and their time limits in months Deadline calculator
CALL_TYPES Booking call types: label, minutes, price, where Booking flow
AVAILABILITY Time zone, weekly hours, notice, closed days Booking flow

The file also exports the TypeScript types (Matter, CheckStep, CheckResult, DeadlineRule, CallType, Availability), so a typo in a new entry shows up in pnpm check and your editor.

To add a matter, add an entry to MATTERS:

ts
holiday: { name: "Holiday pay claim", area: "Claims and disputes", timeline: "1–2 weeks", contribution: false, levels: [[300, 300], [450, 450], [600, 900]],
  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. Also add a card to src/pages/services.astro (copy an existing <li id="..."> and give it the same key as its id) and a row to the fee table in src/pages/fees.astro, so the static pages match the estimator.

Services on the services page are plain markup. Each card is an <li> with an id (used by links such as url("/services#settlement")) and a data-tags value naming its area (leaving, at-work, disputes or new-job). Guides on the resources page are <li data-guide data-tags="..." data-text="..."> items; data-text holds extra search words that aren't visible on the card.

Text in .astro files is HTML: entities such as &rsquo; and &pound; work as they are. A literal { or } in text must be written as {"{"} or {"}"}, because braces start an expression.

Images and artwork#

All artwork is SVG, so it stays sharp and light. Files in public/ are copied to the site as they are, and pages reference them with url(), for example src={url("/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 (or the src); the object-cover object-top classes 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 every icon.
  • public/img/og-image.svg is the source of the social image. Social networks need a PNG, so export it to public/img/og-image.png at 1200 x 630 after editing (any vector editor or a headless browser).
  • public/img/favicon.svg is the favicon.

The header and mobile menu live in src/components/SiteHeader.astro, the footer in src/components/SiteFooter.astro, and their links in src/data/nav.ts:

Export Where it shows
MAIN_NAV The desktop menu
MOBILE_NAV The mobile menu: Home, MAIN_NAV and the booking page
FOOTER_HELP Footer, "Help with" (the services page filtered with ?area=)
FOOTER_PRACTICE Footer, "Practice"

Each link has a label, an href written without .html ("/fees", "/about#regulation") and, for header links, a page name. A page passes current to <Layout> (current="fees"), and the header links whose page matches get aria-current="page", which also draws the highlighter underline. The article page passes current="resources", so Guides stays highlighted.

To add a page, create src/pages/<name>.astro with a <Layout title="..." description="..." current="<name>"> wrapper, then add it to MAIN_NAV (it is added to the mobile menu automatically) and, if you like, to a footer list. Always link with url("/<name>") so the link works under a base path.

The footer holds the not-legal-advice notice and the regulatory statement. Keep both, and replace the placeholder registration number with your own.

Features#

All interactivity is in src/scripts/main.ts, imported once by the layout; each feature only runs on pages that have its markup.

Fee estimator#

On the fees page (#estimator), inside [data-estimator]. Code: initEstimator().

  • The matter list is built from MATTERS. The three level cards (input[name="est-level"], values 0-2) show the matter's hints.
  • When min equals max the result shows a fixed fee with a "Fixed fee" tag; otherwise a range with a "Range: fixed in writing before work starts" tag.
  • Show prices including VAT multiplies by 1 + VAT_RATE. The breakdown always lists the fee, VAT and the total including VAT.
  • My employer is contributing only appears for matters with contribution: true (settlement agreements and exit negotiations). The amount (default 500, excluding VAT) is deducted from the fee, never below zero.
  • I'd like to spread the cost shows PLAN.months equal payments when the amount you pay (after any contribution, excluding VAT) is at least PLAN.minFee, and otherwise explains why it isn't available.
  • The matter and level are written to the URL (/fees?matter=tribunal&level=complex), so links such as "Estimate this fee" on the services page open the right estimate. Unknown values fall back to a settlement 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#

On the check page, inside [data-check]. Code: initCheck(), replayCheck() and readCheck().

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 that preselects the right claim type in the calculator, 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:

ts
"tupe-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#

On the check page (#deadline), inside [data-deadline]. Code: deadlineFrom() and the block after it.

It applies the "three months less one day" rule: the last day is the day before the same date three months later (15 March gives 14 June). If the later month has no such date (31 August gives 30 November), the last day is the final day of that month. DEADLINE_RULES sets the months per claim type; statutory redundancy pay uses six. 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 early conciliation extensions, continuing acts or late-claim discretion, and it doesn't store anything.

Booking flow#

On the book page, inside [data-booking]. Code: initBooking().

  1. Call type from CALL_TYPES. /book?type=advice preselects one.
  2. Date and time: a month calendar (Monday first) and time slots generated from AVAILABILITY: weekly hours windows in UK time, slotStep minutes apart, at least minNoticeHours from now, up to daysAhead days ahead, skipping closed dates. demoBusyShare hides a stable share of slots so the demo looks realistic; set it to 0 when you connect a real calendar.
  3. Details: name, email, optional phone, topic (/book?topic=settlement preselects it), the conflict-check fields (employer, required; anyone else involved, optional), an optional deadline, a short description and a required acknowledgement that booking doesn't create a client relationship.
  4. Confirmation with a summary and a Save a calendar hold (.ics) button.

Times are always generated in Europe/London, so they follow UK daylight saving. 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 20 October 2026, 9 times available". Each step's heading receives focus when the step opens.

  • Services ([data-filter-root] on the services page): chips with data-filter show cards whose data-tags contain the value, update the counts and the "8 services shown" status, and keep the choice in ?area=.
  • Guides ([data-guides] on the resources page): topic chips (data-tag) and the search box combine. Search matches every word against the card text and its data-text. 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, the layout's progress prop adds [data-progress], 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 TOC link. To use the bar on another long page, pass progress to <Layout> and wrap the content in data-article.

FAQ accordion, scroll reveals and motion#

Accordion buttons (.acc-btn) toggle the panel named in aria-controls and update aria-expanded. Elements with .reveal fade in as they scroll into view, and the hero highlighter (.draw-mark) draws in once. With "reduce motion" turned on, everything appears immediately. The layout adds the js class to <html> in an inline script, so content is only hidden for the reveal when JavaScript runs.

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. The back-to-top button (src/scripts/back-to-top.ts) 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 import in src/layouts/Layout.astro and the .back-to-top styles in src/styles/global.css.

Forms#

There are three forms. None of them sends anything until you connect it.

Form Where Validates
Booking details ([data-booking-form]) Book page, step 3 Name, email, optional phone (10+ digits), topic, employer, optional deadline, description (20+ characters), acknowledgement
Message ([data-contact-form]) Book page Name, email, message (20+ characters)
Deadline calculator ([data-deadline]) Check page A date within the last two years or the next six months

Validation runs on submit and again as fields are corrected. Each field declares its rule with data-validate (required, email, phone-optional, message, consent, date-event). Errors appear under the field, 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 the [data-contact-form] submit handler in src/scripts/main.ts:

ts
fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(cf),
  headers: { Accept: "application/json" },
})
  .then((res) => {
    if (!res.ok) throw new Error("Send failed");
    // show the success panel here
  })
  .catch(() => {
    alert("Sorry, your message could not be sent. Please email or call instead.");
  });

Move the lines that hide the form and show [data-success] into the then callback, and change the success text from "ready to send" to "sent". The booking form has the same marked comment in its submit handler.

Taking real bookings#

The simplest route is a scheduling tool such as Cal.com or Calendly: replace the step 2 calendar in src/pages/book.astro 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, using new FormData(form)) and add the chosen slot:

ts
const data = new FormData(form);
data.append("call_type", state.type);
data.append("start", state.slot.at.toISOString());

Set AVAILABILITY.demoBusyShare to 0 in src/data/practice.ts, and keep closed up to date with your holidays.

Because enquiries 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 src/: 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, readCheck() 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 src/data/practice.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. Nothing else is stored, and nothing is sent anywhere.

SEO and sharing#

Each page passes title and description to <Layout>. The layout outputs the meta description, a canonical link, Open Graph tags (og:title, og:description, og:type, og:site_name, og:url, og:image), twitter:card and theme-color. 404.astro passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical.

Canonical, og:url and og:image are absolute URLs built from site in astro.config.mjs. Set your domain when building:

sh
SITE_URL=https://www.example.com pnpm build

or change the fallback (https://brief.example.com) in astro.config.mjs.

Suggestions: add @astrojs/sitemap (or a hand-written public/sitemap.xml) and a public/robots.txt, and add LegalService structured data (schema.org JSON-LD) to index.astro with your real name, address, phone and opening hours. The template doesn't include structured data because it would describe a fictional practice. Check your regulator'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; colours meet WCAG AA (body text slate on paper is 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-motion turns 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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as fees.html rather than fees/index.html (build.format: "file" in astro.config.mjs), and links point to /fees without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /fees from fees.html automatically; Vercel does with cleanUrls (below). 404.html is served for unknown URLs by most hosts.

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /fees serves fees.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /fees to fees.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, for example DEMO_BASE_PATH=/brief pnpm build. It becomes Astro's base, and every link and image written with url() includes it, including the links the scripts write (the estimator's "Book a free call about this" and "What's included" links and the options check's recommendations).

Tailwind scans src/ for class names, including the strings in src/scripts/main.ts. Classes that only appear inside TypeScript strings must be written out in full (for example "tag tag-amber"), not assembled from pieces.

demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.

Troubleshooting#

  • New Tailwind classes have no effect. Make sure the class appears in full in a file under src/. Restart pnpm dev if you added a new folder.
  • The fee estimator, check or booking shows nothing. Check the browser console and run pnpm check. A mistake in MATTERS, CHECK_STEPS or CALL_TYPES stops main.ts.
  • An option in the check leads nowhere. Every next must name a key of CHECK_STEPS, and every result a key of CHECK_RESULTS.
  • The check keeps showing an old result. It's saved in localStorage. Click Start again, or change CHECK_KEY after changing the questions.
  • The booking calendar has no free days. Check AVAILABILITY.hours (day 0 is Sunday), minNoticeHours, daysAhead and closed, and that demoBusyShare is below 1.
  • Booking times look an hour out. Slots are in Europe/London and follow UK daylight saving. Change AVAILABILITY.timeZone and zoneName if you practise elsewhere.
  • Links or images break in a sub-folder. Write internal links and public/ paths with url() (href={url("/fees")}), never as a bare "/fees", and build with DEMO_BASE_PATH.
  • The social preview has no image. Build with SITE_URL set to your real domain so og:image is an absolute https:// URL to og-image.png.

License#

MIT, like every Freeframe template: free for personal and commercial use. See LICENSE.txt in the download. The SVG illustrations, portrait and icons are original artwork included under the same license. The practice, people, reviews and fees are fictional.

Search documentation

Search the guides and every template's documentation