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
Astro version: Astro 7, TypeScript and Tailwind CSS v4, version 1.0.0, building to plain static files with no UI
framework. 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#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev. Fonts load from Google Fonts, so you need an internet connection to
see the right typefaces. Add ?now=2027-11-10T09:00 to any page's URL to preview it at a Central date and time
(see Keeping the demo current).
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/sidecar into demo/ (only used for the demo on freeframe.dev) |
Pages#
| Route (file) | Purpose | Notable sections and features |
|---|---|---|
/ (src/pages/index.astro) |
Home | Hero with a sketch-to-shipped drawing, drawn client logos, three services, featured work, 12-week ruler, founder quotes, next open start dates |
/work (work.astro) |
Work | Eight projects from CASES, filters by service and stage, grid or list layout, all kept in the URL |
/case-study (case-study.astro) |
Case study (Brineworks) | Project facts, the problem, a sketch-versus-shipped compare slider, what shipped, a 14-week timeline, results and a quote |
/services (services.astro) |
Services | Product design, engineering and launch in detail, three engagement models, a three-question model helper, FAQ |
/pricing (pricing.astro) |
Pricing and estimator | Engagement estimator (services, team size, weeks, sprint or retainer) with a price range and sample timeline, what is included, payment terms |
/process (process.astro) |
Process | The 12-week plan as week tabs, founder hours chart, weekly rhythm, handoff list |
/about (about.astro) |
About | Studio numbers, four values, the team of 14 with drawn portraits and a group filter, the studio with a live Little Rock clock |
/careers (careers.astro) |
Careers | Benefits, six open roles with discipline and location filters and expanding details, hiring steps |
/contact (contact.astro) |
Start a project | Five-step project brief with validation and a success panel, next open start dates |
404.astro |
Not found | Links back and a drawn "cut from v1" sketch |
Project structure#
sidecar/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, live region, script
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, main menu, "Start a project" button, mobile menu
│ │ ├─ SiteFooter.astro footer columns, address, hours and copyright
│ │ ├─ Logo.astro the scooter-and-sidecar mark
│ │ └─ ArrowIcon.astro the arrow used on header buttons
│ ├─ data/
│ │ ├─ sidecar.ts content and settings: projects, prices, phases, weeks, roles, team, brief options
│ │ └─ nav.ts header and mobile menu items, footer "Studio" and "Company" links
│ ├─ lib/
│ │ └─ site.ts site name, theme color and the url() helper for links
│ ├─ scripts/
│ │ └─ main.ts all interactions, in numbered sections (reads src/data/sidecar.ts)
│ └─ styles/
│ └─ global.css Tailwind theme (@theme tokens), base styles and the component classes
├─ public/img/
│ ├─ favicon.svg the logo mark
│ ├─ og-image.svg, og-image.png social image source and the PNG used in og:image (1200×630)
│ └─ work/*.svg one drawing per project (640×420), used on the home, work and case study pages
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.mdPages are rendered to HTML at build time. src/scripts/main.ts is the only JavaScript sent to the browser, plus
one inline line in the layout that adds the js class to <html> before the page paints (so scroll reveals never
flash). Layout.astro imports the script and Astro bundles it, with the data it imports from
src/data/sidecar.ts, into one small module. Each page passes page to the layout, which becomes
<body data-page="…">.
Customizing#
Brand and colors#
All colors are tokens in the @theme block of src/styles/global.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; pnpm dev picks the change up instantly. A few
drawings use the hex values directly (#17663f, #86d6a0, #f1d867); search src/ and public/img/ for them if
you change the palette completely. The theme color in the browser bar is themeColor in src/lib/site.ts.
@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#
Two Google Fonts families are loaded with one <link> in src/layouts/Layout.astro:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Recursive:wght,CASL,MONO@400..800,0..1,0..1&display=swap" />- Recursive (
--font-sans) is a variable font. Headings use a little of its casual axis (CASL); themonoutility inglobal.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 fonts, change the <link> in the layout 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 is the same on every visit is in the .astro pages. Everything the script renders is in
src/data/sidecar.ts, typed with the interfaces at the top of that file:
| 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 the work page |
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 the services page |
START_RULE |
How the next open start dates are worked out |
WEEKS |
The 12 weeks on the process page |
DISCIPLINES, MODES, ROLES |
Open roles on the careers page |
TEAM, TEAM_GROUPS |
The 14 people on the about page 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" (a site path, without .html) to link a full case study; the script passes it through
link() so it works under a base path. 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 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 pages. 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
main.ts (workCard) and on the pages.
Team portraits are drawn by drawAvatar() in main.ts from the TEAM settings. To use photos, replace the
drawAvatar(p, i) call in initTeam() with an <img> whose src goes through link().
The social image is public/img/og-image.png, generated from og-image.svg. After editing the SVG, regenerate it
with any SVG-to-PNG tool at 1200×630. The favicon is public/img/favicon.svg.
Navigation, header and footer#
The header and mobile menu are src/components/SiteHeader.astro; the footer is src/components/SiteFooter.astro.
Their links come from src/data/nav.ts: mainNav (desktop menu), mobileNav (adds Contact), studioLinks and
companyLinks (footer columns). To add a page, create src/pages/<name>.astro with
<Layout title="…" description="…" page="<name>" current="<id>">, then add an item to mainNav with the same
id; the header marks it with aria-current="page" on that page. Write internal links with url("/<name>")
from src/lib/site.ts.
Features#
Next open start dates#
Shown on the home and work pages (data-start-date, data-start-open), the pricing page and the contact page
(data-start-list, the start date select). startDates() in main.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#
initWork() renders CASES into [data-work-list]. 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".
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 matching model card gets a
green shadow, and the link opens the estimator with that model.
Engagement estimator#
initEstimator() on the pricing page. 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). The math, in 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.
Settings are saved in sidecar:estimate and written to the URL (?services=design,engineering&team=3&weeks=12&model=sprint)
after the first 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#
initWeeks() on the process page 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#
initTeam() draws the 14 portraits and filters them by group (lead, design, engineering, launch), with
a count such as "5 people in engineering". initClock() writes "It is 10:42 a.m. in Little Rock. The studio is
open." from STUDIO.hours, every 30 seconds, with "opens at 9:00 a.m.", "done for the day" or "out for the
weekend" outside hours.
Open roles#
initRoles() on the careers page 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.
Menu, header and motion#
The menu button (#menu-toggle) opens the mobile menu with aria-expanded; Escape closes it, and it closes when the
window grows to the desktop layout. The header gains a soft shadow once the page scrolls. Elements with .reveal
fade in once as they scroll into view, 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/sidecar.tsto match your real calendar. For fixed dates on a real site, replace the body ofstartDates()inmain.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 (
data-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 built pages contain the text for Nov 10, 2026, so they read correctly before the script runs. 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 at the top of main.ts; set it to false on a live site if you prefer.
Forms#
The project brief on the contact page ([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 the submit handler in section 13 of src/scripts/main.ts (initBrief()), where
f is the form:
fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(f), headers: { Accept: "application/json" } })
.then((r) => {
if (r.ok) showDone(new FormData(f));
else showSummary("Something went wrong. Please try again.");
});Show the success panel only when the service answers OK. Astro builds a static site, so there is no server code to post to; use a form service or a serverless function on your host. The model helper on the services page and the estimator are calculators and send nothing.
Saved data (localStorage)#
All storage goes through safeStorage in main.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 |
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, so
?service=toString is ignored.
SEO and sharing#
Each page passes its title and description to <Layout> (the home page also passes ogTitle), which writes
the <title>, meta description, canonical link, Open Graph tags (og:title, og:description, og:url,
og:image, og:site_name, og:locale en_US), a Twitter card and theme-color. The 404 page passes noindex.
Canonical and Open Graph URLs are absolute, built from site in astro.config.mjs: set SITE_URL when you build
(SITE_URL=https://www.example.com pnpm build) or change the default there. Suggestions: add @astrojs/sitemap
and a public/robots.txt, and consider Organization or ProfessionalService structured data with the studio's
address.
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. - 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 src/data/sidecar.ts so the script
and the markup agree.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as pricing.html rather than pricing/index.html
(build.format: "file" in astro.config.mjs), and links point to /pricing without a trailing slash. Netlify,
Cloudflare Pages and GitHub Pages serve /pricing from pricing.html automatically; Vercel does with cleanUrls.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/pricingservespricing.html. - GitHub Pages: use the official Astro GitHub Action. For a
project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/pricingtopricing.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, for example
DEMO_BASE_PATH=/studio pnpm build. It becomes Astro's base; every link written with url() and every link the
script builds (through link() in main.ts) includes it. Set SITE_URL to your domain for canonical and Open
Graph URLs.
Tailwind CSS is compiled by @tailwindcss/vite during pnpm dev and pnpm build; there is no separate CSS step.
It scans the .astro and .ts files for class names, so keep each class name whole in main.ts (not built from
pieces). compressHTML is off in astro.config.mjs so the spaces between text and inline tags that start on a
new line are kept exactly as written.
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#
- Fonts look plain. Google Fonts need an internet connection; check the
<link>insrc/layouts/Layout.astro. - A new class has no effect. Class names must appear as whole strings in
.astroor.tsfiles; restartpnpm devif you added a new file. - Links break when the site is in a sub-folder. Write internal links with
url("/page")in pages andlink("/page")inmain.ts, never a bare"/page"or"page.html". - 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_RULEinsrc/data/sidecar.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 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 provided by Google Fonts under the SIL Open Font License. Sidecar, its clients,
founders and team are fictional.