Freeframe

Documentation

Studio

v1.0.1Updated Nov 13, 2026A minimal, Swiss-style website for a small design studio, with a filterable work index, case studies and a brief estimator.

Overview#

Studio is a complete website for a small design studio, built for the fictional Raster, a five-person studio in Saint Paul that works across identity, digital product, editorial and wayfinding. The design is deliberately restrained: a strict 12-column grid, one typeface family in several weights plus a mono face for labels, a monochrome palette and a single red accent. Project imagery is drawn in SVG, so there are no photos to license. It has a work index with filters and grid/list views, nine case studies rendered from typed data, a services page with a brief estimator, a studio page, a journal and a five-step project inquiry. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4, generated as a static site), version 1.0.1. See it live at freeframe.dev/templates/studio.

Quick start#

Requirements: Node.js 24 and pnpm.

sh
pnpm install
pnpm dev

Open http://localhost:3000. Pages reload as you edit. Other scripts:

Script What it does
pnpm generate Static site in .output/public/
pnpm preview Serve the last build locally
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm lint ESLint
pnpm typecheck nuxt prepare and vue-tsc

Pages#

Route File What's on it
/ app/pages/index.vue Typographic hero with availability, selected work with hover labels, services list, client wordmarks, count-up numbers, journal teaser, contact call to action
/work app/pages/work.vue All nine projects with discipline filters, year sort, live count, grid/list view toggle and a hover/focus preview in list view
/project?id=<id> app/pages/project.vue Case study: facts, cover, overview, challenge, three artwork images, approach, results, quote, credits, previous/next project
/services app/pages/services.vue Four disciplines with deliverables, timelines and starting prices, three engagement models, the brief estimator and an FAQ
/studio app/pages/studio.vue Story, floor plan of the studio, values, process timeline, team, open roles and visiting details
/journal app/pages/journal.vue Eight short notes with topic filters, expandable text and a newsletter sign-up
/contact app/pages/contact.vue Five-step project inquiry with a review step, plus email, phone and studio hours with a live open/closed note
404 app/error.vue "Off the grid" page with links back into the site

Project structure#

studio/
├─ app/
│  ├─ app.vue                  the layout and the current page (keyed so a link to the same page starts it afresh)
│  ├─ error.vue                the 404 page (generated as 404.html)
│  ├─ router.options.ts        writes query strings the way the HTML version does (commas kept)
│  ├─ layouts/default.vue      skip link, header, page, footer, grid overlay, back to top
│  ├─ pages/                   one .vue file per page (index, work, project, services, studio, journal, contact)
│  ├─ components/
│  │  ├─ SiteHeader.vue        sticky header, current page, mobile menu
│  │  ├─ SiteFooter.vue        footer with "Copy email", the copyright year and the "Show grid" toggle
│  │  ├─ GridOverlay.vue       the 12-column grid laid over the page
│  │  ├─ CopyEmail.vue         copy-to-clipboard button
│  │  ├─ StudioClock.vue       the Saint Paul clock in the header and mobile menu
│  │  ├─ MonthsAhead.vue       rolling dates ("From Feb 2027")
│  │  ├─ CaseStudy.vue         one case study from a project
│  │  ├─ BriefEstimator.vue    the brief estimator on /services
│  │  ├─ ProjectEnquiry.vue    the five-step project inquiry on /contact
│  │  ├─ NewsletterForm.vue    the journal's sign-up form
│  │  ├─ BackToTop.vue         back-to-top button
│  │  └─ ArrowIcon.vue, TickIcon.vue   small icons
│  ├─ composables/
│  │  ├─ usePageSeo.ts         per-page title, description, canonical URL and Open Graph tags
│  │  ├─ useCurrentPage.ts     aria-current for menu links
│  │  ├─ useNow.ts             the current time, once the page runs in the browser
│  │  └─ useAsset.ts           files in public/ with the base path
│  ├─ data/
│  │  ├─ projects.ts           DISCIPLINES and PROJECTS (the case studies)
│  │  ├─ estimator.ts          SCOPE, SCALE, URGENCY, BUDGETS, DEFAULT_BRIEF (the price model)
│  │  ├─ enquiry.ts            the inquiry's steps, timelines and "How did you hear" options
│  │  ├─ journal.ts            TAGS and NOTES
│  │  ├─ services.ts           SERVICES, ENGAGEMENTS, FAQ
│  │  ├─ studio.ts             VALUES, PROCESS, TEAM, ROLES
│  │  ├─ home.ts               SELECTED work, CLIENTS and NUMBERS on the home page
│  │  └─ site.ts               name, contact details, NAV, STUDIO_HOURS
│  ├─ utils/
│  │  ├─ art.ts                ART: the generated case study artwork (board, screens, spread, poster, signage, mark)
│  │  ├─ estimate.ts           estimateBrief(), briefLink(), the saved brief (readBrief())
│  │  ├─ validate.ts           form validation messages, ARIA helpers and the press guard
│  │  ├─ studioTime.ts         Saint Paul time, opening hours and monthsAhead()
│  │  ├─ format.ts             thousands(), plural(), makeRef(), setParams()
│  │  └─ safeStorage.ts        localStorage with an in-memory fallback
│  ├─ plugins/
│  │  ├─ effects.client.ts     scroll reveals, headline lines, process bars, count-ups
│  │  ├─ clock.client.ts       starts the clock and moves it on every 15 seconds
│  │  ├─ same-page.client.ts   links to the page already showing start it afresh, at the top
│  │  └─ stable-build.server.ts  reproducible builds
│  └─ assets/
│     ├─ css/main.css          Tailwind entry: @font-face, @theme tokens and component classes
│     └─ fonts/                woff2 files and their SIL Open Font License texts
├─ public/img/
│  ├─ work/*.svg               project covers, one per project id (800×600)
│  ├─ favicon.svg              browser tab icon
│  ├─ og-image.svg             editable source of the social sharing image
│  └─ og-image.png             1200×630 social sharing image
├─ nuxt.config.ts              base path, head defaults, SITE_URL, prerendered routes, Tailwind
├─ eslint.config.mjs, tsconfig.json, package.json, pnpm-workspace.yaml
└─ README.md, DOCS.md, CHANGELOG.md

Components, composables and utils are imported automatically; data files are imported by path (import { PROJECTS } from "~/data/projects").

Customizing#

Brand and colors#

All design tokens live in the @theme block of app/assets/css/main.css:

Token Value Used for
--color-paper #f6f6f3 Page background
--color-sheet #ffffff Raised surfaces: the estimator panel and the inquiry form
--color-tint #ecece7 Quiet fills and image backgrounds
--color-rule #d6d6cf Hairlines between rows and sections
--color-faint #8b8b85 Decorative lines only (too light for text)
--color-mute #5b5b56 Secondary text (WCAG AA on paper and sheet)
--color-ink, --color-ink-2 #121211, #2a2a28 Text, buttons, dark sections
--color-signal #e8381e The accent, for small fills: dots, squares, bars
--color-signal-ink #c02a12 Accent text on light backgrounds and error messages

The accent is used sparingly on purpose: the square in the logo, the dot before labels and the current menu item, the + and % in the numbers, and error markers. To change it, edit both signal tokens:

css
@theme {
  --color-signal: #2b50ff;
  --color-signal-ink: #1f3ad1;
}

The accent also appears as a hex value in the logo in app/components/SiteHeader.vue, public/img/favicon.svg, public/img/og-image.svg, the floor plan in app/pages/studio.vue and the 404 drawing in app/error.vue. Search for e8381e to find them. The theme-color is in nuxt.config.ts. Each project has its own palette for its artwork (see Content).

Useful component classes in main.css:

Class What it does
.wrap The page container (max-w-[90rem] with side padding)
.grid-12 The grid every section sits on: 4 columns on phones, 12 from sm
.label, .idx, .dot Small uppercase mono labels, index numbers and the red square
.display, .h-section, .lead Display headings, section headings and lead paragraphs
.btn + .btn-ink, .btn-line, .btn-line-light, .btn-sm Square-cornered buttons
.link-line, .link-under An underline that draws in on hover, and a quiet permanent underline
.chip, .seg, .seg-btn Filter chips and the grid/list segmented control
.input, .field-label, .field-hint, .field-error, .choice, .choice-box Form fields and choice tiles
.acc, .acc-icon Native <details> accordions with a plus/minus icon
.cover An image frame that zooms its image slightly on hover
.work-list, .work-item, .work-link, .work-row… The two layouts of the work index (see Work index)
.reveal, .rise, .fill-bar Scroll reveal, headline line reveal and the process bars

Typography#

Two fonts are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css (the same Latin woff2 files Google Fonts serves, so text sets exactly as in the HTML version):

  • Host Grotesk (variable, 300–700, plus italic 400) for everything: --font-sans. Headings use weight 500 with tight negative letter-spacing; the team monograms use 300.
  • Fragment Mono for labels, index numbers and dates: --font-mono.

Each font keeps its real family name, and each token has a system fallback stack (Helvetica, Arial), so the site keeps its character while the fonts load. To swap a font, add its woff2 file to app/assets/fonts/, point an @font-face rule at it and change the token:

css
@font-face {
  font-family: "Inter Tight";
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url("../fonts/InterTight-latin.woff2") format("woff2");
}
@theme {
  --font-sans: "Inter Tight", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}

The generated case study artwork names the fonts in the SANS and MONO constants in app/utils/art.ts; change those too.

Content#

Page copy lives in the page files under app/pages/. Everything that repeats is data in app/data/:

File Data
projects.ts DISCIPLINES, PROJECTS: every case study, the work index and the home page cards
estimator.ts SCOPE, SCALE, URGENCY, BUDGETS, DEFAULT_BRIEF (see Brief estimator)
enquiry.ts STEPS, ENQUIRY_TIMELINES, SOURCES (services and budgets come from estimator.ts)
journal.ts TAGS (the topic filters) and NOTES
services.ts SERVICES (the four disciplines, also listed on the home page), ENGAGEMENTS, FAQ
studio.ts VALUES, PROCESS, TEAM, ROLES
home.ts SELECTED (the four projects on the home page), CLIENTS, NUMBERS
site.ts Name, email, phone, address, social links, NAV and STUDIO_HOURS

Projects. A project in PROJECTS looks like this (shortened):

ts
{
  id: "seeline", // /project?id=seeline and the cover file name
  client: "Wakeline",
  title: "A calmer identity for the lake ferries",
  disciplines: ["identity", "wayfinding"], // keys of DISCIPLINES
  year: 2025,
  sector: "Public transport",
  location: "Excelsior",
  duration: "14 months",
  services: ["Identity system", "Wayfinding", "Schedule design", "Livery"],
  summary: "…",
  challenge: "…",
  approach: [{ title: "Audit every touchpoint", text: "…" }], // three steps
  results: [{ value: "−38%", label: "…" }], // three results
  quote: { text: "…", name: "Andrea Lutz", role: "Head of customer experience, Wakeline" },
  credits: ["Noemi Imhof", "Selin Karaca", "Céline Odermatt"],
  palette: { bg: "#1d3b57", ink: "#f1efe7", accent: "#ff7a3d", soft: "#8fb3c9" },
  mark: '<circle cx="50" cy="50" r="38" …/>', // SVG in a 100×100 box, filled with currentColor
  poster: { lines: ["Summer", "schedule"], sub: "May 1 – Sep 30, 2025" },
  sign: ["3", "Departures", "Wayzata · Excelsior · Tonka Bay"],
  gallery: [
    { kind: "board", caption: "…" }, // the first image is wide (16:9)
    { kind: "signage", caption: "…" }, // the next two are tall (4:5)
    { kind: "poster", caption: "…" },
  ],
}

Gallery kind values: board, screens and spread are wide; poster, signage and mark are tall. screens reads ui: { headline, figure }, spread reads spread: { title, sub }, poster reads poster and signage reads sign (a large number or letter, a title and a small line). The Project type in projects.ts documents every field.

To add a project, add an object to PROJECTS with a new id and add public/img/work/<id>.svg. The work index, its counts, the "(9)" after the heading, the case study page and the previous/next links all follow from the array. The array order (newest first) is the order of the previous/next links and breaks ties in the year sort. The four projects on the home page are listed by id in SELECTED in app/data/home.ts.

Disciplines. DISCIPLINES maps the filter keys to labels: identity, digital, editorial, wayfinding. Add a key there and the work index gets a chip for it.

Journal notes. Each note in NOTES has an id (its anchor is #note-<id>), a date, tags (keys of TAGS), a title, a one-line dek and a body with one entry per paragraph. A paragraph is a string, or a list of pieces when it contains code or a link:

ts
body: [
  "Plain paragraph.",
  ["Our first design system for ", link("/project?id=fondo", "Fondo"), " had 214 color tokens: ", code("surface"), "."],
],

The first three notes appear on the home page, labeled with their first tag. Chip counts are worked out from the data.

Count-up numbers. The home page numbers are NUMBERS in app/data/home.ts, rendered as <span data-count="140">140</span>. The markup keeps the final value: that is what visitors see without JavaScript or with reduced motion.

Team, roles and services. Edit TEAM, ROLES (in studio.ts) and SERVICES (in services.ts). The team "portraits" are monogram tiles; tile sets their colors. Replace the tile <div aria-hidden="true"> in app/pages/studio.vue with an <img> at 4:5 if you have photos.

Rolling dates. The availability dates use <MonthsAhead> (app/components/MonthsAhead.vue), which shows the month n months from now and its year: "From Feb 2027" and "Booking projects from February 2027" on the home page (4), and in ROLES each job's start ("From January 2027", 3, and "From March 2027", 5) and the internship's closes ("December 15, 2026", 2, on the 15th). The generated HTML shows the text written in the page and the browser the rolling date, so they never go stale. For fixed dates, write them as plain text instead.

Images and artwork#

There are no photos; all artwork is SVG.

Artwork Size / ratio Notes
public/img/work/*.svg 800×600 (4:3) Project covers in the grid, list preview, home page and case study. Replace with 4:3 images, at least 1600×1200
Case study gallery 1600×900 and 800×1000 Drawn by the ART functions in app/utils/art.ts from each project's palette and mark
Floor plan, 404 drawing Inline SVG app/pages/studio.vue, app/error.vue
public/img/og-image.png 1200×630 Social image. Edit og-image.svg, then export it to PNG at 1200×630
public/img/favicon.svg 32×32 Browser tab icon

Files in public/ are referenced through useAsset() (app/composables/useAsset.ts), which adds the base path when the site is built for a sub-folder. Keep using it for new images. To show photos in a case study gallery instead of generated artwork, add a src to each gallery item and render an <img> in place of the v-html drawing in app/components/CaseStudy.vue:

html
<img :src="asset(g.src)" :alt="g.caption" class="block h-auto w-full">

Images in .cover frames use object-cover, so other ratios still fit. Covers in cards have an empty alt because the link text names the project.

The menu items are NAV in app/data/site.ts; the header (SiteHeader.vue), the mobile menu and the footer index all render from it. The current page gets aria-current="page" (useCurrentPage()), which draws the red square; on a case study, Work gets aria-current="true" because it is inside that section.

The footer (SiteFooter.vue) has the contact call to action, address, an index of pages, external links, a large aria-hidden "Raster." wordmark, the copyright line (the year updates in the browser), the "Show grid" toggle and a "Back to top" link.

Features#

Work index#

app/pages/work.vue renders the toolbar and the list. Each project is an <li data-project data-order data-year data-disciplines> inside <ul class="work-list" data-view="grid">.

  • Filter. Chips with data-filter="all|identity|digital|editorial|wayfinding" set aria-pressed; their counts come from the data. The choice is kept in the URL as ?discipline=identity, so filtered views can be shared; unknown values fall back to all.
  • Sort. The sort <select> sorts by year, newest (default) or oldest, kept as ?sort=oldest. Projects with the same year keep their order in PROJECTS.
  • Count. [data-work-count] (aria-live="polite") reads "Showing all 9 projects" or "Showing 3 of 9 projects".
  • View. The Grid and List buttons switch data-view on the list; the choice is saved in localStorage (studio-work-view). Both layouts are pure CSS on the same markup (.work-list[data-view=…] rules in main.css).
  • Preview. In list view at 768 px and wider, [data-work-preview] shows the project's cover next to the pointer. When a row receives keyboard focus, the preview appears beside that row. It hides on blur, on scroll and with Escape, is aria-hidden and never blocks the pointer. On phones the list shows a small thumbnail instead.
  • Empty state. If a discipline has no projects, "No projects in this discipline yet." appears with a button that shows all projects again.

The generated HTML lists every project in the default order; the filters from the URL and the saved view apply once the page runs. setParams() in app/utils/format.ts keeps the URL in step without adding history entries.

Case study#

/project?id=<id> shows the matching PROJECTS entry, rendered by CaseStudy (app/components/CaseStudy.vue). The generated page contains the first project's case study, artwork included; in the browser app/pages/project.vue picks the project from ?id= and keeps the tab title and meta description in step. Without ?id= it shows the first project; an unknown id shows "We couldn’t find that project." as the page's <h1>. Previous and next links wrap around the PROJECTS array and open the next case study at the top of the page. Each gallery image is an inline SVG with role="img" and its caption as the accessible name.

Search engines see the first case study only. If case studies matter for SEO, turn them into real routes: move the page to app/pages/work/[id].vue, read useRoute().params.id, list every /work/<id> in routes in nuxt.config.ts, and update the links that point to /project?id=.

Brief estimator#

BriefEstimator (app/components/BriefEstimator.vue) on /services#estimator. Its price model is in app/data/estimator.ts:

ts
export const SCOPE = {
  strategy: { label: "Brand strategy", note: "Positioning, audiences, messaging", cost: [12000, 18000], weeks: [3, 4] },
  identity: { label: "Identity system", note: "Mark, type, color, guidelines", cost: [28000, 45000], weeks: [6, 9] },
  // …
};
export const SCALE = { focused: { label: "Focused", note: "…", mult: 0.75, pace: 0.8 } /* standard, extensive */ };
export const URGENCY = { relaxed: { label: "Relaxed", note: "…", mult: 0.95, pace: 1.2 } /* standard, priority */ };

estimateBrief() in app/utils/estimate.ts does the sums:

  • Budget = the sum of the selected services' cost × SCALE.mult × URGENCY.mult, low rounded down and high rounded up to $1,000, shown with a comma thousands separator ($63,000–$105,000).
  • Duration = the longest selected service plus half of the others (phases overlap), × SCALE.pace × URGENCY.pace, rounded to whole weeks.
  • Breakdown lists each selected service and the combined multiplier.
  • Send this brief links to /contact?services=identity,website&budget=60-120&timeline=standard (briefLink()). The budget band is picked from the midpoint of the range using BUDGETS.

The choice tiles, their price hints ("$12–18k") and the breakdown are all generated from this data. The default selection is DEFAULT_BRIEF. Choices are saved in localStorage (see Saved data); Reset restores the defaults. Results are announced through a visually hidden aria-live region.

Project inquiry#

ProjectEnquiry (app/components/ProjectEnquiry.vue) on /contact has five steps: Project, Budget and timeline, Details, About you and Review (STEPS in app/data/enquiry.ts). "Next" validates the current step before moving on; the step tabs above the form go back to any step already reached and are disabled beyond it. Moving between steps focuses the new step's heading and announces "Step 2 of 5: Budget and timeline" through [data-step-live]. The Review step lists every answer with an "Edit" button that jumps to the right step. The details field shows a live character count ("40 more characters needed"). Sending re-checks every step and jumps to the first one with a problem.

It is prefilled from the estimator link: ?services= (comma-separated SCOPE keys), ?budget= (under-30, 30-60, 60-120, 120-plus) and ?timeline= (relaxed, standard, priority). When anything is prefilled, a note says so.

Journal filters and notes#

app/pages/journal.vue. Topic chips (data-tag) filter the notes, kept in the URL as ?tag=process; [data-note-count] announces the result ("Showing all 8 notes", "Showing 4 of 8 notes"). Each note's "Read note" button toggles aria-expanded and the hidden body. Opening /journal#note-figures (as the home page teasers do) expands that note and scrolls to it, clearing a filter that would hide it; changing the hash on the page does the same.

Clock, studio status and small extras#

  • Saint Paul clock. <StudioClock> renders <time data-clock> with the current time in Saint Paul. The time comes from useNow(), which app/plugins/clock.client.ts starts once the page runs and moves on every 15 seconds. The generated HTML shows "CT" until then, so the server and the browser never disagree.
  • Studio status. The note under the studio hours on /contact says whether the studio is open now, using STUDIO_HOURS in app/data/site.ts (days with 0 = Sunday, open and close in 24-hour decimals, tz) and studioOpen() in app/utils/studioTime.ts. Keep it in step with the hours written on the page.
  • Copy email. <CopyEmail> copies the address and briefly shows "Copied". The label sits in an aria-live region. Browsers only allow copying on https:// or localhost; elsewhere the label asks the visitor to copy it by hand.
  • Grid overlay. The footer's "Show grid" button (aria-pressed) overlays the 12-column grid in translucent red (GridOverlay.vue), handy while you edit layouts. Remove the button from SiteFooter.vue and <GridOverlay /> from app/layouts/default.vue to drop it.

Reveals and motion#

app/plugins/effects.client.ts runs on every page: .reveal elements fade up as they scroll into view; .rise headings lift each line into place (write them as <span><span>…</span></span>, one per line); .fill-bar bars on the studio page grow from the left; numbers with data-count count up. Everything above a revealed element is revealed too, so fast jumps never leave hidden content. With prefers-reduced-motion: reduce all of this is off and content is shown immediately. Without JavaScript nothing is hidden (the styles only apply under html.js, set by a one-line script in nuxt.config.ts). The plugin adds classes with classList, so keep .reveal off elements whose class changes with a :class binding.

Header, mobile menu and back to top#

The header is sticky. Below 1024 px the "Menu" button (#menu-button, aria-expanded, aria-controls) opens a full-height menu and stops the page behind it from scrolling; Escape or a link closes it and Escape returns focus to the button. BackToTop.vue shows a button after you scroll about one screen; it hides while the footer's "Back to top" link is visible. 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 move between pages without a reload, and app/plugins/same-page.client.ts keeps the HTML behavior where it matters: a link to the page already showing (the menu link on a filtered work index, "Start a project" halfway through the inquiry, the next case study) starts that page afresh at the top.

Forms#

There are two forms. Neither sends anything until you connect it, although their success messages read as if it had. The inquiry confirmation shows a short reference (RA- and four characters, made in the browser by makeRef() in app/utils/format.ts; show your form service's own reference instead if it returns one) and repeats the reply time the contact page already promises (within two business days, usually to suggest a 30-minute call). The newsletter confirmation asks the visitor to look out for a confirmation email. Change either if yours differs.

Form Component Validates
Project inquiry app/components/ProjectEnquiry.vue At least one service; organization; a budget and a timeline; project details of at least 40 characters; an optional link that must start with http:// or https://; name; a valid email; the consent box
Newsletter app/components/NewsletterForm.vue A valid email

The rules and messages are in app/utils/validate.ts (fieldError()) and RULES at the top of ProjectEnquiry.vue. Errors appear under each field (.field-error), are linked with aria-describedby and mark the field aria-invalid="true"; the first invalid field receives focus. A field that shows an error re-checks as it is corrected. Fields also check themselves when you leave them, except while Next, Back, Send or Subscribe is being pressed (pressGuard() in validate.ts): those buttons check the form anyway, and an error appearing mid-click would push the button away and lose the click.

Connecting to Formspree#

  1. Create a form at formspree.io and copy its endpoint, for example https://formspree.io/f/abcdwxyz.

  2. In ProjectEnquiry.vue, find submit() (look for "Demo only: nothing is sent") and post the answers before showing the success message. values holds every answer:

    ts
    const res = await fetch("https://formspree.io/f/abcdwxyz", {
      method: "POST",
      body: JSON.stringify({ ...values, services: values.services.join(", ") }),
      headers: { "Content-Type": "application/json", Accept: "application/json" },
    });
    if (!res.ok) return alert("Sorry, something went wrong. Please email hello@raster.example.");
  3. Check the success text ([data-enquiry-success] at the bottom of ProjectEnquiry.vue) matches what really happens next. Keep the reference.value = makeRef("RA") line so the confirmation still shows a reference.

The newsletter form works the same way: in NewsletterForm.vue, send email.value to your provider where it says "Demo only" (Buttondown, Mailchimp and others accept a POST), then check the [data-newsletter-done] text matches your provider (most send a confirmation email for double opt-in).

With pnpm build (a Node.js server build, see Building and deploying) you can post to your own server route instead, for example server/api/enquiry.post.ts with defineEventHandler and readBody().

Saved data (localStorage)#

Key Shape Validation on read Reset
studio-work-view "grid" or "list" Anything other than "list" means grid Switch the view, or remove the key
studio-brief-v1 {"scope":["identity","website"],"scale":"standard","urgency":"standard"} Unknown or duplicate scope keys are dropped; an unknown scale or urgency falls back to the default; invalid JSON means defaults (readBrief() in app/utils/estimate.ts) The estimator's Reset button removes it

Both are read after the page has loaded (the generated HTML shows the defaults) through safeStorage (app/utils/safeStorage.ts), which falls back to memory when storage is blocked or full, so private browsing modes simply don't remember choices between visits. To reset everything, run localStorage.removeItem("studio-work-view"); localStorage.removeItem("studio-brief-v1"); in the browser console.

SEO and sharing#

Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the exact <title> and description copied from the HTML version, a canonical link, Open Graph tags (og:image is public/img/og-image.png) and a Twitter card. The case study page passes getters, so the title and description follow ?id= in the browser; the 404 page passes noindex: true. Social networks need absolute URLs, so set your domain when building; it becomes runtimeConfig.public.siteUrl in nuxt.config.ts:

sh
SITE_URL=https://www.your-studio.example pnpm generate

Consider adding a sitemap.xml and robots.txt to public/ listing your routes, and Organization or ProfessionalService structured data (schema.org JSON-LD, with useHead()) with your address on the home page.

Accessibility#

  • Skip link, landmarks (header, nav, main, footer), one <h1> per page and a logical heading order.
  • Visible focus outlines in the accent color; all interactive elements are native buttons, links and inputs.
  • Filters, view toggle and grid toggle use aria-pressed; counts and estimator results use aria-live="polite".
  • The work preview is decorative (aria-hidden), appears on keyboard focus as well as hover and is dismissible with Escape.
  • The inquiry moves focus to each step's heading, announces the step and links errors to their fields.
  • Accordions are native <details>; note toggles use aria-expanded and aria-controls.
  • Text colors meet WCAG AA: use text-mute (not text-faint) for secondary text, and text-signal-ink for red text on light backgrounds.
  • prefers-reduced-motion turns off all reveals, count-ups and transitions.

When editing, keep one <h1> per page, give every new input a <label>, and keep every aria-labelledby, aria-describedby and aria-controls pointing at an id that exists.

Building and deploying#

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

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

Problem Fix
A project link shows "We couldn’t find that project." The ?id= doesn't match an id in PROJECTS exactly (ids are lowercase with hyphens)
A new project has no cover Add public/img/work/<id>.svg, named after the project's id
The work index always opens in list view It remembers the last view in localStorage; switch back to Grid or remove studio-work-view
The estimator shows old selections Choices are saved; press Reset, or remove studio-brief-v1
"Copy email" says "Press Ctrl+C to copy" Browsers only allow clipboard access on https:// or localhost; use pnpm dev, pnpm preview or deploy
Images or links break when the site is in a sub-folder Build with DEMO_BASE_PATH=/your-folder, and reference files in public/ through useAsset()
The social image doesn't show when sharing Build with SITE_URL set to your domain so og:image is an absolute URL
A hydration warning appears in the console Anything that reads the clock, the URL or storage must run in onMounted() or an event handler, never while rendering

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. Host Grotesk and Fragment Mono are licensed under the SIL Open Font License (app/assets/fonts/*-OFL.txt). Raster, its team, clients and projects are fictional.

Search documentation

Search the guides and every template's documentation