Overview#
Frond is a complete HTML and Tailwind CSS v4 website for an independent houseplant shop on Gracious Street in Knaresborough, UK. Version 1.0.0 has nine pages and a 404: a catalogue of fifteen illustrated plants with sizes and cover pots, a saved basket, a three-step checkout with gift options, a card step and an order confirmation, a four-question plant finder, per-plant care cards and a visit page with a map and live opening hours. The business and people are fictional. See the template page for the live demo and download.
Quick start#
- Unzip the download.
- Open
index.htmlin your browser, or serve the folder (recommended, so saved baskets behave as on a real site):
python3 -m http.server 8080- Visit
http://localhost:8080/index.html.
Any static server works. Node 24 is needed only to rebuild the Tailwind CSS. The site has no JavaScript dependencies, API keys or database. Fonts load from Google Fonts, so they need a network connection.
Pages#
| File | Purpose | Notable sections |
|---|---|---|
index.html |
Home | Plant-room hero, four featured plants, finder promotion, three-step “Frond way”, visit panel |
shop.html |
Catalogue | Fifteen plants, search, four filters, sorting, quick add |
product.html |
Plant details | Query-driven plant, two sizes, optional cover pot, dispatch date, care, size and delivery tabs |
basket.html |
Basket | Variant lines, quantities, delivery or collection, postcode check, welcome code, last order |
checkout.html |
Checkout | Details, delivery or collection with gift message and wrap, card details, order confirmation |
finder.html |
Plant finder | Four questions, ranked matches with a grade and a reason per plant, shareable URL |
care.html |
Care cupboard | Per-plant care cards, five symptom guides, weekly, monthly and seasonal habits |
about.html |
Our roots | Illustrated shopfront, shop history, three illustrated staff portraits, packaging |
visit.html |
Visit and contact | Address, illustrated map, today’s hours and the next bank holiday, access notes, contact form |
404.html |
Missing page | Routes back to the shop, home and contact |
product.html?plant=monstera opens the monstera directly. A missing or unknown plant parameter falls back to the
first plant in the catalogue, with a short notice when an unknown one was asked for.
Project structure#
frond/
index.html, shop.html, product.html, basket.html, checkout.html,
finder.html, care.html, about.html, visit.html, 404.html
assets/
css/style.css Compiled Tailwind output
js/data.js FROND_PRODUCTS, the catalogue and per-plant care text
js/main.js Dates, basket, checkout, finder, care, forms and storage
img/ Fifteen plant drawings, hero and page art, portraits, favicon, social image
src/input.css Tailwind import, brand tokens, small component layer
package.json CSS build and watch scripts
README.md Short introduction
DOCS.md This guide
CHANGELOG.md Template release historytemplate.json is gallery metadata and is not part of the download. LICENSE.txt is added to the download.
Customising#
Brand and colours#
Brand tokens live in @theme in src/input.css. Layout and styling are Tailwind utilities in the HTML and in the
markup generated by main.js; the small @layer components holds repeated controls (.btn, .field, .choice,
.tab, .pill, .card, .eyebrow, .display).
| Token | Default | Use |
|---|---|---|
--color-teal |
#104b46 |
Primary brand, buttons and footer |
--color-deep |
#0a3633 |
Hover and notifications |
--color-acid |
#d8ed75 |
Bright callouts and badges |
--color-orange |
#f58243 |
Decorative accents |
--color-ink |
#173d38 |
Body and heading text |
--color-cloud |
#f1f5ed |
Quiet panel backgrounds |
--color-muted |
#52665e |
Supporting text |
--color-line |
#d5dfd4 |
Borders and dividers |
--color-rust |
#a3331c |
Inline validation errors |
@theme {
--color-teal: #164f50;
}Rebuild the CSS afterwards. SVG fills are self-contained, so recolour the artwork separately. Check contrast when changing text, focus, button or panel colours.
Typography#
Bree Serif (headings, prices) and Cabin (body text and controls) load from Google Fonts in the <head> of every page:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Bree+Serif&family=Cabin:wght@400..700&display=swap"
/>--font-display and --font-sans in src/input.css select the faces. To swap fonts, change the link on every page
and these two tokens, then rebuild. Re-render the social image with the new fonts (see below).
Content#
Business copy lives in the HTML files. The catalogue is window.FROND_PRODUCTS in assets/js/data.js;
assets/js/main.js builds product cards, the product page, basket rows, checkout lines, finder results and care cards
from it.
| Field | Purpose |
|---|---|
id |
Unique URL and basket identifier; also the image file name assets/img/<id>.svg |
name |
Common name shown first (“Spider plant”) |
botanical, cultivar |
Botanical name (shown in italics) and optional cultivar (shown in quotes) |
aka |
Optional other name, included in search (“devil’s ivy”) |
price, largeDelta |
Standard price and the extra for the large size, in whole pounds |
light |
bright, soft or low |
effort |
easy or regular |
room |
Desk, Shelf or Statement (used by the finder and search) |
pets, petNote |
true only if commonly listed as non-toxic to cats and dogs; the wording shown |
blurb |
Short description for the product page |
lightNote, water, feed, extra |
The plant’s own care card |
strength |
The sentence the finder uses as its reason for suggesting the plant |
diameter, height |
Standard growing-pot diameter and height, in centimetres |
stock, largeStock |
Standard and large stock counts (0 disables the large size) |
pot, glaze, shape |
Paired cover pot shape and glaze; shape names the drawing’s silhouette |
To add a plant, copy a complete object, give it a new id, write its care text and add assets/img/<id>.svg.
Don’t reuse an id for a different plant: saved baskets refer to it. Check toxicity guidance (for example the ASPCA
list) before setting pets: true, and keep petNote careful.
{
id: "spider-plant",
name: "Spider plant",
botanical: "Chlorophytum comosum",
cultivar: "Vittatum",
price: 16,
light: "soft",
pets: true,
effort: "easy",
room: "Shelf",
}The excerpt is not complete; keep every field from an existing object. Staff names (Orlaith Sedgewick, Benedicta
Threlfall, Euan Brackenridge) are in about.html. The address, phone, hours and email are repeated in the footer of
every page and in SHOP_ADDRESS in main.js.
Images and artwork#
| File | Size | Used on |
|---|---|---|
<plant id>.svg (15 files) |
400×420 | Cards, product page, basket, checkout, care |
green-room.svg |
720×700 | Home hero |
shopfront.svg |
720×560 | About hero |
light-guide.svg |
720×460 | Finder hero |
portrait-orlaith.svg, portrait-benedicta.svg, portrait-euan.svg |
400×420 | About team cards |
og-image.svg / og-image.png |
1200×630 | Social sharing |
favicon.svg |
64×64 | Browser tab |
The visit-page map is inline SVG in visit.html, so its labels use the page font; edit the <text> elements and the
<title> together. The other SVGs contain no text. The artwork is included with the template.
To use photographs, keep the 400×420 crop for plants (or change the width/height attributes in productCard,
setupProduct, setupBasket, setupCheckout and setupCare). Plant images show the paired cover pot; the alt text
and the “Cover pot shown is an optional extra” label say so.
Regenerate the social image after editing og-image.svg (the page is passed so Bree Serif and Cabin are used):
node scripts/og-png.mjs templates/html/frond/assets/img/og-image.svg templates/html/frond/assets/img/og-image.png templates/html/frond/index.htmlThat script is part of the Freeframe repository; any SVG-to-PNG tool works if you have the fonts installed.
Navigation, header and footer#
The header and footer are repeated in all ten HTML files; edit every copy. Desktop links use .nav-link; mobile links
are in #mobile-menu. Mark the current page with aria-current="page" in both. The basket link’s count and label are
updated by updateBadges. There is no dark mode.
Features#
Dates, dispatch and opening hours#
Everything dated is worked out in the browser in Europe/London time (TIME_ZONE), so the demo never goes stale.
| Constant | Default | Meaning |
|---|---|---|
DISPATCH.cutoff |
"12:00" |
Orders before this time on a dispatch day leave that day |
DISPATCH.days |
[1, 2, 3, 4] |
Dispatch weekdays (Monday to Thursday; 0 is Sunday) |
DISPATCH.transit |
"1–2 working days" |
Courier time shown after dispatch |
HOURS |
Tue–Sat 10:00–17:00, Sun 11:00–15:00 | Opening hours by weekday; null is closed |
BANK_HOLIDAY_HOURS |
["11:00", "15:00"] |
Hours on bank holidays |
SHOP_CLOSED |
["12-25", "12-26", "01-01"] |
MM-DD days the shop is shut; add one-off closures here |
bankHolidays(year) computes England and Wales bank holidays for any year from the rules (New Year’s Day, Good Friday
and Easter Monday from the Easter computus, the two May Mondays, the August Monday, Christmas and Boxing Day with
substitute days). They are never dispatch days.
dispatchDate(): today if it is a dispatch day before the cut-off, otherwise the next dispatch day. Shown on product pages, the basket, the checkout and the confirmation ([data-dispatch-line]).collectionDate(): collection orders are ready the next day the shop is open ([data-collect-date]).setupVisit(): “Open now, until 5pm”, “Closed today. We open again…” and the next bank holiday the shop opens.
For review, add ?now=YYYY-MM-DDTHH:MM (London time) to any page, for example
product.html?plant=monstera&now=2026-12-24T13:00. Invalid values are ignored. For a real shop, set the constants to
your own rules; nothing needs turning off.
Catalogue filters#
setupShop renders the catalogue. Search (up to 80 characters, case-insensitive) checks the common name, botanical
name and cultivar, aka, blurb and room. Filters combine; price limits are strict (Under £25 excludes a £25 plant).
Selections update the URL without reloading; invalid values are ignored. Quick add adds one standard plant in its
growing pot.
| Query parameter | Accepted values |
|---|---|
q |
Search text |
light |
all, bright, soft, low |
care |
all, easy, regular |
pets |
all, safe |
price |
all, 25, 35 |
sort |
featured, price-asc, price-desc, name |
Product options and tabs#
setupProduct reads plant, size (standard, large) and pot (growing, ceramic). A size with no stock is
disabled and can’t be chosen through the URL. coverPrice adds £8 (standard) or £12 (large) for the cover pot; stock is
per plant and size, shared across pot choices. setupTabs implements the three tabs with Left/Right, Home and End.
Basket and delivery#
A basket line is a variant id:size:pot; adding the same variant raises its quantity. Prices are always recalculated
from the catalogue.
| Constant | Value | Meaning |
|---|---|---|
FREE_DELIVERY |
6500 |
Free mainland delivery threshold in pence, after discounts |
DELIVERY_PRICE |
595 |
Mainland delivery in pence |
GIFT_WRAP_PRICE |
350 |
Gift wrapping at checkout, in pence |
COUPON |
GROW10 |
10% off plants and cover pots |
The delivery option label shows “free” once the basket qualifies. postcodeResult checks the postcode format and
refuses addresses the 48-hour courier can’t reach: the areas in OFFSHORE_AREAS (BT, GY, JE, IM, HS, ZE) and the
districts in OFFSHORE_DISTRICTS (Orkney KW15–17, Scilly TR21–25, Bute PA20, Gigha, Islay, Jura, Colonsay, Mull, Iona,
Tiree and Coll PA41–49 and PA60–78, Arran and Cumbrae KA27–28, Skye IV41–49, IV51 and IV55–56, the Small Isles
PH42–44). Edit those lists to match your courier.
Checkout and order confirmation#
setupCheckout has three steps, each validated before moving on:
- Your details: name, email, optional phone.
- Getting home: tracked delivery (address, town, postcode) or free collection, an optional gift (card message up to
GIFT_MESSAGE_MAXcharacters, optional gift wrap) and a note. - Check and pay: a review, then card name, number (Luhn check, grouped in fours as you type), expiry (
MM / YY, not in the past) and security code. The page says “No payment is taken on this site.”
Placing the order shows a confirmation with an order number (FR- and five digits), the dispatch or collection date,
the lines and totals; it empties the basket and saves a short summary as the last order, shown on the empty basket and
checkout pages. Personal details and card fields are never stored, and the card inputs have no name attributes.
To take real orders, replace the card fields with your payment provider’s hosted fields (Stripe, Square, PayPal and so
on) and, at the comment in the submit handler, send the order to your server, recheck stock, prices and delivery
there, take payment, and only then show the confirmation. Never trust prices, stock or discounts calculated in the
browser. Connect a stock system so stock and largeStock come from your inventory, and an address lookup if your
courier offers one.
Plant finder#
setupFinder asks about light, watering style, pets and where the plant will live (desk, shelf, statement,
any). finderScore gives each plant up to 9 points: light 4 for an exact match or 2 for one step away (plants two
steps away are left out), care 3 for a match (2 when a regular-care home gets an easy plant, 0 the other way round),
space 2 for a match or 1 for “anywhere”. A pet-friendly request is a hard rule. The top six are shown, graded
“Best match” (8+), “Good match” (6+) or “Worth a try”, each with a reason from finderReason built from the plant’s own
strength text and how it differs from the answers.
Answers are shared through the URL (?light=low&care=easy&pets=safe&space=desk); opening such a link shows the
results straight away. Invalid values are ignored.
Care cupboard#
setupCare shows the selected plant’s lightNote, water, feed, extra and petNote (?plant=<id> preselects
one). SYMPTOMS holds five symptom guides with title, copy and action, shown in a polite live region. This is
general growing guidance, not a diagnosis.
Navigation and motion#
setupHeader toggles the small-screen menu with aria-expanded; Escape closes it and returns focus. setupReveals
fades sections in with IntersectionObserver, revealing everything above a section too; reduced-motion users see all
content immediately.
Forms#
| Form | Validation | Result |
|---|---|---|
#contact-form |
Name 2+ characters, valid email, message 20+ characters | “Thanks, Name. We’ll reply within a working day.” |
#checkout-form |
Details, address and deliverable postcode, gift message length, card fields | Order confirmation |
#product-options |
Known size and pot, quantity clamped to stock | A basket line |
#basket-options |
Known route, postcode check, recognised code | Saved choices and totals |
#plant-finder |
Answers checked against FINDER_ALLOWED |
Ranked plants |
#shop-filters |
Known values, capped search | Filtered catalogue |
Errors appear under their fields, linked with aria-describedby, and the first invalid field gets focus. The contact
form doesn’t send anything until you connect it. To use Formspree, create a form and replace showThanks() in the
submit handler of setupContact (the comment above the function shows the same code):
fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
}).then((response) => (response.ok ? showThanks() : showSendError()));Write a showSendError() that shows an inline message, and only thank the visitor after a successful response. Add a
privacy notice suitable for your service.
Saved data (localStorage)#
All access goes through safeStorage in main.js, which falls back to memory when storage throws (sandboxed
previews, strict privacy settings). Then the basket works within the page view but doesn’t survive navigation.
| Key | Shape | Validation on read |
|---|---|---|
frond-basket-v1 |
Array of { id, size, pot, qty } |
Array, at most 60 rows, own-key ids and enums, positive quantities clamped to stock, duplicates merged |
frond-options-v1 |
{ shipping, postcode, coupon } |
Known route, postcode letters and digits up to 10 characters, exact code |
frond-last-order-v1 |
{ number, total, items, when } |
FR- plus five digits, integer pence and item count, when capped at 120 characters |
Malformed values are discarded. No names, emails, phone numbers, addresses, messages or card details are stored. To reset, run in the browser console:
["frond-basket-v1", "frond-options-v1", "frond-last-order-v1"].forEach((key) => localStorage.removeItem(key));
location.reload();SEO and sharing#
Each page has its own title, description, Open Graph tags, theme-color and locale. The product page title is set
in the browser to the plant’s name. Set og:image on every page to an absolute URL such as
https://your-site.example/assets/img/og-image.png. No structured data, sitemap or robots file is included; add
LocalBusiness and Product data once you have real details, and a sitemap of your final URLs.
Accessibility#
Skip link, landmarks, one h1 per page, visible labels with linked inline errors, a roving tab index on the product
tabs, polite announcements for basket changes and results, and managed focus after removals, checkout steps and form
success. The map has a text alternative in its <title>. Keep contrast, focus outlines and target sizes when
customising, label new inputs, keep each aria-* reference pointing at an existing id, and keep the finder’s
pet-safety rule.
Building and deploying#
The compiled CSS is included. After adding utilities or changing tokens, with Node 24:
npm install
npm run build # or: npm run watchThis compiles src/input.css to assets/css/style.css. Upload the whole folder to Netlify Drop, GitHub Pages,
Cloudflare Pages or any static host, keeping assets/ beside the HTML. Configure your host to serve 404.html for
missing pages. Links are relative, so a subdirectory works too.
Troubleshooting#
New utility classes have no effect#
The CSS is compiled. Run npm run build after adding classes or changing @theme.
The basket empties on another page#
Storage is blocked (private browsing, a sandboxed preview or some file:// setups), so the basket lives in memory for
one page view. Serve the folder over HTTP.
The dispatch date looks wrong#
Dates use Europe/London time and the rules in DISPATCH and bankHolidays. Try ?now= to see a given moment, and add
one-off closures to SHOP_CLOSED.
A welcome code adds a delivery charge#
Free delivery applies after discounts: £70 less 10% is £63, so delivery is £5.95.
A postcode is refused#
It is outside mainland Great Britain or in one of the island districts listed above. Edit OFFSHORE_AREAS and
OFFSHORE_DISTRICTS to match your courier.
Fonts look different offline#
Bree Serif and Cabin come from Google Fonts; without a connection the system fallback fonts are used.
License#
MIT. See LICENSE.txt in the download. All artwork is included with the template.
Bree Serif and Cabin are licensed under the SIL Open Font License.
Frond, its staff and its contact details are fictional.