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 case studies rendered from typed data, a services page with a brief estimator, a studio page, a journal and a five-step project enquiry. This is the Next.js version (Next.js 16 App Router, React 19, TypeScript, Tailwind CSS v4, static export), version 1.0.0. See it live at freeframe.dev/templates/studio.
Quick start#
Requirements: Node.js 24 and pnpm.
pnpm install
pnpm devOpen http://localhost:3000. Pages reload as you edit. Other scripts:
| Script | What it does |
|---|---|
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types and run TypeScript |
Pages#
| Route | File | What's on it |
|---|---|---|
/ |
src/app/page.tsx |
Typographic hero with availability, selected work with hover labels, services list, client wordmarks, count-up numbers, journal teaser, contact call to action |
/work |
src/app/work/page.tsx |
All nine projects with discipline filters, year sort, live count, grid/list view toggle and a hover/focus preview in list view |
/project?id=<id> |
src/app/project/page.tsx |
Case study: facts, cover, overview, challenge, three artwork images, approach, results, quote, credits, previous/next project |
/services |
src/app/services/page.tsx |
Four disciplines with deliverables, timelines and starting prices, three engagement models, the brief estimator and an FAQ |
/studio |
src/app/studio/page.tsx |
Story, floor plan of the studio, values, process timeline, team, open roles and visiting details |
/journal |
src/app/journal/page.tsx |
Eight short notes with topic filters, expandable text and a newsletter sign-up |
/contact |
src/app/contact/page.tsx |
Five-step project enquiry with a review step, plus email, phone and studio hours with a live open/closed note |
| 404 | src/app/not-found.tsx |
"Off the grid" page with links back into the site |
Project structure#
studio/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx <html>, skip link, header, footer, page effects, back to top, default metadata
│ │ ├─ fonts.ts Host Grotesk and Fragment Mono with next/font/local
│ │ ├─ globals.css Tailwind entry: @theme tokens and component classes
│ │ ├─ icon.svg favicon
│ │ ├─ page.tsx home
│ │ ├─ not-found.tsx 404
│ │ └─ work/, project/, services/, studio/, journal/, contact/ one page.tsx per route
│ ├─ components/
│ │ ├─ SiteHeader.tsx sticky header, current page, mobile menu (client)
│ │ ├─ SiteFooter.tsx footer; FooterTools.tsx holds the copy-email button, grid overlay and year (client)
│ │ ├─ Clock.tsx Zürich clock and the studio open/closed note (client)
│ │ ├─ PageEffects.tsx scroll reveals, headline lines, process bars, count-ups (client)
│ │ ├─ BackToTop.tsx back-to-top button (client)
│ │ ├─ WorkBrowser.tsx work index: filters, sort, view, preview (client)
│ │ ├─ CaseStudy.tsx a case study from one project, and the "not found" state
│ │ ├─ CaseFromUrl.tsx picks the case study from ?id= and updates the title (client)
│ │ ├─ ProjectArt.tsx the generated SVG artwork (board, screens, spread, poster, signage, mark)
│ │ ├─ Estimator.tsx brief estimator (client)
│ │ ├─ Enquiry.tsx five-step project enquiry (client)
│ │ ├─ JournalBrowser.tsx journal filters and notes (client); Newsletter.tsx, the sign-up form (client)
│ │ └─ FloorPlan.tsx, HeadlineLines.tsx, Icons.tsx, SectionHead.tsx small shared pieces
│ ├─ data/
│ │ ├─ projects.ts DISCIPLINES and PROJECTS (the case studies)
│ │ ├─ estimator.ts SCOPE, SCALE, URGENCY, BUDGETS, DEFAULT_BRIEF (the price model)
│ │ ├─ enquiry.ts the enquiry's steps, timelines and "How did you hear" options
│ │ ├─ journal.tsx TAGS and NOTES
│ │ ├─ services.ts SERVICES, ENGAGEMENTS, FAQ
│ │ ├─ studio.ts VALUES, PROCESS, TEAM, ROLES
│ │ └─ site.ts name, contact details, NAV, STUDIO_HOURS
│ ├─ lib/
│ │ ├─ estimate.ts estimateBrief(), chf(), the saved brief (localStorage)
│ │ ├─ validate.ts form validation messages and ARIA helpers
│ │ ├─ use-press-guard.ts skips on-blur checks while Next, Back or Send is pressed
│ │ ├─ zurich-time.ts Zürich time and opening hours hooks
│ │ ├─ url.ts setQuery(): keep filters in the URL
│ │ ├─ use-document-meta.ts title and description for content chosen in the browser
│ │ ├─ metadata.ts pageMeta(): per-page title, description and Open Graph
│ │ └─ asset.ts asset(): prefixes public/ paths with the base path
│ └─ fonts/ woff2 files and their SIL Open Font License texts
├─ public/img/
│ ├─ work/*.svg project covers, one per project id (800×600)
│ ├─ og-image.svg editable source of the social sharing image
│ └─ og-image.png 1200×630 social sharing image
├─ next.config.ts static export and basePath
├─ eslint.config.mjs, tsconfig.json, postcss.config.mjs, package.json, pnpm-workspace.yaml
└─ README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
All design tokens live in the @theme block of src/app/globals.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:
@theme {
--color-signal: #2b50ff;
--color-signal-ink: #1f3ad1;
}The accent also appears as a hex value in LogoMark (src/components/Icons.tsx), src/app/icon.svg,
public/img/og-image.svg, src/components/FloorPlan.tsx, the 404 drawing in src/app/not-found.tsx and the
viewport theme colour in src/app/layout.tsx. Search for e8381e to find them. Each project has its own palette
for its artwork (see Content).
Useful component classes in globals.css:
| Class | What it does |
|---|---|
.wrap |
The page container (max-w-[90rem] with side padding) |
.grid-12 |
The grid every section sits on: 4 columns on phones, 12 from sm |
.label, .idx, .dot |
Small uppercase mono labels, index numbers and the red square |
.display, .h-section, .lead |
Display headings, section headings and lead paragraphs |
.btn + .btn-ink, .btn-line, .btn-line-light, .btn-sm |
Square-cornered buttons |
.link-line, .link-under |
An underline that draws in on hover, and a quiet permanent underline |
.chip, .seg, .seg-btn |
Filter chips and the grid/list segmented control |
.input, .field-label, .field-hint, .field-error, .choice, .choice-box |
Form fields and choice tiles |
.acc, .acc-icon |
Native <details> accordions with a plus/minus icon |
.cover |
An image frame that zooms its image slightly on hover |
.work-list, .work-item, .work-link, .work-row… |
The two layouts of the work index (see Work index) |
.reveal, .rise, .fill-bar |
Scroll reveal, headline line reveal and the process bars |
Typography#
Two fonts are self-hosted from src/fonts/ with next/font/local in src/app/fonts.ts (the same Latin woff2 files
Google Fonts serves, so text sets exactly as in the HTML version):
- Host Grotesk (variable, 300–700, plus italic 400) for everything:
--font-sans. Headings use weight 500 with tight negative letter-spacing; the team monograms use 300. - Fragment Mono for labels, index numbers and dates:
--font-mono.
Each font keeps its real family name, and each token has a system fallback stack (Helvetica, Arial), so the site keeps
its character while the fonts load. To swap a font, add its woff2 file to src/fonts/, point localFont() at it with
the new family name, and change the token:
export const hostGrotesk = localFont({
src: [{ path: "../fonts/InterTight-latin.woff2", weight: "300 700", style: "normal" }],
declarations: [{ prop: "font-family", value: "Inter Tight" }],
display: "swap",
preload: false,
adjustFontFallback: false,
});--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/components/ProjectArt.tsx; change those too.
Content#
Page copy lives in the page files under src/app/. Everything that repeats is data in src/data/:
| File | Data |
|---|---|
projects.ts |
DISCIPLINES, PROJECTS: every case study, the work index and the home page cards |
estimator.ts |
SCOPE, SCALE, URGENCY, BUDGETS, DEFAULT_BRIEF (see Brief estimator) |
journal.tsx |
TAGS (the topic filters) and NOTES |
services.ts |
SERVICES (the four disciplines, also listed on the home page), ENGAGEMENTS, FAQ |
studio.ts |
VALUES, PROCESS, TEAM, ROLES |
site.ts |
Name, email, phone, address, social links, NAV and STUDIO_HOURS |
Projects. A project in PROJECTS looks like this (shortened):
{
id: "seeline", // /project?id=seeline and the cover file name
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 type in projects.ts documents every field.
To add a project, add an object to PROJECTS with a new id and add public/img/work/<id>.svg. The work index, its
counts, the "(9)" after the heading, the case study page and the previous/next links all follow from the array. The
array order (newest first) is the order of the previous/next links and breaks ties in the year sort. The four
projects on the home page are listed by id in SELECTED in src/app/page.tsx.
Disciplines. DISCIPLINES maps the filter keys to labels: identity, digital, editorial, wayfinding. Add a
key there and the work index gets a chip for it.
Journal notes. Each note in NOTES has an id (its anchor is #note-<id>), a date, tags (keys of TAGS), a
title, a one-line dek and a body with one entry per paragraph (plain strings, or JSX for links and code). The
first three notes appear on the home page, labelled with their first tag. Chip counts are worked out from the data.
Count-up numbers. The home page numbers are NUMBERS in src/app/page.tsx, rendered as
<span data-count="140">140</span>. The markup keeps the final value: that is what visitors see without JavaScript or
with reduced motion.
Team, roles and services. Edit TEAM, ROLES (in studio.ts) and SERVICES (in services.ts). The team
"portraits" are monogram tiles; tile sets their colours. Replace the tile <div aria-hidden="true"> in
src/app/studio/page.tsx with an <img> at 4:5 if you have photos.
Rolling dates. The availability dates use <MonthsAhead> (src/components/MonthsAhead.tsx), which shows the
month n months from now and its year: "From Feb 2027" and "Booking projects from February 2027" on the home page (4),
and in ROLES each job's start ("From January 2027", 3 and "From March 2027", 5) and the internship's closes ("15
December 2026", 2, on the 15th). The static build shows the fallback text and the browser the rolling date, so they
never go stale. For fixed dates, write them as plain text instead.
Images and artwork#
There are no photos; all artwork is SVG.
| Artwork | Size / ratio | Notes |
|---|---|---|
public/img/work/*.svg |
800×600 (4:3) | Project covers in the grid, list preview, home page and case study. Replace with 4:3 images, at least 1600×1200 |
| Case study gallery | 1600×900 and 800×1000 | React components in src/components/ProjectArt.tsx, drawn from each project's palette and mark |
| Floor plan, 404 drawing | Inline SVG | src/components/FloorPlan.tsx, src/app/not-found.tsx |
public/img/og-image.png |
1200×630 | Social image. Edit og-image.svg, then export it to PNG at 1200×630 |
src/app/icon.svg |
32×32 | Browser tab icon |
Images in public/ are referenced through asset() (src/lib/asset.ts), which adds the base path when the site is
built for a sub-folder. Keep using it for new images. To show photos in a case study gallery instead of generated
artwork, add a src to each gallery item and render an <img> in place of <ProjectArt> in
src/components/CaseStudy.tsx:
<img src={asset(g.src)} alt={g.caption} className="block h-auto w-full" />Images in .cover frames use object-cover, so other ratios still fit. Covers in cards have an empty alt because
the link text names the project.
Navigation, header and footer#
The menu items are NAV in src/data/site.ts; the header (SiteHeader.tsx), the mobile menu and the footer index
all render from it. The current page gets aria-current="page" automatically (from usePathname()), which draws the
red square; on a case study, Work gets aria-current="true" because it is inside that section.
The footer (SiteFooter.tsx) has the contact call to action, address, an index of pages, external links, a large
aria-hidden "Raster." wordmark, the copyright line (the year updates in the browser), the "Show grid" toggle and a
"Back to top" link.
Features#
Work index#
WorkBrowser (src/components/WorkBrowser.tsx) renders the toolbar and the list. Each project is an
<li data-project data-order data-year data-disciplines> inside <ul class="work-list" data-view="grid">.
- Filter. Chips with
data-filter="all|identity|digital|editorial|wayfinding"setaria-pressed; their counts come from the data. The choice is kept in the URL as?discipline=identity, so filtered views can be shared; unknown values fall back to all. - Sort. The sort
<select>sorts by year,newest(default) oroldest, kept as?sort=oldest. Projects with the same year keep their order inPROJECTS. - Count.
[data-work-count](aria-live="polite") reads "Showing all 9 projects" or "Showing 3 of 9 projects". - View. The Grid and List buttons switch
data-viewon the list; the choice is saved inlocalStorage(studio-work-view). Both layouts are pure CSS on the same markup (.work-list[data-view=…]rules inglobals.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 discipline has no projects, "No projects in this discipline yet." appears with a button that shows all projects again.
The static HTML lists every project in the default order; the filters from the URL apply in the browser
(WorkBrowserFromUrl reads them with useSearchParams() inside a <Suspense> boundary). setQuery() in
src/lib/url.ts keeps the URL in step without adding history entries.
Case study#
/project?id=<id> shows the matching PROJECTS entry, rendered by CaseStudy (src/components/CaseStudy.tsx).
The static page contains the first project's case study, artwork included; CaseFromUrl picks another one from ?id=
in the browser and keeps the tab title and meta description in step (useDocumentMeta). Without ?id= it shows the
first project; an unknown id shows "We couldn’t find that project." as the page's <h1>. Previous and next links wrap
around the PROJECTS array. Each gallery image is an inline SVG with role="img" and its caption as the accessible
name.
Search engines see the first case study only. If case studies matter for SEO, turn them into real routes: move the
page to src/app/work/[id]/page.tsx, export generateStaticParams() returning every project id, and render
<CaseStudy index={…} /> from the id param (then update the links that point to /project?id=).
Brief estimator#
Estimator (src/components/Estimator.tsx) on /services#estimator. Its price model is in
src/data/estimator.ts:
export const SCOPE = {
strategy: { label: "Brand strategy", note: "Positioning, audiences, messaging", cost: [12000, 18000], weeks: [3, 4] },
identity: { label: "Identity system", note: "Mark, type, colour, guidelines", cost: [28000, 45000], weeks: [6, 9] },
// …
};
export const SCALE = { focused: { label: "Focused", note: "…", mult: 0.75, pace: 0.8 } /* standard, extensive */ };
export const URGENCY = { relaxed: { label: "Relaxed", note: "…", mult: 0.95, pace: 1.2 } /* standard, priority */ };estimateBrief() in src/lib/estimate.ts does the sums:
- Budget = the sum of the selected services'
cost×SCALE.mult×URGENCY.mult, low rounded down and high rounded up to CHF 1’000, shown with a Swiss thousands separator (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. - 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 choice tiles, their price hints ("CHF 12–18k") and the breakdown are all generated from this data. The default
selection is DEFAULT_BRIEF. Choices are saved in localStorage (see
Saved data); Reset restores the defaults. Results are announced through a visually hidden
aria-live region.
Project enquiry#
Enquiry (src/components/Enquiry.tsx) on /contact has five steps: Project, Budget and timeline, Details, About you
and Review (STEPS in src/data/enquiry.ts). "Next" validates the current step before moving on; the step tabs above
the form go back to any step already reached and are disabled beyond it. Moving between steps focuses the new step's
heading and announces "Step 2 of 5: Budget and timeline" through [data-step-live]. The Review step lists every
answer with an "Edit" button that jumps to the right step. The details field shows a live character count ("40 more
characters needed"). Sending re-checks every step and jumps to the first one with a problem.
It is prefilled from the estimator link: ?services= (comma-separated SCOPE keys), ?budget= (under-30,
30-60, 60-120, 120-plus) and ?timeline= (relaxed, standard, priority). When anything is prefilled, a note
says so.
Journal filters and notes#
JournalBrowser (src/components/JournalBrowser.tsx). Topic chips (data-tag) filter the notes, kept in the URL as
?tag=process; [data-note-count] announces the result ("Showing all 8 notes", "Showing 4 of 8 notes"). Each note's
"Read note" button toggles aria-expanded and the hidden body. Opening /journal#note-figures (as the home page
teasers do) expands that note and scrolls to it, clearing a filter that would hide it; changing the hash on the page
does the same.
Clock, studio status and small extras#
- Zürich clock.
<Clock>(src/components/Clock.tsx) renders<time data-clock>with the current time in Zürich, updated every 15 seconds. The static HTML shows "CET" until the browser knows the time, so the server and the browser never disagree. - Studio status.
<StudioStatus>on/contactsays whether the studio is open now, usingSTUDIO_HOURSinsrc/data/site.ts(dayswith 0 = Sunday,openandclosein 24-hour decimals,tz). Keep it in step with the hours written on the page. - Copy email.
<CopyEmail>(src/components/FooterTools.tsx) 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 (
<GridToggle>,aria-pressed) overlays the 12-column grid in translucent red, handy while you edit layouts. Remove it fromSiteFooter.tsxto drop it.
Reveals and motion#
PageEffects (src/components/PageEffects.tsx) runs on every page: .reveal elements fade up as they scroll into
view; .rise headings lift each line into place (<Lines> in HeadlineLines.tsx writes the
<span><span>…</span></span> markup); .fill-bar bars on the studio page grow from the left; numbers with
data-count count up. Everything above a revealed element is revealed too, so fast jumps never leave hidden content.
With prefers-reduced-motion: reduce all of this is off and content is shown immediately. Without JavaScript nothing
is hidden (the styles only apply under html.js, set by a one-line script in layout.tsx).
Header, mobile menu and back to top#
The header is sticky. Below 1024 px the "Menu" button (#menu-button, aria-expanded, aria-controls) opens a
full-height menu and stops the page behind it from scrolling; Escape or a link closes it and Escape returns focus to
the button. BackToTop.tsx shows a button after you scroll about one screen; it hides while the footer's "Back to top"
link is visible. To remove it, delete <BackToTop /> from layout.tsx and the .back-to-top styles in
globals.css.
Forms#
There are two forms. Neither sends anything until you connect it; each shows a success message that says so.
| Form | Component | Validates |
|---|---|---|
| Project enquiry | src/components/Enquiry.tsx |
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 | src/components/Newsletter.tsx |
A valid email |
The rules and messages are in src/lib/validate.ts (fieldError()) and RULES at the top of Enquiry.tsx. Errors
appear under each field (.field-error), are linked with aria-describedby and mark the field aria-invalid="true";
the first invalid field receives focus. A field that shows an error re-checks as it is corrected. Fields also check
themselves when you leave them, except while Next, Back, Send or Subscribe is being pressed (usePressGuard() in
src/lib/use-press-guard.ts): those buttons check the form anyway, and an error appearing mid-click would push the
button away and lose the click.
Connecting to Formspree#
-
Create a form at formspree.io and copy its endpoint, for example
https://formspree.io/f/abcdwxyz. -
In
Enquiry.tsx, findsubmit()(look for "Demo only: nothing is sent") and post the answers before showing the success message.valuesholds every answer:tsxconst res = await fetch("https://formspree.io/f/abcdwxyz", { method: "POST", body: JSON.stringify({ ...values, services: values.services.join(", ") }), headers: { "Content-Type": "application/json", Accept: "application/json" }, }); if (!res.ok) return alert("Sorry, something went wrong. Please email hello@raster.example.");Make
submitanasyncfunction for this. -
Change the success text (
[data-enquiry-success]at the bottom ofEnquiry.tsx) to say the message was sent, and remove the "This is a demo" note.
The newsletter form works the same way: in Newsletter.tsx, send email to your provider where it says "Demo only"
(Buttondown, Mailchimp and others accept a POST), then update the [data-newsletter-done] text.
If you remove the static export (see Building and deploying), you can post to a Route
Handler instead (src/app/api/enquiry/route.ts with export async function POST(request: Request)) or call a Server
Action from submit().
Saved data (localStorage)#
| Key | Shape | Validation on read | Reset |
|---|---|---|---|
studio-work-view |
"grid" or "list" |
Anything other than "list" means grid |
Switch the view, or remove the key |
studio-brief-v1 |
{"scope":["identity","website"],"scale":"standard","urgency":"standard"} |
Unknown or duplicate scope keys are dropped; an unknown scale or urgency falls back to the default; invalid JSON means defaults (readBrief() in src/lib/estimate.ts) |
The estimator's Reset button removes it |
Both are read after the page has loaded (the static HTML shows the defaults) and storage access 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#
Every page exports metadata built with pageMeta(title, description) (src/lib/metadata.ts): the exact <title>,
meta description and Open Graph tags with the social image. Defaults, the Twitter card and metadataBase are in
src/app/layout.tsx. The case study page's static metadata is the first project's; for other ?id= values the title
and description are updated in the browser.
Open Graph images need absolute URLs, which Next.js builds from metadataBase: set NEXT_PUBLIC_SITE_URL when you
build (or change site.url in src/data/site.ts):
NEXT_PUBLIC_SITE_URL=https://www.your-studio.ch pnpm buildAdd src/app/sitemap.ts and src/app/robots.ts (Next.js generates sitemap.xml and robots.txt from them in a
static export), and consider Organization or ProfessionalService structured data with your address on the home
page.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines in the accent 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 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 out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH. To host the site
in a sub-folder, build with that folder (DEMO_BASE_PATH=/studio pnpm build); links, scripts and asset() image URLs
all pick it up. Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the
repository into Vercel. Most hosts serve 404.html for missing pages automatically. demo/ is the freeframe.dev demo
build and can be deleted.
To use server features (Route Handlers, Server Actions), remove output: "export" from next.config.ts and deploy to
Vercel or any Node.js host with next build and next start.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API.
Replace both with typescript@7 once typescript-eslint supports it.
Troubleshooting#
| Problem | Fix |
|---|---|
| A project link shows "We couldn’t find that project." | The ?id= doesn't match an id in PROJECTS exactly (ids are lowercase with hyphens) |
| A new project has no cover | Add public/img/work/<id>.svg, named after the project's id |
| The work index always opens in list view | It remembers the last view in localStorage; switch back to Grid or remove studio-work-view |
| The estimator shows old selections | Choices are saved; press Reset, or remove studio-brief-v1 |
| "Copy email" says "Press Ctrl+C to copy" | Browsers only allow clipboard access on https:// or localhost; use pnpm dev, pnpm start or deploy |
| Images or links break when the site is in a sub-folder | Build with DEMO_BASE_PATH=/your-folder, and reference files in public/ through asset() |
| The social image doesn't show when sharing | Set NEXT_PUBLIC_SITE_URL to your domain when building so og:image is an absolute URL |
pnpm lint fails after upgrading TypeScript |
See TypeScript 7 setup |
License#
MIT. Free for personal and commercial use, including client work. The full 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. Host Grotesk and Fragment Mono are under the SIL Open Font License (src/fonts/*-OFL.txt). Raster, its
team, clients and projects are fictional.