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).
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/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.mdCustomising#
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:
@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:
{ 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:
{ 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)insrc/lib/art.tsfrom a product'sartobject.kindpicks the drawing:boule,tin,baguette,focaccia,croissant,painchoc,bun,knot,scone,tart,cookie,brownie,loafcakeorlayercake. Optional colours:crustandtop(breads),seeds/oats(tin),score: "leaf"(boule),almonds(croissant),dough/swirl(bun),sponge,frosting,fillingandtop("berries","walnuts","drip","flowers") forlayercake. 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.
Navigation, header and footer#
The header and mobile menu are src/components/SiteHeader.astro, the footer SiteFooter.astro, and both
read their links from src/data/nav.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 toPICKUP.daysAhead(CAKE.daysAheadwhen there's a celebration cake). Orders beforePICKUP.cutoffcan collect the next open day; after it, the day after. Whole cakes add theirleadDays, celebration cakesCAKE.leadDays. - Time slot: half-hour slots from opening time until
lastSlotBeforeCloseminutes 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.notesMaxcharacters, 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 | |
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():
$$<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:
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-expandedand 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-describedbyand fields getaria-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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/shopservesshop.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/shoptoshop.html, changebuild.formatto"directory"inastro.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.
HOURSdecides when you're open; check the closing time for today and thatTIMEZONEis your bakery's zone. - A bake never appears on the board. It needs a
BAKE_SCHEDULEentry 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 withDEMO_BASE_PATH. - The basket shows old items. Stored baskets are repaired against
PRODUCTS; if you renamed anid, old baskets drop that line. Clearcrumb-basketto start fresh. - The calendar file has the wrong time. Events use
TIMEZONE; if you change it, update theVTIMEZONErules inicsFor()too.
License#
MIT. See the repository license (LICENSE.txt in the download). All artwork is included under the same
license.