Overview#
Studio is a complete website for a small design studio, built for the fictional Raster, a five-person studio in
Zürich 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 data-driven case studies, a services page with a brief estimator, a studio page, a
journal and a five-step project enquiry. This is the Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no
UI framework, built to plain static files. It has the same pages, design, copy and interactions as the HTML version.
This guide covers version 1.0.0 (see template.json). Live demo:
freeframe.dev/templates/studio.
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.
| 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/studio into demo/ (only used for the demo on freeframe.dev) |
Pages#
| Route | File | Purpose | Notable sections and features |
|---|---|---|---|
/ |
src/pages/index.astro |
Home | Typographic hero with availability, selected work with hover labels, services list, client wordmarks, count-up numbers, journal teaser, contact call to action |
/work |
src/pages/work.astro |
Work index | All nine projects with discipline filters, year sort, live count, grid/list view toggle and a hover/focus preview in list view |
/project |
src/pages/project.astro |
Case study | project?id=<id>: facts, cover, overview, challenge, three artwork images, approach, results, quote, credits, previous/next project; a not-found message for unknown ids |
/services |
src/pages/services.astro |
Services | Four disciplines with deliverables, timelines and starting prices, three engagement models, the brief estimator and an FAQ |
/studio |
src/pages/studio.astro |
Studio | Story, floor plan of the studio, values, process timeline, team, open roles and visiting details |
/journal |
src/pages/journal.astro |
Journal | Eight short notes with topic filters, expandable text and a newsletter sign-up |
/contact |
src/pages/contact.astro |
Contact | Five-step project enquiry with a review step, plus email, phone and studio hours with a live open/closed note |
/404 |
src/pages/404.astro |
Not found | "Off the grid" page with links back into the site |
Project structure#
studio/
astro.config.mjs site URL, base path, static output (pages built as work.html)
package.json scripts and dependencies
pnpm-workspace.yaml marks the folder as a standalone pnpm project
tsconfig.json extends Astro's strict preset
eslint.config.mjs ESLint flat config (typescript-eslint + eslint-plugin-astro)
public/img/
favicon.svg browser tab icon
og-image.svg social image source (1200 x 630)
og-image.png social image used in og:image
work/<id>.svg nine project covers, one per project id (800 x 600)
src/
pages/*.astro one file per page (see Pages)
layouts/Layout.astro <html>, <head> metadata, skip link, header, footer and the script imports
components/
SiteHeader.astro sticky header with the Zürich clock, desktop and mobile menus
SiteFooter.astro footer columns, copy-email button, wordmark, grid toggle and "Back to top" link
ProjectCard.astro a selected-work card on the home page
CaseStudy.astro one complete case study (used for every project on the case study page)
data/
nav.ts header, mobile menu and footer links
projects.ts disciplines and the nine projects (case study text, palette, mark, artwork data)
estimator.ts the estimator price model, budget bands, timelines and the default brief
services.ts the four discipline sections, engagement models and FAQ
home.ts selected work, the "What we do" rows, client wordmarks and numbers
journal.ts journal topics and the eight notes
studio.ts values, process phases, team and open roles
lib/
site.ts site name, email, phone, theme colour, studio hours and the url() helper
art.ts the generated case study artwork (ART) and esc()
estimate.ts estimator maths: estimateBrief(), chf(), briefQuery(), orderedScope()
icons.ts the arrow, tick and logo SVGs
scripts/
main.ts all interactive behaviour (menu, clock, reveals, filters, estimator, forms...)
back-to-top.ts the back-to-top button
styles/global.css Tailwind CSS v4: brand tokens and component classes
template.json gallery metadata for freeframe.dev
README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
All design tokens live in the @theme block of src/styles/global.css:
| Token | Value | Used for |
|---|---|---|
--color-paper |
#f6f6f3 |
Page background |
--color-sheet |
#ffffff |
Raised surfaces: the estimator panel and the enquiry 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 (Astro rebuilds the CSS on
the next pnpm dev change or pnpm build):
@theme {
--color-signal: #2b50ff;
--color-signal-ink: #1f3ad1;
}The accent also appears as a hex value in logoMark in src/lib/icons.ts, public/img/favicon.svg,
public/img/og-image.svg, the floor plan in src/pages/studio.astro and the 404 drawing. Search for e8381e to find
them. themeColor in src/lib/site.ts sets <meta name="theme-color">. Each project has its own palette for its
artwork (see Content).
Useful component classes in global.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 Google Fonts are loaded by one <link> in the <head> of src/layouts/Layout.astro:
- Host Grotesk (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 token has a system fallback stack (Helvetica, Arial), so the site keeps its character if the fonts don't load. To swap a font, change the Google Fonts URL in the layout and the matching token:
--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 src/lib/art.ts; change those
too.
Content#
Repeated content is typed data in src/data/*.ts, rendered by the pages with .map(). One-off text (headlines,
intros, the floor plan) is in the page files.
Projects. PROJECTS in src/data/projects.ts drives the work index, the home page cards, the case studies, the
filter chip counts and the "(9)" after the Work heading. The array order is the order of the previous/next links and
breaks ties in the year sort. A project looks like this (shortened):
{
id: 'seeline', // project?id=seeline and public/img/work/seeline.svg
client: 'Seeline',
title: 'A calmer identity for the lake ferries',
disciplines: ['identity', 'wayfinding'], // keys of DISCIPLINES
year: 2025,
sector: 'Public transport',
location: 'Zürich',
duration: '14 months',
services: ['Identity system', 'Wayfinding', 'Timetable 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, Seeline' },
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', 'timetable'], sub: '1 May – 30 Sept 2025' },
sign: ['3', 'Departures', 'Rapperswil · Thalwil · Küsnacht'],
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 interface documents every field.
To add a project, add an object to PROJECTS with a new id and add public/img/work/<id>.svg. That's all: the work
index, chip counts, case study and previous/next links pick it up on the next build. To feature it on the home page,
put its id in FEATURED in src/data/home.ts (each entry also carries the grid classes that place the card). The
Work page's intro sentence ("Nine projects from the last five years…") is plain text in work.astro.
Disciplines. DISCIPLINES in projects.ts maps the filter keys to labels: identity, digital, editorial,
wayfinding. Adding a key adds a filter chip with its count.
Estimator and enquiry. The price model, budget bands and timeline choices are in src/data/estimator.ts (see
Brief estimator). The enquiry form's service, budget and timeline choices are rendered from the
same objects.
Services page. SERVICES, ENGAGEMENTS and FAQ in src/data/services.ts. Each service's recent lists
project ids; the links use the projects' client names.
Journal. NOTES in src/data/journal.ts, newest first; the home page shows the first three. Each note has an
id (it opens from journal#note-<id>), a datetime and display date, tags (keys of TAGS; the first one is
shown on the home page), a title, a teaser and body paragraphs. Body paragraphs are HTML, so links and <code>
work; write internal links with url():
body: [
`We started the <a class="link-under" href="${url("/project?id=seeline")}">Seeline</a> project with three grids…`,
],The topic chips and their counts come from TAGS and the notes.
Studio page. VALUES, PROCESS (each phase's bar classes position it on the timeline), TEAM and JOBS in
src/data/studio.ts. The team "portraits" are monogram tiles (tile sets their colours); replace the tile <div> in
studio.astro with an <img> at 4:5 if you have photos.
Home page. FEATURED, DISCIPLINE_ROWS, CLIENTS (each wordmark has its own type classes) and NUMBERS in
src/data/home.ts. Numbers count up from 0 when scrolled into view; the final value is in the HTML, which is what
visitors see without JavaScript or with reduced motion.
Contact details. The email address, phone number and studio hours are in site and STUDIO_HOURS in
src/lib/site.ts. The hours written on the contact page ("Mon–Thu, 09:00–18:00") are text in contact.astro; keep
them in step.
Rolling dates. The availability dates are <span data-months-ahead="n">, which src/scripts/main.ts fills
with 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 JOBS each job's start ("From January 2027", 3 and "From March 2027", 5) and the internship's
closes ("15 December 2026", 2, on the 15th). The dates in the pages are shown without JavaScript. 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 | Generated by ART in src/lib/art.ts from each project's palette and mark, at build time |
Floor plan (studio.astro), 404 drawing |
Inline SVG | Edit in place |
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 |
To use photos in the case study gallery instead of generated artwork, add a src to each gallery item (and to the
gallery type in projects.ts), put the files in public/img/, and in src/components/CaseStudy.astro replace the
set:html={ART[g.kind](p, g.caption)} div with:
<div class="overflow-hidden bg-tint"><img src={url(g.src)} alt={g.caption} class="block h-auto w-full"></div>Images in .cover frames use object-cover, so other ratios still fit. Keep width, height and a meaningful alt
in step when you replace them. Covers in cards have an empty alt because the link text names the project.
Navigation, header and footer#
The header and footer are components, so there is one place to change them:
- Links:
mainNavinsrc/data/nav.tsis used by the desktop menu, the numbered mobile menu and the footer's Index column;socialLinksfills the Elsewhere column. Write paths without.html("/journal"); the components pass them throughurl(). - Current page: each page passes
currentto<Layout>(current="services", or"home"for the logo link), which setsaria-current="page"on the matching links; the red square and colour come from thearia-[current]styles on.nav-linkand.mobile-link. The case study passessection="work"instead, which setsaria-current="true"because it is inside that section. - Footer: contact call to action and copy-email button, address, the large
aria-hidden"Raster." wordmark, the copyright line (the year is filled in by[data-current-year]), the "Show grid" toggle and a "Back to top" link, all inSiteFooter.astro.
To add a page, create src/pages/<name>.astro with <Layout title=... description=... current="<name>">, put the
content in <main id="main">, and add it to mainNav.
Features#
All behaviour is in src/scripts/main.ts (and back-to-top.ts), imported once from the layout. Each feature looks
for its data attributes and does nothing on pages without them.
Work index#
work.astro wraps everything in [data-work]. Each project is an <li data-project data-order data-year data-disciplines> inside <ul class="work-list" data-work-list data-view="grid">, rendered from PROJECTS.
- Filter. Chips with
data-filter="all|identity|digital|editorial|wayfinding"setaria-pressed. The choice is kept in the URL as?discipline=identity, so filtered views can be shared; unknown values fall back to all. - Sort. The
<select data-sort>sorts by year,newest(default) oroldest, kept as?sort=oldest. Items with the same year keep theirdata-order. - Count.
[data-work-count](aria-live="polite") reads "Showing all 9 projects", "Showing 3 of 9 projects" or "Showing 1 of 9 projects". - View. The two
[data-view-btn]buttons switchdata-viewon the list betweengridandlist; the choice is saved inlocalStorage(studio-work-view). Both layouts are pure CSS on the same markup (.work-list[data-view=…]rules inglobal.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, isaria-hiddenand never blocks the pointer. On phones the list shows a small thumbnail instead. - Empty state. If a filter matches nothing,
[data-work-empty]appears with a "Show all projects" button.
Case study#
Every case study is rendered at build time by src/components/CaseStudy.astro, so all text and artwork is in the
HTML. project.astro builds the first project into <article data-case-article data-rendered="seeline"> (that is
what /project shows, also without JavaScript) and every other project into a
<template data-case-template="<id>" data-title data-summary>. For project?id=<id>, main.ts copies the matching
template into the article and updates the page <title> and meta description. An unknown id removes the article and
shows [data-case-missing] with its heading promoted to the page's <h1>. Previous and next links wrap around the
PROJECTS array. Each gallery image is an inline SVG with role="img" and its caption as the accessible name.
Brief estimator#
The estimator on services#estimator is the [data-estimator] block. Its price model is in
src/data/estimator.ts:
export const SCOPE = {
strategy: { label: "Brand strategy", hint: "Positioning, audiences, messaging", cost: [12000, 18000], weeks: [3, 4] },
identity: { label: "Identity system", hint: "Mark, type, colour, guidelines", cost: [28000, 45000], weeks: [6, 9] },
…
};
export const SCALE = { focused: { label: "Focused", hint: "…", mult: 0.75, pace: 0.8 }, standard: …, extensive: … };
export const URGENCY = { relaxed: { label: "Relaxed", hint: "…", mult: 0.95, pace: 1.2 }, standard: …, priority: … };The calculation is estimateBrief() in src/lib/estimate.ts, used both by the page (for the figures in the HTML)
and by main.ts (live updates):
- Budget = the sum of the selected services'
cost×SCALE.mult×URGENCY.mult, low rounded down and high rounded up to CHF 1’000, shown with a Swiss thousands separator bychf()(CHF 63’000–105’000). - Duration = the longest selected service plus half of the others (phases overlap), ×
SCALE.pace×URGENCY.pace, rounded to whole weeks (11–17 weeks). - Breakdown lists each selected service and the combined multiplier.
- Send this brief links to
contact?services=identity,website&budget=60-120&timeline=standard. The budget band is picked from the midpoint of the range usingBUDGETS.
The tiles, their price hints ("CHF 12–18k") and the default selection (DEFAULT_BRIEF) are rendered from these
objects, so changing a number updates the page too. Choices are saved in localStorage (see
Saved data); Reset restores the defaults. Results are announced through a visually hidden
aria-live region.
Project enquiry#
The form on /contact ([data-enquiry]) has five <section data-step> steps: Project, Budget and timeline, Details,
About you and Review. "Next" validates the current step before moving on; the step tabs above the form let visitors
jump back to any step they have reached. 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.
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,
[data-prefill-note] appears.
Journal filters and notes#
journal.astro wraps the notes in [data-journal]. Topic chips (data-tag) filter the notes, kept in the URL as
?tag=process; [data-note-count] announces the result with correct singular/plural wording. Each note's "Read
note" button toggles aria-expanded and the hidden body. Opening journal#note-figures expands that note (clearing
a filter that would hide it), and so does changing the hash later.
Clock, studio status and small extras#
- Zürich clock. Any
<time data-clock>(in the header and the mobile menu) shows the current time in Zürich, updated every 15 seconds. - Studio status.
[data-studio-status]on/contactsays whether the studio is open now, usingSTUDIO_HOURSinsrc/lib/site.ts(dayswith 0 = Sunday,openandclosein 24-hour decimals,tz). - Copy email. A
<button data-copy="hello@raster.example">copies the address and briefly shows "Copied". The label sits in anaria-liveregion. Browsers only allow copying onhttps://orlocalhost; elsewhere the label asks the visitor to copy it by hand. - Grid overlay. The footer's "Show grid" button (
[data-grid-toggle],aria-pressed) overlays the 12-column grid in translucent red, handy while you edit layouts. Remove the button to drop it.
Reveals and motion#
.reveal elements fade up as they scroll into view; .rise headings lift each line (<span><span>…</span></span>)
into place; .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, which an inline script in the layout's <head> sets.
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; Escape or a link closes it and focus returns to the button. The back-to-top button is created by
src/scripts/back-to-top.ts after you scroll about one screen; it hides while the footer's "Back to top" link is
visible. To remove it, delete that file, its import in Layout.astro and the .back-to-top styles in global.css.
Forms#
There are two forms. Neither sends anything until you connect it; each shows a success message that says so.
| Form | Page | Validates |
|---|---|---|
Project enquiry ([data-enquiry]) |
/contact |
At least one service; organisation; 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 ([data-newsletter]) |
/journal |
A valid email |
Validation is in checkField(), checkGroup() and validate() in main.ts. It reads standard attributes
(required, type="email", type="url", minlength, required checkboxes); a <fieldset data-group="name"> needs
at least one ticked box or chosen radio. 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. Fields re-check
as they are corrected.
Connecting to Formspree#
-
Create a form at formspree.io and copy its endpoint, for example
https://formspree.io/f/abcdwxyz. -
In
src/scripts/main.ts, find the enquirysubmithandler (look for "Demo only: nothing is sent") and replace the lines that show the success message with a request:tsfetch("https://formspree.io/f/abcdwxyz", { method: "POST", body: new FormData(enquiry), headers: { Accept: "application/json" }, }) .then((res) => { if (!res.ok) throw new Error("Request failed"); panel.hidden = true; success.querySelector("[data-success-name]")!.textContent = (field("name")?.value ?? "").trim().split(/\s+/)[0]; success.hidden = false; success.focus(); }) .catch(() => { alert("Sorry, something went wrong. Please email hello@raster.example."); }); -
Change the success text in
contact.astro([data-enquiry-success]) to say the message was sent, and remove the "This is a demo" note.
The newsletter form works the same way: replace the lines after "Demo only: connect your newsletter provider here"
with a request to your provider (Buttondown, Mailchimp and others accept a form POST), then update the
[data-newsletter-done] text in journal.astro. Checkbox groups submit one services value per ticked box.
The site is static, so there is no server code to change. If you need your own endpoint, add an Astro server adapter
(for example @astrojs/node) and an API route in src/pages/api/, or use a serverless function on your host.
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 | The estimator's Reset button removes it |
The keys are VIEW_KEY and BRIEF_KEY at the top of main.ts; readBrief() does the validation. Storage is
wrapped in try/catch, so private browsing modes that block it simply don't remember choices. To reset everything,
run localStorage.removeItem('studio-work-view'); localStorage.removeItem('studio-brief-v1'); in the browser
console.
SEO and sharing#
Each page passes title and description to <Layout>; the layout writes the <title>, meta description,
canonical URL, og:* tags and twitter:card, plus theme-color and the SVG favicon. Canonical and og:url use the
clean URL (/services, not /services.html), and og:image is the absolute URL of /img/og-image.png. Both are
built from site in astro.config.mjs, which reads SITE_URL:
SITE_URL=https://www.your-studio.ch pnpm buildor change the fallback (https://studio.example.com) in astro.config.mjs. The 404 page passes noindex, which
outputs <meta name="robots" content="noindex"> and no canonical.
The case study page is one URL with ?id=: the HTML contains the first project, and the script updates the title and
description for the others. If case studies matter for search, consider one page per project: create
src/pages/work/[id].astro with getStaticPaths() returning PROJECTS.map((p, index) => ({ params: { id: p.id }, props: { index } })) and render <CaseStudy index={index} />, then point the links at the new URLs.
For a sitemap, add the official @astrojs/sitemap integration, and add a public/robots.txt. For a real studio,
Organization or ProfessionalService structured data (JSON-LD) with your address helps search engines show your
details; add it as a <script type="application/ld+json"> in the layout's <head>.
Accessibility#
Built in:
- Skip link,
header/nav/main/footerlandmarks, one<h1>per page and a logical heading order. - Visible focus outlines in the accent colour; all interactive elements are native buttons, links and inputs.
- Filters, view toggle and grid toggle use
aria-pressed; counts and estimator results usearia-live="polite". - The work preview is decorative (
aria-hidden), appears on keyboard focus as well as hover and is dismissible with Escape. - The enquiry form moves focus to each step's heading, announces the step and links errors to their fields.
- Accordions are native
<details>; note toggles usearia-expandedandaria-controls. - Text colours meet WCAG AA: use
text-mute(nottext-faint) for secondary text, andtext-signal-inkfor red text on light backgrounds. prefers-reduced-motionturns 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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as services.html rather than services/index.html
(build.format: "file" in astro.config.mjs), and links point to /services without a trailing slash. Netlify,
Cloudflare Pages and GitHub Pages serve /services from services.html automatically; Vercel does with cleanUrls
(below). 404.html is served for unknown URLs by most hosts.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/servicesservesservices.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/servicestoservices.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, and every link and image written with url()
includes it. astro.config.mjs also sets compressHTML: false, so the spaces between words and inline tags that
start on a new line are kept.
Tailwind scans the .astro and .ts files for class names. Classes that only appear inside strings (the estimator
breakdown and enquiry summary in main.ts, the bar classes in studio.ts) must be written out in full so Tailwind can
find them.
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#
| Problem | Fix |
|---|---|
| A link or image 404s after deploying to a sub-folder | Write it with url("/path") rather than a hard-coded "/path", and build with DEMO_BASE_PATH set to the folder |
/services 404s but /services.html works |
Your host doesn't map clean URLs. On Vercel add { "cleanUrls": true }; elsewhere set build.format: "directory" |
| 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) |
| 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 it |
| Words run together after editing a page | A line break before an inline tag is kept because compressHTML is false; if you turn compression on, keep inline tags on the same line as the text before them |
| Fonts look different in the project covers | Covers are <img> SVGs, which can't load web fonts; their few labels fall back to Helvetica or Arial |
License#
MIT. Free for personal and commercial use, including client work. The license text is in LICENSE.txt in the
download. All artwork (project covers, generated case study images, floor plan, favicon and social image) is included
under the same license. Raster, its team, clients and projects are fictional.