Freeframe

Documentation

Roast

v1.0.1Updated Oct 16, 2026A bold shop for a specialty coffee roaster, with a subscription builder, brew guides and a working cart.

Overview#

Roast is a shop for First Crack Coffee, a fictional specialty coffee roaster in Baltimore: a home page, a coffee shop with roast, origin and process filters, product pages with bag sizes, grinds, a tasting profile and a brew recipe, a subscription builder that shows the price per cup and the next delivery dates, a slide-out cart, a three-step demo checkout, brew guides with a recipe scaler, a wholesale page with a volume estimator and enquiry form, a sourcing page, contact and FAQ, and a 404 page. Every coffee bag and bean illustration is drawn in SVG from the product data, so there are no photos to replace before launch. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.0. It builds to static HTML files with no UI framework, and can be hosted anywhere. See it live at freeframe.dev/templates/roast.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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/roast into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page. Each page passes a page prop to <Layout>, which becomes <body data-page="…">; src/scripts/main.ts reads it to decide which page features to start, so keep it when you copy a page.

URL File page What's on it
/ src/pages/index.astro home Hero with two bag illustrations, tasting-note ticker, this week's roasts (coffees with featured), roast levels, subscription steps, brew guide links, sourcing figures, reviews
/shop src/pages/shop.astro shop Coffee grid with roast, origin and process filters, sort, live count, removable filter chips and shareable URLs
/product src/pages/product.astro product One coffee, chosen by ?id=: bag and beans gallery, bag size, grind, quantity, tasting profile, origin details, brew recipe, related coffees
/subscribe src/pages/subscribe.astro subscribe Subscription builder (style, bag size, bags, frequency, grind), live summary with price per cup and next deliveries, sign-up form
/checkout src/pages/checkout.astro checkout Details, delivery and payment steps, order summary, confirmation; an empty-cart message when there is nothing to buy
/brew src/pages/brew.astro brew Six brew methods in tabs (#v60, #aeropress, #french, #moka, #cold, #espresso) and a recipe scaler
/wholesale src/pages/wholesale.astro wholesale Benefits, price tiers, volume estimator and enquiry form (#enquiry)
/about src/pages/about.astro about How we buy, prices paid per coffee, producers, visit call to action
/contact src/pages/contact.astro contact Contact form, roastery address and hours, FAQ (#faq)
404 src/pages/404.astro 404 Not-found page (most hosts show 404.html automatically)

Project structure#

roast/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), <body data-page>, header, footer, cart drawer, scripts
│  ├─ components/
│  │  ├─ Logo.astro          the logo link (bean mark + "First Crack")
│  │  ├─ SiteHeader.astro    announcement bar, header, cart button, mobile menu
│  │  ├─ SiteFooter.astro    footer links, newsletter form, copyright
│  │  └─ CartDrawer.astro    cart drawer, overlay and toast markup (filled by the script)
│  ├─ data/
│  │  ├─ coffees.ts          coffees, sizes, grinds, shipping, subscription styles, brew methods, wholesale tiers
│  │  ├─ faq.ts              contact page questions and answers
│  │  └─ nav.ts              header, mobile menu and footer links
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity: artwork, cart, shop, product, subscription, checkout, brew, wholesale, forms
│  │  └─ back-to-top.ts      the back-to-top button
│  └─ styles/
│     └─ global.css          Tailwind import, brand tokens (@theme), component classes
├─ public/img/               favicon.svg, og-image.svg (source), og-image.png (social image)
├─ astro.config.mjs          site URL, base path, file-style output
├─ eslint.config.mjs, tsconfig.json, package.json
└─ README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

The design tokens live in the @theme block of src/styles/global.css. Every utility such as bg-cobalt, text-navy or border-navy comes from them, so changing a value re-skins the whole site.

Token Default Used for
--color-butter #fff6df Page background
--color-cream #fffcf4 Cards and form fields
--color-oat #f6e9c8 Soft panels
--color-sun #ffcd4a Yellow blocks, stickers, secondary buttons
--color-cobalt / --color-cobalt-dark #2338b5 / #18268a Brand colour, primary buttons, links
--color-sky #d9e0ff Selected options, highlighted cards
--color-navy #141a3c Text, outlines and the offset "sticker" shadows
--color-muted #4b4f6e Secondary text
--color-tomato / --color-tomato-dark #e0522f / #b03417 Decoration / error text and stars
--color-leaf #23714a Success text
css
@theme {
  --color-cobalt: #0f6b5c;      /* a deep green instead of cobalt */
  --color-cobalt-dark: #0a4d42;
}

Keep text colours at WCAG AA contrast against butter and cream. The theme colour of the browser UI is themeColor in src/lib/site.ts. Component classes (btn-primary, btn-sun, btn-outline, btn-light, card, sticker, tag, eyebrow, field, option, pill, check) are defined in the same file; the artwork uses the navy outline colour #141a3c directly in art() in src/scripts/main.ts.

Typography#

Two Google Fonts are loaded in src/layouts/Layout.astro: Unbounded (headings, font-display) and Onest (body, font-sans). To swap them, change the Google Fonts <link> and the --font-display and --font-sans tokens in src/styles/global.css. The bag labels in the SVG artwork name the fonts too (font-family="Unbounded, …" and "Onest, …" in art()).

Content#

Page copy is plain markup in src/pages/*.astro. The data the script works from lives in src/data/coffees.ts:

Export What it is
COFFEES Every coffee: id, name, origin, region, process, roast, price (250 g, dollars), rating, reviews, optional badge and featured, notes, profile (acidity, sweetness, body, 1–5), producer, altitude, variety, harvest, bag (illustration colour), brew (a METHODS key), blurb
ORIGINS, ROASTS, PROCESSES The keys a coffee can use and their display names (also the shop filters)
SIZES Bag sizes; a size's price is Math.round(price × factor)
GRINDS Grind options for the product page, cart and subscription
FREE_SHIPPING, SHIPPING, MAX_QTY Free-shipping threshold, delivery methods and prices, highest quantity per line
STYLES, SUB_DISCOUNT, DOSE, FREQUENCIES, MAX_BAGS, ROAST_DAY, DELIVERY_LAG Subscription styles and prices, discount, grams per cup, schedules, bags per delivery, roast weekday and days to delivery
METHODS Brew methods: ratio, cup size, grind, temperature and time (product recipes and the brew scaler)
WHOLESALE_DOSE, TIERS Grams per drink and the price tiers of the wholesale estimator

To add a coffee, add an object to COFFEES:

ts
{
  id: 'santa-barbara', name: 'Santa Bárbara', origin: 'honduras', region: 'Santa Bárbara',
  process: 'washed', roast: 'medium', price: 20, rating: 4.8, reviews: 12,
  notes: ['Peach', 'Honey', 'Almond'], profile: { acidity: 3, sweetness: 4, body: 3 },
  producer: 'Finca La Montañita', altitude: '1,500 m', variety: 'Pacas', harvest: 'March 2026',
  bag: '#c86b98', brew: 'v60',
  blurb: 'Soft stone fruit and a honey finish.'
},

It appears in the shop, the filter counts, related coffees and the cart right away, and its page is /product?id=santa-barbara. A new origin, roast or process needs a key in ORIGINS, ROASTS or PROCESSES; the shop's filter checkboxes are rendered from those objects in src/pages/shop.astro.

Some numbers also appear as copy (the "Free shipping over $45" announcement, the FAQ, the wholesale tier table, the delivery notes on the checkout options). Update the copy when you change the settings.

Images and artwork#

There are no photos. art(coffee, view) in src/scripts/main.ts draws each coffee as an SVG: view 0 is the bag (colour from bag, a roast mark, the origin and the process on the label) and view 1 is a pile of beans in the roast's colour. Any element with data-art="<coffee id>" (and optionally data-view="1") is filled with the artwork when the page loads, as in the home page hero. The drawings are 400 × 500 (4:5); to use photos, replace the art(…) calls in coffeeCard(), renderDrawer(), renderCheckout() and initProduct() with <img> tags of the same aspect ratio and a meaningful alt.

The illustrations on the sourcing page and the icons are inline SVG in the page files. The social image is public/img/og-image.png (1200 × 630), exported from public/img/og-image.svg; the favicon is public/img/favicon.svg.

The header and mobile menu are rendered from mainNav and contactLink in src/data/nav.ts; the footer columns from footerCoffee, footerHelp and socials. Each page passes current (for example current="shop") to <Layout>, and the link with the matching key gets aria-current="page". To add a page, create src/pages/<name>.astro using <Layout> and add a link to mainNav. Write internal links with url() so they keep working under a base path.

Features#

Shop filters#

initShop() in src/scripts/main.ts. The checkboxes in #filters filter by roast, origin and process (options in a group are combined with "or", groups with "and"); #sort orders by featured, price, roast or name. The result count (#result-count) is a polite live region, active filters show as removable chips, and the URL is kept in sync (/shop?roast=light,medium&origin=ethiopia&sort=price-asc), so filtered views can be shared and linked to, as the footer does. Parameter names and values are read case-insensitively, unknown values are ignored, and the URL is rewritten in canonical form. On phones the filters fold behind a "Filters" button with aria-expanded.

Product page#

initProduct(). Reads ?id= (case-insensitive; unknown or missing ids show the first coffee), sets the title, text and links, draws the gallery (bag and beans, aria-pressed thumbnails), renders the size options with prices from SIZES and the grind options from GRINDS, and keeps the add-to-cart total up to date. Quantities are clamped to 1–MAX_QTY. The brew card shows the recipe from METHODS[coffee.brew] for one cup, and "Build a plan" links to the subscription builder with the matching style.

Cart drawer#

The cart button in the header, the "View cart" link in toasts and any [data-cart-open] open the drawer. It lists each line with its quantity stepper, remove button and line total, shows progress to free shipping (an ARIA progress bar) and the subtotal. Focus moves into the drawer and is trapped there; Escape, the close button and the overlay close it and return focus. "Add 250 g" buttons on coffee cards ([data-add]) add a 250 g whole-bean bag and show a toast. Other open tabs update through the storage event.

Subscription builder#

initSubscribe(). The five steps are fieldsets (#step-style, #step-size, #step-bags, #step-freq, #step-grind) inside #plan-form; the summary shows the plan, the price per delivery, the price per cup (DOSE grams per cup), an approximate monthly cost, the saving compared with shop prices and the next three delivery dates. "Change" buttons scroll to their step and focus its current choice. The plan is saved as you go and restored on the next visit; ?style=bright (used by product pages) preselects a style.

Delivery dates come from deliveryDates(): the next ROAST_DAY after today plus DELIVERY_LAG days, then every freq weeks, formatted in US English. They are calculated in the browser, so they are always relative to the visitor's today.

Checkout#

initCheckout(). Three steps in #checkout-form (details, delivery, payment); each step is validated before moving on, Back and Edit buttons move between steps, and the step list marks the current step with aria-current="step". The summary lists the cart, shipping for the chosen method (standard is free from FREE_SHIPPING) and the total. The card number and expiry are formatted as you type. Placing the order shows a confirmation with a random order number and empties the cart; no payment is taken and nothing is sent. To take real payments, send the cart to a hosted checkout such as Stripe Checkout from a server or serverless function instead of collecting card details in this form.

Brew guides#

initBrew(). The method buttons are an ARIA tablist: arrow keys, Home and End move between them, and the open tab is written to the URL hash, so /brew#aeropress (any letter case) opens that method. The recipe scaler multiplies the method's cup size by the number of cups and divides by its ratio (one point stronger or lighter with the strength menu; espresso ignores strength).

Wholesale estimator#

initWholesale(). Drinks per day × WHOLESALE_DOSE grams × days open gives kilos per week, which picks a tier from TIERS and a weekly cost. The matching row of the price table ([data-tier]) is highlighted.

Back to top button#

src/scripts/back-to-top.ts adds a button that appears after about one screen of scrolling, returns to the top (instantly with reduced motion) and moves focus to the main content. It lifts itself above toasts. Mark your own fixed UI with data-back-to-top-avoid or data-back-to-top-hide. To remove it, delete the file, its import in src/layouts/Layout.astro and the .back-to-top styles in src/styles/global.css.

Forms#

All forms are validated by setupValidation() (or, for checkout, validate() per step) in src/scripts/main.ts: errors appear on blur and submit, the first invalid field is focused, and each error is linked with aria-describedby. Rules come from attributes:

Attribute Rule
required Must not be empty (checkboxes must be ticked)
type="email" Must look like an email address
type="tel" Digits, spaces and +()-. only
minlength Minimum length
data-label Name used in the error message
data-validate zip, order, card, expiry or cvc
data-group (on a fieldset) At least one checkbox ticked; message from data-msg
Form Where Behaviour
Newsletter (data-form="newsletter") Every footer Replaced with a thank-you message
Contact (data-form="contact") /contact Replaced with a thank-you message
Wholesale (data-form="wholesale") /wholesale#enquiry Replaced with a thank-you message
Subscription (data-form="subscribe") /subscribe#start Replaced with a summary of the plan
Checkout (#checkout-form) /checkout Shows the order confirmation

Nothing is sent. To collect submissions, post the form in its callback in section 14 of src/scripts/main.ts, for example to Formspree:

ts
$$<HTMLFormElement>('[data-form="contact"]').forEach((form) => setupValidation(form, () => {
  fetch("https://formspree.io/f/your-id", {
    method: "POST", body: new FormData(form), headers: { Accept: "application/json" },
  }).then((res) => {
    successSwap(form, res.ok ? "<p>Thanks for writing.</p>" : "<p>Something went wrong. Please email us.</p>");
  });
}));

FormData only includes fields with a name; every field already has one. To handle submissions with your own code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/contact.ts with export const prerender = false, then post to it.

Saved data (localStorage)#

Key Value Used for
firstcrack-cart JSON array of { id, size, grind, qty } Cart contents
firstcrack-plan JSON { style, size, bags, freq, grind } The subscription plan being built

Both are validated every time they are read. normalizeCart() keeps only known coffee ids (matched case-insensitively), replaces unknown sizes and grinds with the defaults, clamps quantities to 1–MAX_QTY, merges duplicate lines and drops anything else; tampered data is overwritten with the cleaned version. normalizePlan() does the same for the plan, falling back to DEFAULT_PLAN field by field. Prices are never stored: every total is calculated from src/data/coffees.ts. If storage is blocked, the shop still works but forgets the cart after a reload. To reset, run localStorage.removeItem("firstcrack-cart") (or "firstcrack-plan") in the browser console.

SEO and sharing#

  • Each page passes title and description to <Layout>, which outputs <title>, the meta description, og:title, og:description and a summary_large_image Twitter card. The product page also sets document.title from the coffee.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://roast.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build
  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • /product is one page filled in by JavaScript from ?id=, so search engines that don't run scripts see the default coffee. If product search traffic matters, generate a page per coffee with getStaticPaths() from COFFEES (for example src/pages/coffee/[id].astro).

  • For a sitemap, add @astrojs/sitemap (pnpm astro add sitemap) and a public/robots.txt that points to it.

Accessibility#

Built in: a skip link, landmarks, visible focus rings, a focus-trapped cart drawer that closes with Escape, a mobile menu with aria-expanded that closes with Escape, the cart count in the cart button's label, polite toasts, keyboard-operable radio cards and pills, an ARIA tablist on the brew page, aria-pressed gallery thumbnails, text alternatives on the artwork, live regions for the shop count, plan summary, scaler and estimator, an ARIA progress bar for free shipping, inline form errors linked to fields, reduced-motion support (no reveal animations, no ticker, instant scrolling) and WCAG AA contrast. Content still shows without JavaScript except the parts drawn from the catalogue (artwork, coffee grids, cart and prices).

When editing, keep one <h1> per page, keep a <label> and data-label on every form field, and keep coffee names meaningful (they are the artwork's text alternative).

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as shop.html rather than shop/index.html (build.format: "file" in astro.config.mjs), and links point to /shop without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /shop from shop.html automatically; Vercel does with cleanUrls (below).

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /shop serves shop.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /shop to shop.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/roast pnpm build. It becomes Astro's base, and every link written with url(), including the links the script builds, includes it.

Tailwind finds class names in src/ automatically, including the ones inside strings in src/scripts/main.ts, as long as they're written in full. 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#

  • A new class has no effect. Write class names in full (not built from pieces) so Tailwind can find them.
  • A product page shows Kochere Lot 7 instead. The ?id= doesn't match any coffee id.
  • A coffee is missing from a filter. Its origin, roast or process must be a key of ORIGINS, ROASTS or PROCESSES.
  • The cart or plan is forgotten. localStorage is blocked, or the coffee's id changed.
  • Delivery dates look a day off. They use the visitor's local date and ROAST_DAY/DELIVERY_LAG; change those two numbers to match your roasting schedule.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links with url() (in .astro files and in src/scripts/main.ts) rather than plain "/..." strings.
  • Words run together after editing. Astro removes line breaks between text and a following inline element. Keep the text and the element on one line, or add {" "}.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.

License#

MIT. The code and artwork are free for personal and commercial use; the download includes the license text in LICENSE.txt. Unbounded and Onest are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation