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
Next.js version: Next.js 16 with the App Router, React 19, TypeScript and Tailwind CSS v4, exported as a fully
static site. It has the same pages, design and interactions as the HTML version. This guide covers version 1.0.0.
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.
Quick start#
-
Unzip the download and open a terminal in the folder.
-
Install and start the development server:
shpnpm install pnpm dev -
Visit
http://localhost:3000. Edit files insrc/and the page reloads.
Requirements: Node.js 24 and pnpm (npm works too: npm install, npm run dev). The fonts are bundled, so nothing
loads from the internet.
| Command | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
Route types and TypeScript |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
Home: hero with a sketch-to-shipped drawing, drawn client logos, three services, featured work, 12-week ruler, founder quotes, next open starts |
/work |
src/app/work/page.tsx |
Eight projects from CASES, filters by service and stage, grid or list layout, all kept in the URL |
/case-study |
src/app/case-study/page.tsx |
Brineworks: project facts, the problem, a sketch-versus-shipped compare slider, what shipped, a 14-week timeline, results and a quote |
/services |
src/app/services/page.tsx |
Product design, engineering and launch in detail, three engagement models, a three-question model helper, FAQ |
/pricing |
src/app/pricing/page.tsx |
Engagement estimator with a price range and sample timeline, what is included, payment terms |
/process |
src/app/process/page.tsx |
The 12-week plan as week tabs, founder hours chart, weekly rhythm, handoff list |
/about |
src/app/about/page.tsx |
Studio numbers, four values, the team of 14 with drawn portraits and a group filter, the studio with a live Little Rock clock |
/careers |
src/app/careers/page.tsx |
Benefits, six open roles with discipline and location filters and expanding details, hiring steps |
/contact |
src/app/contact/page.tsx |
Five-step project brief with validation and a success panel, next open start dates |
| 404 | src/app/not-found.tsx |
Links back and a drawn "cut from v1" sketch |
Project structure#
sidecar/
src/
app/
layout.tsx <html>, fonts, header, footer, live region, metadata defaults
fonts.ts next/font/local setup for Recursive and Architects Daughter
globals.css Tailwind import, @theme tokens and the component classes
icon.svg favicon (the logo mark)
page.tsx home page; every other route has its own folder with page.tsx
not-found.tsx 404 page
components/
SiteHeader.tsx sticky header, menu with aria-current, phone menu
SiteFooter.tsx footer columns, address, copyright year
StartDates.tsx StartDate, StartOpen and StartList (next open start dates)
WorkBrowser.tsx work filters, grid/list layout and project cards
CompareSlider.tsx sketch-to-shipped slider on the case study
EngagementModels.tsx model cards and the three-question helper
Estimator.tsx the engagement estimator
WeekPlan.tsx week tabs, panel and founder hours chart
Team.tsx, Avatar.tsx team filter and the drawn portraits
StudioClock.tsx "It is 9:00 a.m. in Little Rock..."
Roles.tsx open roles with filters and details
Brief.tsx the five-step project brief and its success panel
Year.tsx, icons.tsx, RevealController.tsx, NavigationSync.tsx small shared pieces
data/
site.ts all content and settings: studio, services, projects, estimator, weeks, roles, team, brief
nav.ts header, phone menu and footer links
lib/
time.ts Central time, day numbers, start dates, the studio clock text
estimate.ts estimator math, timeline phases, URL and storage validation
client.ts browser-only hooks (useNow, useSearch, useStarts) and URL helpers
store.ts saved state shared between components
safe-storage.ts localStorage with an in-memory fallback, and the storage KEYS
format.ts money, plurals, lists, reference codes
asset.ts asset() and pageUrl() add the base path
metadata.ts pageMetadata() for titles, descriptions and Open Graph
fonts/ Recursive and Architects Daughter woff2 files and their OFL licenses
public/img/
og-image.svg, og-image.png social image (1200×630) and its source
favicon.svg the logo mark
work/*.svg one drawing per project (640×420)
next.config.ts static export, basePath from DEMO_BASE_PATH
package.json, pnpm-lock.yaml, tsconfig.json, eslint.config.mjs, postcss.config.mjs
README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
All colors are tokens in the @theme block of src/app/globals.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 / inputs |
--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 development server picks it up. A few
drawings use the hex values directly (#17663f, #86d6a0, #f1d867); search src/ 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: .sheet and .sheet-lift (ink-outlined cards with an offset
shadow), .tape, .stamp, .marker, .grid-paper (the engineering grid), buttons (.btn-primary,
.btn-outline, .btn-night), .chip, .filter-btn, .seg, .choice, .wk-tab and the form fields.
Typography#
Both families are self-hosted with next/font/local in src/app/fonts.ts, from the Latin files Google Fonts
serves, and registered under their plain names, so CSS and the SVG drawings can say font-family="Recursive".
- Recursive (
--font-sans) is a variable font (weight 400 to 800). Headings use a little of its casual axis (CASL); themonoutility inglobals.cssswitches 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 a font, put its woff2 file in src/fonts/, change src and the font-family declaration in fonts.ts,
and update --font-sans or --font-hand in globals.css. If the new sans has no MONO axis, point the mono
utility at a monospaced family instead.
Content#
Text that is the same on every visit is in the page files. Everything the interactive components render is in
src/data/site.ts:
| Constant | What it holds |
|---|---|
STUDIO |
Name, city, timeZone (America/Chicago), phone, emails and studio hours (Mon–Fri, 09:00–17:30) |
SERVICES, SERVICE_ORDER |
design, engineering, launch: names and the rate per person-week ($4,500, $5,000, $4,000) |
STAGES, STAGE_ORDER |
pre-seed, seed, series-a |
CASES |
The eight projects on /work |
ESTIMATE |
Estimator settings: team sizes, weeks range, defaults, sprint and retainer multipliers, hints |
PHASES |
Phases of the estimator's sample timeline, with weights |
MODELS |
The three engagement models suggested by the helper on /services |
START_RULE |
How the next open start dates are worked out |
WEEKS |
The 12 weeks on /process |
DISCIPLINES, MODES, ROLES |
Open roles on /careers |
TEAM, TEAM_GROUPS |
The 14 people on /about and their drawn portraits |
BRIEF |
Labels for the brief form 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 create src/app/your-case/page.tsx). 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.
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 /services, the studio front on /about, the compare slider screens on /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> through asset(), so their text uses the system sans rather
than the bundled fonts. To use screenshots instead, keep the same ratio or change aspect-[64/42] in
src/components/WorkBrowser.tsx and on the home and case study pages.
Team portraits are drawn by Avatar in src/components/Avatar.tsx from the TEAM settings. To use photos,
replace <Avatar p={p} i={...} /> in src/components/Team.tsx 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 src/app/icon.svg.
Navigation, header and footer#
The header and phone menu are src/components/SiteHeader.tsx and the footer is src/components/SiteFooter.tsx;
their links are in src/data/nav.ts (MAIN_NAV, MOBILE_NAV, FOOTER_NAV). To add a page, create
src/app/<route>/page.tsx with export const metadata = pageMetadata(...) and add its link to nav.ts. The
current page's link gets aria-current="page" automatically; the case study counts as Work.
Features#
Dates and the static build#
The static build can't know the visitor's date, URL parameters or saved choices, so everything that depends on
them renders right after the page loads. The hooks in src/lib/client.ts (useNow, useSearch, useStarts,
useInitialHash) and src/lib/store.ts (useStored, useStoredString) use useSyncExternalStore and return
null while the page is prerendered: the static HTML shows the dates as of Nov 10, 2026 (WRITTEN_DAY in
src/lib/time.ts), and the real values follow without a hydration mismatch.
Next open start dates#
Shown on the home page and /work (StartDate, StartOpen), and on /contact (StartList, the start date
select), all from src/components/StartDates.tsx. startDates() in src/lib/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#
WorkBrowser renders CASES. 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 /case-study, CompareSlider has a range input ([data-compare-range], 0–100, starting at 50) that 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 two drawings are passed in as the shipped and sketch props from the page.
Engagement model helper#
On /services, EngagementModels asks three yes/no questions (scope, engineers, team) and suggests a model
from MODELS once all three are answered (pickModel()): 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 matching model card gets a green shadow, and the link opens the estimator with that model.
Engagement estimator#
Estimator on /pricing, with the math in src/lib/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 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.
After a change, the settings are saved in sidecar:estimate and written to the URL
(?services=design,engineering&team=3&weeks=12&model=sprint), so an estimate can be shared. The URL wins over the
saved settings, which win over ESTIMATE.defaults. "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#
WeekPlan on /process 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#
Team draws the 14 portraits and filters them by group (lead, design, engineering, launch), with a count
such as "5 people in engineering". StudioClock writes "It is 10:42 a.m. in Little Rock. The studio is open." from
STUDIO.hours (clockText() in src/lib/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#
Roles on /careers renders ROLES 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 and scrolls to it.
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 gets a soft shadow once the page scrolls. Elements with .reveal fade in once as they
scroll into view (RevealController), 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,closedoropeningsinsrc/data/site.tsto match your real calendar. For fixed dates on a real site, replace the body ofstartDates()insrc/lib/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 (
Year) is filled in the browser. - The studio clock uses the current Central time.
- Past content keeps its real dates: project years in
CASESand "Est. 2019".
The static export contains the text for Nov 10, 2026, so the page reads correctly before it loads. 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 src/lib/time.ts; set it to false on a live site if you prefer.
Forms#
The project brief on /contact (Brief, [data-brief]) has five steps: Stage, Need, Budget, Timing and You.
"Next" validates 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."
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 src/components/Brief.tsx where it calls showDone(v), and show
the success panel only when the service answers OK:
const response = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(e.currentTarget),
headers: { Accept: "application/json" },
});
if (!response.ok) {
setSummary("Something went wrong. Please try again.");
return;
}
showDone(v);Make the handler async for this. With a server deployment (remove output: "export", see
Building and deploying) you can post to a Route Handler instead, for example
src/app/api/brief/route.ts with export async function POST(request: Request) that reads
await request.formData() and passes it to your CRM or email service, or call a Server Action from the form.
The model helper on /services and the estimator are calculators and send nothing.
Saved data (localStorage)#
All storage goes through safeStorage in src/lib/safe-storage.ts, which falls back to memory when localStorage
is blocked (private modes, sandboxed frames), so the features keep working for the visit. The keys are in KEYS
in the same file.
| Key | Shape | Validation |
|---|---|---|
sidecar:estimate |
{ "v": 1, "services": ["design", "engineering"], "team": 3, "weeks": 12, "model": "sprint" } |
cleanEstimate() in src/lib/estimate.ts: 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 |
sidecar:work-view |
grid or list (a plain string) |
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 (has() in
src/lib/format.ts), so ?service=toString is ignored.
SEO and sharing#
Each page sets its title, description and Open Graph tags with pageMetadata() from src/lib/metadata.ts
(export const metadata = pageMetadata(...) at the top of each page.tsx); the layout has the defaults. Set
NEXT_PUBLIC_SITE_URL to your domain when you build (NEXT_PUBLIC_SITE_URL=https://www.example.com pnpm build):
metadataBase in src/app/layout.tsx uses it so og:image (/img/og-image.png) gets an absolute URL, which most
social sites require. Add src/app/sitemap.ts and src/app/robots.ts if you want them generated, and consider
Organization or ProfessionalService structured data with the studio's address.
Accessibility#
- Skip link, landmarks, one
<h1>per page, a logical heading order andaria-current="page"in the menus. - 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 other changes are announced through#live-region(announce()insrc/lib/client.ts). - Form errors are linked with
aria-describedbyand fields getaria-invalid; the first problem gets focus and 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 src/data/site.ts so the components
and the pages agree.
Building and deploying#
pnpm build writes a static site to out/ (output: "export" in next.config.ts):
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve from a sub-folder, build with DEMO_BASE_PATH=/studio pnpm build; next.config.ts passes it to
basePath and NEXT_PUBLIC_BASE_PATH, which asset() and pageUrl() use. pnpm build:demo does this for the
freeframe.dev demo (demo/, which you can delete).
Tailwind scans the source files for class names; keep each class name whole (not built from pieces such as
`bg-${color}`), or it won't be generated.
To use server features (Route Handlers, Server Actions), remove output: "export" and images.unoptimized from
next.config.ts and deploy to a Node-capable host such as Vercel.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc) and typescript as an alias of
@typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6
API. When typescript-eslint supports TypeScript 7, replace both with typescript@7. eslint.config.mjs pins
settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.
Troubleshooting#
- New classes have no effect: class names built from pieces aren't generated; write them out in full.
- The estimator opens with old settings: the URL or
sidecar:estimatewins over the defaults. Use "Reset the estimator", or open/pricingwithout parameters. - Start dates look wrong for my calendar: edit
START_RULEinsrc/data/site.ts, or hard-code dates instartDates(). - The brief says "received" but nothing arrives: the demo form is not connected; see Forms.
- Project drawings show a different font: they load as
<img>, which can't use the page's fonts; inline the SVG if you need them. - Images or links break under a sub-folder: use
asset()for files inpublic/andpageUrl()for plain links with parameters, and build withDEMO_BASE_PATH. - A hydration warning after editing a component: values that depend on the clock, the URL or storage must come
from
useNow,useSearchoruseStored, which returnnulluntil the page has loaded.
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 included under the SIL Open Font License (see src/fonts/). Sidecar, its clients,
founders and team are fictional.