Freeframe

Documentation

Crumb

v1.0.1Updated Oct 16, 2026A charming bakery and café website with click-and-collect preorders, a live bake board and a celebration cake builder.

Overview#

Crumb is a website for a neighbourhood bakery and café with click-and-collect preorders, built for Bramble & Rye, a fictional bakery on St Benedicts Street in Norwich. It has a live "fresh out of the oven" board, a preorder catalogue with per-day availability and quantity limits, a basket drawer with a collection day, time slot and order cut-off, a demo checkout with an order number and a calendar file, a celebration cake builder with a live drawing and price, a café menu with diet filters, an about page and a visit page with a wholesale form. This version is built with Astro 7, TypeScript and Tailwind CSS v4, and builds to plain static files. Product cards, the bake board, the café menu, the cake builder's options and every bake's SVG artwork are rendered at build time, so they are in the HTML. This guide covers version 1.0.0. See it live at freeframe.dev/templates/crumb.

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

To preview the site at another time of day (the bake board, open-now badges and cut-off all follow the bakery's clock), add ?now= to any page URL, for example /?now=2026-10-17T10:15.

Pages#

File Route Purpose and notable features
src/pages/index.astro / Home: hero with the shop-window illustration, live bake board, how preordering works, this week's specials (from the product data), celebration cake teaser, café teaser, reviews, visit us with open-now badge, hours and map
src/pages/shop.astro /shop Preorder: shelf filters with counts (/shop?category=bread), collection-day picker, "only that day's bakes" toggle, product cards grouped by shelf, add buttons and steppers, allergen note
src/pages/cakes.astro /cakes Celebration cakes: builder for size, sponge, filling, finish, colour, message and collection date; live SVG preview, price breakdown and allergens; recent cakes; cake FAQ
src/pages/cafe.astro /cafe Café menu: coffee, tea, brunch and sweet things with diet filters (/cafe?diet=vegan,gf), café notes
src/pages/checkout.astro /checkout Checkout (demo): collection day and slot, notes, contact details, pay at collection or demo card, order summary, confirmation with order number and .ics file, empty state with the last order
src/pages/about.astro /about Our story: story, timeline, the 36-hour sourdough process, suppliers, team, waste figures
src/pages/visit.astro /visit Visit & wholesale: illustrated map, hours with today highlighted, contact, getting here, wholesale enquiry form (/visit#wholesale), FAQ
src/pages/404.astro /404 Not found: links back to home, preorder and café

Project structure#

crumb/
  astro.config.mjs            site URL, base path, file-style output, Tailwind plugin
  src/
    pages/                    one .astro file per page (see Pages)
    layouts/Layout.astro      <html>, <head> metadata, fonts, header, footer, basket drawer, scripts
    components/
      Logo.astro              wordmark and round loaf mark (header and footer versions)
      SiteHeader.astro        open-now bar, sticky header, basket button, mobile menu
      SiteFooter.astro        bake list sign-up, address, hours, explore links
      BasketDrawer.astro      basket drawer and toast (filled in by the script)
    data/
      shop.ts                 SHOP, TIMEZONE, LOCALE, CURRENCY, HOURS, PICKUP, storage keys
      bakes.ts                CATEGORIES, ALLERGENS, DIETS, PRODUCTS, BAKE_SCHEDULE, CATEGORY_TINT
      cafe.ts                 CAFE_MENU
      cakes.ts                CAKE (sizes, sponges, fillings, finishes, colours, limits), CAKE_DEFAULTS
      nav.ts                  header, mobile and footer links
    lib/
      art.ts                  bake() and cake(): the SVG artwork
      bakery.ts               lookups, opening-hours text, bake days, cake price, summary and allergens
      render.ts               HTML for product cards, shelves, board tiles, café menu, cake options
      format.ts               price(), escapeHtml(), plural(), listText()
      site.ts                 site name, theme colour and the url() helper
    scripts/
      main.ts                 shop clock, basket, drawer, filters, cake builder, checkout, forms
      back-to-top.ts          the back-to-top button
    styles/global.css         Tailwind import, brand tokens (@theme) and component classes
  public/img/                 favicon.svg, og-image.svg, og-image.png
  README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

The design tokens are in the @theme block of src/styles/global.css. Every utility such as bg-bramble or text-muted comes from them.

Token Default Used for
--color-flour #fbf5ec Page background
--color-paper #fffdf8 Cards and form fields
--color-oat #f3e7d5 Soft panels, bread tiles
--color-line #e7d6bf Hairlines and dashed rules
--color-ink #3a2620 Text, outlines, the footer
--color-muted #6c574a Secondary text
--color-bramble / --color-bramble-deep #74305a / #561f42 Brand colour: buttons, links, basket
--color-bramble-soft #f3dce7 Selected options, the hero window frame
--color-blush / --color-blush-deep #f7dcd3 / #a33a3f Cake tiles, sold out, error text
--color-pistachio / --color-pistachio-deep #dfe8cb / #43602a Whole cake tiles, "on the shelf", vegan
--color-butter / --color-butter-deep #f9e8b2 / #7a5a06 Pastry tiles, "baking now", badges
--color-lilac, --color-sky #e5ddf0, #d9e8ee Pastel accents
--color-crust #b9733c Decoration only (steam, bread brown)

To change the brand colour, edit the tokens; the dev server picks the change up straight away:

css
@theme {
  --color-bramble: #2f5d50;
  --color-bramble-deep: #1f4237;
  --color-bramble-soft: #d8ebe3;
}

Also update themeColor in src/lib/site.ts (the browser's theme-color). The illustrations use fixed colours inside src/lib/art.ts and inline SVG in the pages, so a new brand colour doesn't recolour the bakes. The category tile colours are CATEGORY_TINT in src/data/bakes.ts.

Typography#

Three Google Fonts are loaded by the <link> in src/layouts/Layout.astro:

Token Font Used for
--font-display Corben Headings, prices, the wordmark
--font-sans Karla Body text and UI
--font-hand Delicious Handrawn Hand-lettered notes (.hand), the cake message

To swap a font, change the Google Fonts URL in the layout and the matching token in src/styles/global.css. The SVG artwork names 'Delicious Handrawn' and Corben directly for its labels.

Content#

Page text lives in the .astro files. Everything the cards, board and scripts work from is typed data in src/data/:

Constant File What it holds
SHOP shop.ts Name, address, phone and email used in the drawer, toasts and calendar file
TIMEZONE, LOCALE, CURRENCY shop.ts Europe/London, en-GB, GBP
HOURS shop.ts Opening hours per weekday, Sunday first, ["07:30", "16:00"] or null for closed
PICKUP shop.ts cutoff ("14:00"), slotStep (30), lastSlotBeforeClose (30), daysAhead (14), notesMax (200)
CATEGORIES bakes.ts The shelves: bread, pastry, bakes, whole
ALLERGENS, DIETS bakes.ts Allergen names and diet labels (vegan, vegetarian, gf, df)
PRODUCTS bakes.ts Every bake on the preorder page
BAKE_SCHEDULE, BAKING_MINS bakes.ts When each bake comes out of the oven, and how long "baking now" shows before that
CAFE_MENU cafe.ts Café sections and items
CAKE, CAKE_DEFAULTS cakes.ts Celebration cake options, lead time and limits; the builder's starting choices

Add a bake by adding an object to PRODUCTS:

ts
{ id: "rye-bun", name: "Caraway rye bun", category: "bread", price: 1.6, unit: "each", days: [5, 6], max: 12,
  blurb: "Soft rye buns with caraway, made for sandwiches.",
  allergens: ["gluten"], diet: ["vegan"], art: { kind: "boule", crust: "#8c5532", top: "#a8693d" } },
Field Meaning
id Unique, used in the basket and in data-add buttons
category One of the CATEGORIES ids
price In pounds; whole numbers show without pence (£32)
days Weekdays it's baked (0 Sunday to 6 Saturday), or null for every day you're open
max The most one order can hold
leadDays Optional days' notice (default 1: order before the cut-off the day before)
special Optional badge text; specials also appear on the home page
allergens, diet Keys from ALLERGENS and DIETS (TypeScript checks them)
art How it's drawn (see Images and artwork)

The new bake appears on the preorder page (and, with special, on the home page) at the next build. To show it on the home page board, add it to BAKE_SCHEDULE with the time the first tray comes out and, optionally, when it usually sells out:

ts
{ id: "rye-bun", out: "09:15", soldOut: "13:00" },

Café items are { name, price, desc, diet } inside a CAFE_MENU section. The cake builder reads CAKE.sizes, CAKE.sponges, CAKE.fillings, CAKE.finishes and CAKE.colours; each option has an id, a label and a price (a size has serves, factor and tiers, the diameters in inches used for the drawing). CAKE_DEFAULTS picks the options selected when the page loads.

The footer hours (SiteFooter.astro), the reviews and the café teaser on the home page are plain markup: update them alongside HOURS and CAFE_MENU.

Images and artwork#

There are no photos. All artwork is SVG:

  • Bakes are drawn by bake(art) in src/lib/art.ts from a product's art object. kind picks the drawing: boule, tin, baguette, focaccia, croissant, painchoc, bun, knot, scone, tart, cookie, brownie, loafcake or layercake. Optional colours: crust and top (breads), seeds / oats (tin), score: "leaf" (boule), almonds (croissant), dough / swirl (bun), sponge, frosting, filling and top ("berries", "walnuts", "drip", "flowers") for layercake. A seeded random generator keeps each drawing the same on every build and in the browser.
  • Celebration cakes are drawn by cake(config) from the builder's choices.
  • The hero shop window, map, kitchen window, team avatars, coffee cup and 404 loaf are inline SVG in the pages. Edit them in place, or replace them with an <img>.

To use photos instead, replace Art.bake(p.art) in productCard() and boardItemHtml() (src/lib/render.ts) with an <img> (keep the 4:3 tile on cards and the square tile on the board), and give each product an image path in public/img/. Keep the hero image around 560×660 (portrait) to match the layout.

The social image is public/img/og-image.png (1200×630), rendered from og-image.svg. The favicon is public/img/favicon.svg.

The header and mobile menu are src/components/SiteHeader.astro, the footer SiteFooter.astro, and both read their links from src/data/nav.ts:

ts
export const mainNav: NavLink[] = [
  { key: "shop", label: "Preorder", href: "/shop" },
  // …
];

To add a page, create src/pages/<name>.astro with the layout, then add it to mainNav (and footerNav). A page passes current="<key>" to <Layout> to mark its link with aria-current="page", and page="<name>", which becomes <body data-page> and tells main.ts which features to start (home, shop, cakes, cafe, checkout). Write internal links with url("/shop") from src/lib/site.ts so they work under a base path.

Features#

Shop clock and opening hours#

All times use the bakery's clock (TIMEZONE) via shopNow() in src/scripts/main.ts, so a visitor abroad sees the shop's real state. Elements with data-open-status show "Open now until 4pm" or "Closed · opens Tuesday 7:30am" with a coloured dot; data-cutoff shows the countdown to the order cut-off ("Order in the next 2h 14m to collect tomorrow"); data-hours-list lists the week (rendered at build time by hoursListHtml(), then again with today highlighted). They refresh every 30 seconds. Add ?now=YYYY-MM-DDTHH:MM to a URL to test any time.

Bake board#

The home page's "Fresh out of the oven" list (#bake-board) is rendered at build time with every BAKE_SCHEDULE entry. In the browser, renderBoard() shows the bakes baked today. Each bake is Out at 7:30am until BAKING_MINS before its time, then Baking now, then Out since 7:30am, and Sold out after its optional soldOut time. Bakes on the shelf come first and sold-out ones last. After closing (and on closed days) it shows the next bake day instead, so the page never looks empty. It refreshes every minute. Each tile links to its shelf on the preorder page.

Preorder catalogue#

Every shelf and card is rendered at build time (allShelvesHtml() in src/lib/render.ts). initShop() fills in the collection days and keeps each card's button in step with the basket. The chips filter by shelf and update the URL (?category=pastry); the collection-day picker is the basket's day; "Only that day's bakes" hides anything not baked then. Filtering renders the shelves again with the same shopSectionHtml() and productCard() functions. Cards show the bake days as a Monday-first week strip, diet tags, allergens and an action:

  • Add to basket when it fits the chosen day.
  • Add for Sat 17 Oct when it doesn't: adding it moves the collection to the next day that suits the whole basket, and a toast says so.
  • A quantity stepper once it's in the basket, capped at max.

Filters are buttons with aria-pressed, and the result count is a live region.

Basket drawer#

The basket button in the header opens #basket-drawer, a modal dialog with a focus trap; Esc, the close button or the backdrop close it and focus returns to the button. It lists each line with a stepper and remove button, then the collection details:

  • Collection day: open days from the earliest possible (earliestDate()) up to PICKUP.daysAhead (CAKE.daysAhead when there's a celebration cake). Orders before PICKUP.cutoff can collect the next open day; after it, the day after. Whole cakes add their leadDays, celebration cakes CAKE.leadDays.
  • Time slot: half-hour slots from opening time until lastSlotBeforeClose minutes before closing.
  • Conflicts: if something isn't baked on the chosen day, the drawer says so, offers the next day that suits everything, and disables checkout until it's fixed.
  • Notes up to PICKUP.notesMax characters, saved as you type.

Celebration cake builder#

The radio cards, the first drawing, price, breakdown and allergens are rendered at build time from CAKE and CAKE_DEFAULTS. initCakes() redraws the cake and price on every change. The price is the size's price plus sponge, filling and finish extras multiplied by the size's factor, rounded to whole pounds (cakePrice() in src/lib/bakery.ts). The colour swatches are disabled for the naked finish. The message is limited to CAKE.messageMax characters with a live counter. The date input's minimum is CAKE.leadDays days ahead (after the cut-off) and closed days are rejected. Adding the cake puts it in the basket, sets the collection to that date and shows a toast; one order holds up to CAKE.maxPerOrder cakes.

Café menu filters#

The full menu is rendered at build time by cafeMenuHtml(). initCafe() renders it again for the chosen diets: the chips combine (a dish must match all of them; vegan counts as vegetarian and dairy-free), update the URL (?diet=vegan,gf) and show an empty state with a clear button.

Checkout and calendar file#

initCheckout() shows the collection picker, contact fields, payment choice and summary. "Pay now by card" reveals a demo card form (Luhn-checked number, MM / YY expiry, CVC). On a valid submit it creates an order number (BR- and five digits), saves the order to crumb-last-order, empties the basket and shows a confirmation with an Add to calendar (.ics) link built by icsFor(): a 30-minute event at the collection slot in Europe/London, with the items in the description. The empty checkout shows the last order and its calendar link.

Scroll reveal, motion and back to top#

Elements with .reveal fade up as they scroll into view; the layout's inline script adds js to <html> first, so without JavaScript nothing is hidden. With prefers-reduced-motion, reveals, steam and pulses are off. A back-to-top button (src/scripts/back-to-top.ts) appears after one screen of scrolling and moves focus to the main content. To drop it, delete that file, its import in Layout.astro and the .back-to-top styles in src/styles/global.css.

Forms#

Form Where Validates
Bake list sign-up (data-form="newsletter") Footer, every page Email
Wholesale enquiry (data-form="wholesale") /visit Business, type, name, email, optional phone, loaves a day (10–400), at least one interest, start date at least 7 days ahead, message up to 600 characters
Cake builder (#cake-form) /cakes Collection date (lead time, open day), message length
Checkout (#checkout-form) /checkout Time slot, name, email, UK phone, allergen confirmation, card fields when paying now

Validation is inline: errors appear under each field on blur and submit (fieldMessage() in section 17 of main.ts), the first invalid field gets focus, and a success panel replaces the form. The demo forms send nothing.

To connect the newsletter or wholesale form to a form service such as Formspree, replace the successSwap(...) call in initForms():

ts
$$<HTMLFormElement>('[data-form="wholesale"]').forEach((form) => setupValidation(form, () => {
  fetch('https://formspree.io/f/your-id', { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
    .then((r) => r.ok ? successSwap(form, '<p class="font-display text-2xl">Thanks, we’ll be in touch.</p>') : alert('Sorry, that didn’t send.'));
}));

Every field already has a name. For real orders, send the order object in the checkout's submit handler (there is a comment marking the spot) to your order system, and take payment with your provider (Stripe Checkout, Square, SumUp…) before showing the confirmation. Remove the demo card form when you do. The site is static, so the order endpoint lives elsewhere (a serverless function or your order system's API); to run server code in this project instead, add an Astro adapter and an API route.

Saved data (localStorage)#

Key Shape Notes
crumb-basket { lines, date, time, notes } lines are { id, qty } or { id: "cake", key, cake: { size, sponge, filling, finish, colour, message }, qty: 1 }; date is YYYY-MM-DD, time is HH:MM
crumb-last-order { number, placedAt, date, time, notes, name, email, phone, payment, lines, total } Written at checkout; shown on the empty checkout page

The key names are BASKET_KEY and ORDER_KEY in src/data/shop.ts. Everything is checked on every read (loadBasket(), readLastOrder()): unknown products and cake options are dropped, quantities become whole numbers within max, duplicates merge, dates outside the bookable range and slots that don't exist reset, and the repaired value is written back. Prices are never stored; they always come from PRODUCTS and CAKE. Open tabs stay in sync through the storage event. To reset, run localStorage.removeItem('crumb-basket') in the console, or clear the site's data.

SEO and sharing#

Each page passes its title and description to Layout.astro, which writes the meta description, canonical URL, Open Graph and Twitter tags. Canonical, og:url and og:image are absolute URLs built from SITE_URL, so set it to your domain when you build:

sh
SITE_URL=https://www.your-bakery.co.uk pnpm build

(or change the default in astro.config.mjs). The 404 page passes noindex. The preorder cards, specials and café menu are in the built HTML, so search engines see every bake without running JavaScript. Add a sitemap with @astrojs/sitemap (leave out /404 and /checkout) and a public/robots.txt. A Bakery schema.org block (name, address, opening hours, telephone) in the home page helps local search; you can build it from SHOP and HOURS.

Accessibility#

  • Skip link, landmarks, one <h1> per page and logical headings.
  • Visible focus outlines in the brand colour; WCAG AA colour contrast.
  • The mobile menu button has aria-expanded and closes with Esc.
  • The basket drawer is a modal dialog with a focus trap, Esc to close and focus returned to the opener.
  • Filter chips use aria-pressed; result counts and toasts are live regions.
  • Steppers have labelled buttons ("One more Butter croissant"); the art has text alternatives or is hidden.
  • Form errors are linked with aria-describedby and fields get aria-invalid.
  • Scroll reveals, steam and pulses are off with prefers-reduced-motion.

When editing, keep one <h1> per page and label every new input.

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=/crumb 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/lib/render.ts and 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#

  • The bake board says "Tomorrow" all day. HOURS decides when you're open; check the closing time for today and that TIMEZONE is your bakery's zone.
  • A bake never appears on the board. It needs a BAKE_SCHEDULE entry and must be baked that day (days).
  • Customers can't pick tomorrow. It's after PICKUP.cutoff, tomorrow is closed, or something in the basket needs more notice (leadDays).
  • A new product doesn't show on the live site. Cards are rendered at build time: rebuild and redeploy after editing src/data/.
  • A new class does nothing. Classes in TypeScript strings must be written in full ("bg-oat"), not built from pieces, so Tailwind can find them.
  • Links break when the site is in a sub-folder. Write internal links with url("/page"), never a bare "/page", and build with DEMO_BASE_PATH.
  • The basket shows old items. Stored baskets are repaired against PRODUCTS; if you renamed an id, old baskets drop that line. Clear crumb-basket to start fresh.
  • The calendar file has the wrong time. Events use TIMEZONE; if you change it, update the VTIMEZONE rules in icsFor() too.

License#

MIT. See the repository license (LICENSE.txt in the download). All artwork is included under the same license.

Search documentation

Search the guides and every template's documentation