Overview#
Sidecar is a multi-page website for a product and engineering studio, an agency that startups hire to design,
build and launch their first product. The fictional Sidecar is a 14-person studio on South Main Street in Little
Rock, Arkansas, working in Central time (America/Chicago) with seed-stage founders across the US. This is the
Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from the HTML
template, and it generates to static files. The template page, with a link to the live demo, is at
freeframe.dev/templates/sidecar.
The look is a workshop build log: warm paper with a faint engineering grid, ink outlines with hard offset shadows, one green accent ("Sidecar green"), masking tape, stamped labels and handwritten margin notes. Type is Recursive (sans for text, its monospaced setting for labels) with Architects Daughter for the notes. It is a light-only design.
The site is a front end only: the estimator and model helper are calculators, and the project brief doesn't send anything until you connect it (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/sidecar into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with a sketch-to-shipped drawing, drawn client logos, three services, featured work, 12-week ruler, founder quotes, next open start dates |
/work |
app/pages/work.vue |
Eight projects from CASES, filters by service and stage, grid or list layout (?service=, ?stage=, ?view=list) |
/case-study |
app/pages/case-study.vue |
Brineworks: project facts, the problem, a sketch-versus-shipped compare slider, what shipped, a 14-week timeline, results and a quote |
/services |
app/pages/services.vue |
Product design, engineering and launch, three engagement models, a three-question model helper, FAQ |
/pricing |
app/pages/pricing.vue |
Engagement estimator with a price range and sample timeline, what is included, payment terms |
/process |
app/pages/process.vue |
The 12-week plan as week tabs (#week-9), founder hours chart, weekly rhythm, handoff list |
/about |
app/pages/about.vue |
Studio numbers, four values, 14 drawn portraits with a group filter, the studio with a live Little Rock clock |
/careers |
app/pages/careers.vue |
Benefits, six open roles with filters (?discipline=, ?mode=) and expanding details (#role-<id>), hiring steps |
/contact |
app/pages/contact.vue |
Five-step project brief (app/components/BriefForm.vue), next open start dates, contact details |
| 404 | app/error.vue |
Links back and a drawn "cut from v1" sketch (generated as 404.html, noindex) |
Project structure#
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind (@tailwindcss/vite)
app/
app.vue <NuxtLayout> around <NuxtPage>
error.vue the 404 page
router.options.ts query strings written like the HTML version (?services=design,engineering)
layouts/default.vue skip link, header, the page's <main>, footer, the live region
pages/*.vue one file per page
components/
SiteHeader.vue sticky header, current page, phone menu
SiteFooter.vue footer with the copyright year
SidecarMark.vue the logo mark
ArrowIcon.vue the arrow on buttons
WorkCard.vue a project as a grid card or list row
RoleItem.vue an open role with its details
TeamAvatar.vue a drawn portrait
BriefForm.vue the five-step project brief and its success panel
composables/
useClock.ts today in Central time, ?now=, the next open start dates, the year
useCurrentPage.ts aria-current for menu links
usePageSeo.ts title, description, canonical and Open Graph tags
useAsset.ts paths to files in public/ with the base path
data/
site.ts STUDIO, SERVICES, STAGES, START_RULE, ALLOW_NOW_PARAM, WRITTEN_ON
work.ts CASES (the eight projects)
estimate.ts ESTIMATE, PHASES, MODELS
process.ts WEEKS (the 12-week plan)
careers.ts DISCIPLINES, MODES, ROLES
team.ts TEAM, TEAM_GROUPS
brief.ts BRIEF, STEP_NAMES
nav.ts header, phone menu and footer links
utils/
estimate.ts estimator math, sample timeline, URL and storage reading
time.ts Central time, start dates, the studio clock text
avatar.ts drawAvatar(): portraits from settings
format.ts plural(), money(), listJoin(), refCode(), hasKey()
safeStorage.ts localStorage with an in-memory fallback, STORAGE_KEYS
url.ts urlParams(), setParams(), setHash()
announce.ts screen-reader announcements
plugins/
reveal.client.ts scroll reveals
stable-build.server.ts reproducible builds (drops the build timestamp)
assets/css/main.css Tailwind entry: fonts, @theme tokens, components
assets/fonts/ Recursive and Architects Daughter (woff2) and their licenses
public/img/ favicon.svg, og-image.svg, og-image.png, work/*.svg (project drawings)Customizing#
Brand and colors#
All colors are tokens in the @theme block of app/assets/css/main.css, so Tailwind generates bg-paper,
text-pine, border-ink and so on from them.
| Token | Value | Used for |
|---|---|---|
--color-paper / --color-paper-2 |
#f5f0e4 / #ece5d4 |
Page background / alternate bands |
--color-card |
#fcfaf4 |
Sheets and cards |
--color-ink, --color-ink-soft, --color-ink-faint |
#1d1b16, #4b463b, #625c4e |
Text, outlines and hard shadows |
--color-line / --color-line-strong |
#d6ccb6 / #8b8271 |
Hairlines / rulers |
--color-pine / --color-pine-deep |
#17663f / #0f4a2d |
The accent: buttons, links, notes, focus / hover |
--color-sprout / --color-sprout-soft |
#86d6a0 / #dcecd8 |
Light green fills with ink text, selected choices |
--color-tape / --color-tape-soft |
#f1d867 / #f7ebb4 |
Masking tape, highlighter, the price on dark |
--color-night, --color-mist |
#1d1b16, #cfc6b1 |
Footer and dark bands, text on them |
--color-danger |
#a3361a |
Form errors |
To change the accent, edit --color-pine and --color-pine-deep; the dev server picks it up at once. A few
drawings use hex values directly (#17663f, #86d6a0, #f1d867); search app/ and public/img/ for them if
you change the palette completely.
@theme {
--color-pine: #1f5f8b;
--color-pine-deep: #174766;
}The repeated pieces live in @layer components in the same file: .sheet and .sheet-lift (ink-outlined cards
with an offset shadow), .tape, .stamp, .grid-paper (the engineering grid), buttons (.btn-primary,
.btn-outline, .btn-night), .chip, .filter-btn, .seg, .choice, .wk-tab and the form fields.
Typography#
The fonts are self-hosted in app/assets/fonts/ and declared with @font-face at the top of main.css:
- Recursive (
--font-sans) is a variable font. Headings use a little of its casual axis (CASL); themonoutility switches on its monospaced axis (MONO 1) for labels, chips and numbers;casualsetsCASL 0.5(the wordmark). - Architects Daughter (
--font-hand, classfont-handor.hand) is for margin notes and quotes in pen.
To swap fonts, put the new woff2 files in app/assets/fonts/, change the @font-face rules and the two
--font-* tokens. If the new sans has no MONO axis, point the mono utility at a monospaced family instead.
Content#
Text that never changes is written in the page files. Everything rendered from data is in app/data/:
| Constant | File | What it holds |
|---|---|---|
STUDIO |
site.ts |
Name, city, timeZone (America/Chicago), phone, emails and studio hours (Mon–Fri, 09:00–17:30) |
SERVICES, SERVICE_ORDER |
site.ts |
design, engineering, launch: names and the rate per person-week ($4,500, $5,000, $4,000) |
STAGES, STAGE_ORDER |
site.ts |
pre-seed, seed, series-a |
START_RULE |
site.ts |
How the next open start dates are worked out |
CASES |
work.ts |
The eight projects on the work page |
ESTIMATE, PHASES |
estimate.ts |
Estimator settings and the phases of its sample timeline |
MODELS |
estimate.ts |
The three engagement models suggested by the helper on the services page |
WEEKS |
process.ts |
The 12 weeks on the process page |
DISCIPLINES, MODES, ROLES |
careers.ts |
Open roles |
TEAM, TEAM_GROUPS |
team.ts |
The 14 people on the about page and their portrait settings |
BRIEF, STEP_NAMES |
brief.ts |
Labels for the brief and the budget ranges used to match an estimate |
To add a project, add an object to CASES and a 640×420 drawing (or image) at public/img/work/<id>.svg:
{
id: "ferrylog", name: "Ferrylog", what: "Crew scheduling for river ferries",
city: "Helena, AR", stage: "seed", services: ["design", "engineering"], weeks: 10, team: 3, year: 2026,
summary: "One or two sentences about the problem and what we did.",
shipped: ["First thing shipped", "Second thing", "Third thing"],
metric: { value: "3×", label: "faster crew changes" },
},Add page: "/your-case" to link a full case study (and add the page and its route). To add a role, copy an entry
in ROLES (discipline must be a key of DISCIPLINES, mode a key of MODES). To add a team member, copy an
entry in TEAM; the portrait settings are skin 0–4, hair 0–5 (crop, side part, bob, long, curls, bun),
hairColor 0–4, glasses and bg 0–3. The featured projects on the home page and the case study are written
in their page files.
Images and artwork#
All artwork is SVG drawn for this template. The hero sketch and phone, client wordmarks and service drawings on
the home page, the workbench on the services page, the studio front on the about page, the compare slider screens
on the case study and the 404 sketch are inline SVG in the page files. The eight project drawings are files in
public/img/work/ (640×420, shown at a 64:42 ratio); they load as <img>, so their text uses the system sans
rather than the web fonts. To use screenshots instead, keep the same ratio or change aspect-[64/42] in
WorkCard.vue and the pages.
Team portraits are drawn by drawAvatar() in app/utils/avatar.ts and shown by TeamAvatar.vue. To use photos,
replace <TeamAvatar :person="p" /> in about.vue with an <img>.
The social image is public/img/og-image.png, generated from og-image.svg (any SVG-to-PNG tool at 1200×630
works). The favicon is public/img/favicon.svg.
Navigation, header and footer#
app/data/nav.ts holds the links: NAV (header), MOBILE_NAV (the phone menu) and FOOTER_NAV (the two footer
columns). NAV_PARENT marks a parent menu item for pages that aren't in the menu (the case study marks Work).
SiteHeader.vue marks the current page with aria-current="page". To add a page, create app/pages/<name>.vue,
add its route to routes in nuxt.config.ts and add links in nav.ts.
Features#
Next open start dates#
Shown on the home page and the work page (data-start-date, data-start-open), the estimator and the contact
page (data-start-list, the start date select). startDates() in app/utils/time.ts reads START_RULE:
- the first Monday at least
leadDays(21) days after today in Central time, - then every
everyWeeks(4) weeks,count(3) dates in all, - a start between
closed.fromandclosed.to(Dec 19 to Jan 3, inclusive) moves to the first Monday after it, openings[1, 2, 2]gives "1 of 2 pods open", "2 of 2 pods open"... by position.
On Nov 10, 2026 this gives Monday, Dec 7, 2026; Monday, Jan 4, 2027; Monday, Feb 1, 2027. On Nov 10, 2027: Dec 6, 2027; Jan 10, 2028 (Jan 3 is in the break); Feb 7, 2028. See Keeping the demo current.
Work filters and layout#
app/pages/work.vue renders CASES with WorkCard.vue. The service buttons (data-filter-service) and stage
buttons (data-filter-stage) combine (a project must match both); the count reads "Showing 2 of 8 projects". The
Grid and List buttons (data-view) switch the layout; the choice is saved in sidecar:work-view. Filters and
layout live in the URL so a filtered view can be linked:
| Parameter | Values | Default |
|---|---|---|
service |
design, engineering, launch |
all |
stage |
pre-seed, seed, series-a |
all |
view |
grid, list |
saved choice, else grid |
For example /work?service=launch&stage=seed shows Brineworks and Mulecart. Unknown values are ignored. When
nothing matches, a message with "Show all projects" appears. Filter buttons use aria-pressed, and the count is a
live region.
Compare slider#
On the case study a range input ([data-compare-range], 0–100, starting at 50) clips the sketch layer
([data-compare-top], clip-path: inset(0 <100-v>% 0 0)) and moves the divider. It works with arrow keys and
announces "50% sketch". The value is the sketch ref in case-study.vue.
Engagement model helper#
On the services page, three yes/no questions (scope, engineers, team) suggest a model from MODELS once all
three are answered: wanting a team within a year with no engineers on staff suggests Build to hire; otherwise a
clearly scoped release suggests Fixed sprint; otherwise Monthly retainer. The rule is onChange() in
services.vue. The matching model card gets a green shadow, and the link opens the estimator with that model.
Engagement estimator#
app/pages/pricing.vue, with the math in app/utils/estimate.ts. Inputs: services (checkboxes, at least one;
unchecking the last one keeps it and shows "Pick at least one service"), team size (2 to 6), weeks (range 4 to 24,
step 2) and the payment model (Fixed sprint or Monthly retainer, aria-pressed buttons). computeEstimate():
- rate = the average
SERVICES[...].rateof the services picked, - fixed sprint: total = rate × people × weeks; shown as total × 0.9 to total × 1.15, each rounded to $1,000,
- retainer: monthly = rate × people × 4 × 0.9 (10% off); shown as monthly × 0.95 to × 1.05, rounded to $500, for max(3, ceil(weeks ÷ 4)) months, with the total for those months.
| Settings | Shows |
|---|---|
| Design + Engineering, 3 people, 12 weeks, sprint (the default) | $154,000 to $197,000; 3 people for 12 weeks at $4,750 per person-week |
| Same, retainer | $48,500 to $54,000 a month; every 4 weeks for 3 months: $145,500 to $162,000 in total |
| All three services, 4 people, 16 weeks, sprint | $259,000 to $331,000 at $4,500 per person-week |
| Launch only, 4 people, 4 weeks, sprint | $58,000 to $74,000 |
The sample timeline (planPhases()) shares the weeks among the PHASES that apply (Discovery and Handoff always,
Design, Build and Launch when that service is picked) by weight, largest remainder first, at least one week each;
if there are more phases than weeks, Handoff and then Discovery are dropped. The default reads Discovery weeks
1–2, Design 3–5, Build 6–11, Handoff week 12. Dates start on the next open start date and end on the Friday of the
last week (start + weeks × 7 − 3 days): Dec 7 to Feb 26 for the default on Nov 10, 2026.
Settings are saved in sidecar:estimate and written to the URL
(?services=design,engineering&team=3&weeks=12&model=sprint) after a change, so an estimate can be shared. The URL
wins over the saved settings. "Send this with a project brief" opens /contact with the same parameters; "Reset
the estimator" clears both. The yellow note says it is an estimate, not a quote; keep it.
The 12-week plan#
app/pages/process.vue builds 12 tabs (role="tab", arrow keys, Home and End) from WEEKS and fills one panel:
phase, title, text, who is on it, what you get and the founder hours with a bar. The founder hours chart has one
button per week; selecting a bar opens that week. The total reads "39 hours in total, about 3.3 a week". #week-5
in the URL opens week 5, and the hash follows the selected week.
Team and studio clock#
app/pages/about.vue draws the 14 portraits and filters them by group (lead, design, engineering,
launch), with a count such as "5 people in engineering". The clock writes "It is 10:42 a.m. in Little Rock. The
studio is open." from STUDIO.hours (clockText() in app/utils/time.ts), every 30 seconds, with "opens at
9:00 a.m.", "done for the day" or "out for the weekend" outside hours.
Open roles#
app/pages/careers.vue renders ROLES with RoleItem.vue, with filters by discipline (data-role-discipline)
and location (data-role-mode), kept in the URL as ?discipline=engineering&mode=hybrid (2 open roles). "Read
the role" expands the details (aria-expanded, aria-controls); the open role goes into the hash
(#role-mobile-engineer), so a link opens that role.
Menu, header and motion#
The menu button (#menu-toggle) opens the phone menu with aria-expanded; Escape closes it and returns focus to
the button. The header gains a soft shadow once the page scrolls. Elements with .reveal fade in once as they
scroll into view (app/plugins/reveal.client.ts), and everything above them is revealed too. With
prefers-reduced-motion: reduce there is no reveal, underline drawing or smooth scrolling.
Keeping the demo current#
Nothing dated goes stale:
- Start dates follow
START_RULEfrom today in Central time (above). ChangeleadDays,everyWeeks,closedoropeningsinapp/data/site.tsto match your real calendar. For fixed dates on a real site, replace the body ofstartDates()inapp/utils/time.tswith a list, for examplereturn [{ day: dayNumber(2027, 3, 1), open: 1 }, { day: dayNumber(2027, 4, 5), open: 2 }];. - Estimator dates start on the first open start date.
- The copyright year is the current year in Central time.
- The studio clock uses the current Central time.
- Past content keeps its real dates: project years in
CASESand "Since 2019".
The generated HTML shows the dates as they were on WRITTEN_ON (Nov 10, 2026, in app/data/site.ts), so the
page reads correctly before the scripts run and hydration never mismatches; useClock() replaces them with
today's once the page has loaded. A preview parameter pretends it is another moment in Central time, for
reviews: /contact?now=2027-11-10T09:00. It is controlled by ALLOW_NOW_PARAM in app/data/site.ts; set it to
false on a live site if you prefer.
Things that read the browser#
The URL, the clock and localStorage are only read in onMounted() or event handlers, never while rendering, so
the static pages are identical on every build. Pages that read the URL (work, pricing, careers, contact) read it
again when a link opens the same page with other parameters.
Forms#
The project brief (app/components/BriefForm.vue, [data-brief]) has five steps: Stage, Need, Budget, Timing
and You. "Next" checks the current step and shows inline errors:
| Step | Rules | Message |
|---|---|---|
| Stage | one of stage |
"Pick the stage that fits best." |
| Need | at least one need; about 20 characters or more (1,200 max, with a counter) |
"Pick at least one: design, engineering or launch." / "Tell us what you are building." / "Tell us a little more: at least 20 characters." |
| Budget | one of budget |
"Pick a range, or “Not sure yet”." |
| Timing | one of timeline; start (the next open start dates or "I’m flexible", hidden for "Just exploring") |
"Pick when you would like to start." |
| You | name, email (valid), company; role optional |
"Tell us your name." / "We need an email to reply to." / "Enter an email like name@company.example." / "Which company is this for?" |
Opened from the estimator (?services=…&team=…&weeks=…&model=…), it ticks the services, picks the budget range
the estimate's middle falls in (BRIEF.budgetCaps: up to $75,000, $150,000, $300,000, then more) and shows a note:
"From the estimator: Design and Engineering, 3 people for 12 weeks, fixed sprint, $154,000 to $197,000. We filled
in what we could."
On success the form is replaced by "Thanks, Leilani. Your brief is in." with a reference (SC- and five
characters), a summary and what happens next. The demo does not send anything until you connect it. To use a form
service such as Formspree, change onSubmit() in BriefForm.vue so it posts the form and shows the success
panel only when the service answers OK:
async function onSubmit() {
// ...the step checks stay as they are...
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(formEl.value!),
headers: { Accept: "application/json" },
});
if (res.ok) showDone();
else summary.value = "Something went wrong. Please try again.";
}To keep the endpoint private, deploy with pnpm build and add a server route (server/api/brief.post.ts) that
forwards the form to your CRM or email service, then post to /api/brief instead. The model helper and the
estimator are calculators and send nothing.
Saved data (localStorage)#
All storage goes through safeStorage in app/utils/safeStorage.ts, which falls back to memory when
localStorage is blocked (private modes, sandboxed frames), so the features keep working for the visit.
| Key | Shape | Validation |
|---|---|---|
sidecar:estimate |
{ v: 1, services: ["design", "engineering"], team: 3, weeks: 12, model: "sprint" } |
v must be 1; services must be keys of SERVICES (at least one), team in teamSizes, weeks in range and on the step, model sprint or retainer; otherwise the defaults are used (estimateFromStorage()) |
sidecar:work-view |
"grid" or "list" |
anything else means grid |
"Reset the estimator" removes sidecar:estimate. To clear everything, remove both keys in the browser's developer
tools (Application > Local Storage). All values read from the URL are checked with own-key lookups (hasKey()),
so ?service=toString is ignored.
SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title,
description, Open Graph and Twitter tags and a canonical link. URLs are absolute: set SITE_URL when you build
(SITE_URL=https://your-domain.example pnpm generate); the default is https://sidecar.example. The social image
is public/img/og-image.png. The 404 page is noindex. Add a sitemap.xml and robots.txt to public/ when
you deploy, and consider Organization or ProfessionalService structured data with the studio's address
(useHead({ script: [{ type: "application/ld+json", innerHTML: … }] })).
Accessibility#
- Skip link, landmarks, one
<h1>per page and a logical heading order. - Visible focus outlines in Sidecar green; choice cards show focus from their input.
- Filters and toggles use
aria-pressed; the week tabs follow the tabs pattern with arrow keys; role details usearia-expandedandaria-controls; counts and results are live regions, and short changes ("List layout", "Step 2 of 5: Need") go to the live region at the end of the page throughannounce(). - Form errors are linked with
aria-describedbyand fields getaria-invalid; the success heading receives focus. - Drawings are
role="img"with a description, or hidden when decorative. - Text colors meet WCAG AA on paper, cards and the dark bands;
prefers-reduced-motionturns animation off.
When editing, keep one <h1> per page, label every new input and keep data in app/data/ so the pages and the
logic agree.
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); 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=/sidecar 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.
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 estimator opens with old settings. The URL or
sidecar:estimatewins over the defaults. Use "Reset the estimator", or open/pricingwithout parameters after clearing storage. - Start dates look wrong for my calendar. Edit
START_RULEinapp/data/site.ts, or return fixed dates fromstartDates(). - The page shows Nov 2026 dates for a moment. That is the text generated at build time (
WRITTEN_ON); it is replaced once the page loads. ChangeWRITTEN_ONwhen you rebuild if you want the static text closer to today. - The brief says "received" but nothing arrives. The form is not connected; see Forms.
- A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- A Tailwind class written in a template string has no effect. Write class names in full inside quotes; Tailwind can't find classes built from pieces.
- Project drawings show a different font. They load as
<img>, which cannot use web fonts; inline the SVG if you need the page fonts.
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. Recursive and
Architects Daughter are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Sidecar, its
clients, founders and team are fictional.