Overview#
Sprout is a multi-page website template for a practical gardening school, built for the fictional Loamstead field school on a walled plot in Pershore, Worcestershire. It has ten courses with their own pages and drawings, a filterable course list, a twelve-week timetable with places left, a three-step enrolment request, a bed planner that draws plants to scale, a saved shortlist, tutor profiles, and an access statement, booking terms and safeguarding note written the way a real school would write them. The look is a field notebook: cream paper, graph-paper panels, deep green ink and a rust accent, with Vollkorn headings and Commissioner text.
It is plain HTML, Tailwind CSS v4 and one vanilla JavaScript file, with no dependencies. This guide covers version 1.0.0 of the HTML version. The template page, with a live demo, is at freeframe.dev/templates/sprout.
Quick start#
-
Unzip the download.
-
Open
index.htmlin a browser. Everything works from the file system, including course links and saved data. -
To preview it over HTTP instead (closer to a real host), run a static server in the folder:
shpython3 -m http.server 8000and open
http://localhost:8000.
The compiled CSS is included. You only need Node.js 24 if you change src/input.css or add Tailwind classes that
aren't already used (see Building and deploying).
Pages#
| File | Purpose | Notable sections and features |
|---|---|---|
index.html |
Home | Hero with the field drawing, the next five sessions, "In the garden this month", the three soonest courses, how enrolment works, bed planner teaser, tutors and learner quotes |
courses.html |
All courses | Level buttons, topic and season filters (kept in the URL), course cards with next date and places, Save buttons, level guide |
course.html |
One course (?course=<id>) |
Title, description and Open Graph tags set from the course; facts, drawing, session plan, what you take home, what to bring, next four dates with places, tutor, learner quote, related courses; an "unknown course" message for bad links |
timetable.html |
Upcoming sessions | Every session starting in the next twelve weeks, grouped by month, filterable by course and level, with Enrol links |
enrol.html |
Enrolment request | Three steps (date, who's coming, your details), inline validation, running total, confirmation with a reference |
planner.html |
Bed planner | Bed size and crop, plant count, scale drawing with one mark per plant, too-small and corner-only messages, suggested course with Save |
saved.html |
Shortlist | Saved courses with their next dates, enrolment requests made on this device, clear and remove buttons |
about.html |
About the school | Founder's note, map of the plot, numbers, tutor cards, values |
visit.html |
Visit and contact | Enquiry form, address, hours, sketch map, travel notes, access statement (#access), booking terms (#terms), safeguarding (#safeguarding), FAQ (#faq) |
404.html |
Page not found | Links back to the home page, courses and timetable |
Project structure#
sprout/
index.html, courses.html, course.html, timetable.html, enrol.html,
planner.html, saved.html, about.html, visit.html, 404.html
assets/
css/style.css compiled Tailwind CSS (generated; don't edit by hand)
js/main.js all data and behaviour: courses, tutors, session rules, forms, planner, storage
img/favicon.svg the seedling logo
img/field.svg hero drawing (tomato plant and living soil)
img/plot-map.svg map of the Walled Plot (about page)
img/directions.svg sketch map of Pershore (visit page)
img/og-image.svg social image source
img/og-image.png social image (1200 × 630) used by og:image
src/input.css Tailwind source: brand tokens and a few shared components
package.json build and watch scripts for the CSS
README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
The colours are Tailwind theme tokens in src/input.css. Every bg-*, text-* and border-* class in the pages
uses them, so changing a value changes it everywhere after a CSS rebuild.
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f6f4e9 |
Page background |
--color-paper-2 |
#ecedda |
Graph-paper panels (.graph) |
--color-card |
#fffdf6 |
Cards and form fields |
--color-line |
#c8ccb2 |
Hairlines and card borders |
--color-ink |
#1f3a2e |
Text, buttons, footer |
--color-muted |
#4b5b4f |
Secondary text |
--color-leaf |
#dbe3b8 |
Pressed buttons, "places left" badges |
--color-rust |
#a84325 |
Accent: italic headline words, eyebrows, focus ring |
--color-straw |
#f1dfb2 |
Notes, tape, "only a few places" badges |
@theme {
--color-ink: #1f3a2e; /* change to recolour text, buttons and the footer */
--color-rust: #a84325; /* the accent */
}The drawings in assets/js/main.js (section 3) and the SVG files use the same hex values directly; search for
#1f3a2e and #a84325 if you change the palette. Also update theme-color in each page's <head>.
Shared pieces live in @layer components in src/input.css: .wrap (page width), .btn, .btn-primary,
.btn-ghost, .btn-sm, .link, .eyebrow, .sheet (the specimen-card frame), .tape (the strip of tape),
.graph (graph paper), .tag, .chip, .label, .input, .select, .field-error, .check, .choice (the
session and experience options) and .acc (FAQ accordions). Everything else is Tailwind utilities in the markup.
Typography#
Two Google Fonts are loaded in each page's <head>:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Commissioner:wght@400;500;600;700&family=Vollkorn:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap">--font-sans (Commissioner) is the body face and --font-display (Vollkorn) is used for headings, prices and the
italic accent words through the font-display class. Vollkorn uses old-style figures by default; src/input.css
switches headings and .font-display to lining figures so dates and prices line up. To swap a font, change the
Google Fonts link on every page and the family name in @theme.
Content#
Page text is in the HTML files. The school's data is in the first section of assets/js/main.js:
| Constant | What it holds |
|---|---|
COURSES |
The ten courses: id, name, level, topic, tutor, price (pounds per person), capacity, rule (when it runs), start/end (UK local time), tone (drawing background), summary, about, plan, takeaway, bring |
TUTORS |
Eudora Wickmere, Idris Pennock and Halima Fennimore: name, role, bio, quote |
LEVELS, TOPICS, SEASONS |
Filter values and labels (SEASONS lists the months in each season) |
QUOTES |
One learner quote per course: course, name, place, text |
CROPS |
Planner crops: name, spacing (cm), mark (the plant drawing), course (the course suggested) |
MONTH_NOTES |
Twelve "In the garden this month" notes: title, text and a course id |
MIN_LEAD_DAYS, TIMETABLE_DAYS, SESSIONS_PER_COURSE, CLOSED, DEMO_TAKEN, MAX_PEOPLE |
Timetable and enrolment settings (see Session dates) |
To add a course, add an object to COURSES with a new id, add a drawing to ART (or let it fall back to the first
course's drawing), and add a quote to QUOTES if you have one:
{
id: 'herb-garden', name: 'A kitchen herb garden', level: 'beginner', topic: 'containers', tutor: 'halima', price: 45, capacity: 10,
rule: { day: 6, every: 'month', nth: 4, months: [4, 5, 6, 7], parts: [0] }, start: '10:00', end: '12:30',
tone: '#e4e9cf',
summary: 'Grow the herbs you cook with, in pots or a small bed.',
about: 'A morning planting a herb trough to take home.',
plan: ['Sun, soil and drainage for herbs', 'Planting a trough', 'Picking and cutting back'],
takeaway: ['A planted herb trough'],
bring: 'Nothing: everything is provided.'
}The enquiry form's topic list fills itself from COURSES. Course and planner copy on the home page (for example "Ten
courses through the year") is plain text in index.html and courses.html; update the number if you add or remove
courses.
Images and artwork#
All artwork is SVG drawn for the template; there are no photos.
- Course drawings are built in
assets/js/main.js(ART, one function per course id, drawn in a 320 × 220 view box and shown at 16:11). To use a photo instead, replacecourseArt(c)with an<img>of the same ratio. - Tutor portraits are
PORTRAITentries drawn byportrait(id). - Planner marks are
MARKS, one small drawing per crop type, centred on 0,0. assets/img/field.svg(600 × 660),plot-map.svg(640 × 480) anddirections.svg(480 × 360) are plain SVG files used with<img>; keep the ratios or update thewidth/heightattributes.- The social image is
assets/img/og-image.png(1200 × 630), made fromog-image.svg. Edit the SVG, then regenerate the PNG with any SVG-to-PNG tool or a browser screenshot at 1200 × 630. - The favicon is
assets/img/favicon.svg; the same mark is inline in the header and footer of every page.
Navigation, header and footer#
The header and footer are repeated in every HTML file, so edit them in each page (a find-and-replace across the
folder works well). The current page's link has aria-current="page"; course.html marks Courses as current, and
enrol.html marks the Enrol button. The mobile menu (#mobile-menu) repeats the links. The footer holds the
address, phone, email and office hours, and the line "A fictional school from a Freeframe template", which you should
delete for a real site.
Features#
Session dates#
Session dates are never written down. Each course has a rule, and sessionsFor(course) works out its next dates
from today's date in UK time (TIME_ZONE = 'Europe/London'), so the demo always shows upcoming sessions:
rule field |
Meaning |
|---|---|
day |
Weekday the course starts on: 0 Sunday … 6 Saturday |
every |
'week', 'fortnight' (with parity 0 or 1 choosing which weeks) or 'month' (with nth: 1st, 2nd or 3rd of that weekday in the month) |
months |
The months it runs in (1–12): seasonal courses only appear in season |
parts |
Day offsets of each meeting: [0] one day, [0, 1] a weekend, [0, 21] two Saturdays three weeks apart |
The rules as shipped:
| Course | Rule | Time |
|---|---|---|
| Your first growing bed | Saturdays, fortnightly (parity 0), March–October | 10am–2pm |
| A harvest in small spaces | Sundays, fortnightly (parity 1), April–September | 10am–1pm |
| Make living soil | First Saturday of every month | 2:30pm–5pm |
| Seed to supper | Second Saturday, February–May, plus a Saturday 21 days later | 10am–1pm |
| Winter fruit-tree pruning | Saturdays, fortnightly (parity 1), November–February | 10am–3pm |
| Cordons and espaliers | Saturdays, fortnightly (parity 0), July–September | 10am–2pm |
| Cuttings and propagation | Every Wednesday, May–September | 6pm–8:30pm |
| Hedge-laying weekend | Third Saturday and the Sunday after, November–February | 9am–3:30pm |
| Setting up no-dig beds | Sundays, fortnightly (parity 0), February–March and September–November | 10am–3pm |
| Saving your own seed | Second Sunday, August–October | 2pm–4:30pm |
Other rules:
- The first bookable day is
MIN_LEAD_DAYS(7) days after today, so the next session is always at least a week away. - No session starts or runs between
CLOSED.fromandCLOSED.to(20 December to 4 January). The visit page says the same in its hours list; change both together. - Fortnights count weeks from Monday 5 January 1970 (
weekIndex); parity 0 and 1 alternate. - Course pages and the enrolment form offer the next
SESSIONS_PER_COURSE(4) dates, however far ahead. The timetable lists every session starting withinTIMETABLE_DAYS(84) days of the first bookable day. If a filtered course has nothing in that window (a seasonal course out of season), it says when the next date is and links to it. - Long dates add the year when it isn't the current year ("Saturday 6 March 2027").
For example, with today as Sunday 18 October 2026 the first bookable day is Sunday 25 October, and the home page lists: Your first growing bed, Saturday 31 October; Setting up no-dig beds, Sunday 1 November; Winter fruit-tree pruning and Make living soil, Saturday 7 November; Setting up no-dig beds, Sunday 15 November.
To use fixed dates on a real site, either set each course's rule to match your real pattern, or replace
sessionsFor() with a function that returns your own list of makeSession(course, 'YYYY-MM-DD') objects.
Preview another date: add ?now=YYYY-MM-DD to any page (for example timetable.html?now=2027-06-01) to see the
site as it will look on that day in the UK. The parameter is ignored unless it is a real calendar date.
Places left#
Each session shows how many places are left: capacity minus a demo number of places already taken, minus places
requested on this device. The demo number comes from a stable hash of the course and date, so it doesn't change
between visits: sessions within three weeks have up to about DEMO_TAKEN (75%) of their places taken, further-out
sessions fewer, and at least two places are always left until a visitor requests them. With three or fewer left the badge turns straw
coloured ("Only 2 places left", "Last place"); a session with none left shows "Full" and a waiting-list link to the
enquiry form. On a real site, replace the taken value in makeSession() with numbers from your booking system.
Course list filters#
courses.html filters by level (buttons with aria-pressed), topic and season (selects). The choice is written to
the URL (?level=beginner&topic=veg&season=spring) with history.replaceState, so a filtered list can be shared.
The count above the grid is a live region ("Showing 2 of 10 courses"); when nothing matches, a message and a Clear
the filters button appear. A course is in a season when any of its rule.months falls in that season.
Course pages#
course.html?course=<id> renders one course from COURSES into the page's data-c-* elements and sets the
document title, meta description and Open Graph title and description. The id is matched case-insensitively and only
against the courses' own ids. Without a parameter the page shows the first course (the HTML holds its text, so the page
reads sensibly before the script runs). With an unknown id it says "We couldn't find that course" and lists every
course instead of showing a wrong one.
Timetable#
timetable.html groups sessions by month with a heading per month. Each row shows the date, course, time, tutor,
price, level and places left, and links to enrol.html?course=<id>&session=<YYYY-MM-DD>. The course and level
filters are kept in the URL (?course=…&level=…).
Enrolment request#
enrol.html is one form in three steps (fieldset[data-step]); Continue validates the current step before moving
on and focuses the next step's heading. Step 1 picks a course and one of its next four dates (full dates are
disabled); step 2 takes the number of people (1 to MAX_PEOPLE, and never more than the places left), how much
growing they have done and optional access or other needs; step 3 takes name, email, an optional UK phone number and
agreement to the booking terms. Errors appear under each field, the field gets aria-invalid="true" and focus moves
to the first problem. The summary card shows the course drawing, date, time, people and total as you go.
On submit the request is saved to sprout:enrolments:v1 with a reference such as LS-4821, the places left on that
date go down, and the confirmation explains what happens next. The request then appears on saved.html. The
confirmation ends with a small line saying the site is a demonstration and nothing was sent; remove it once the form
is connected (see Forms).
A link with an unknown course, or with a date that has passed or doesn't match the course's rule, shows a short note above the steps and lets the visitor choose again.
Bed planner#
planner.html divides the bed into equal squares, one per plant, with the crop's spacing as the side: plants across
= floor(width ÷ spacing), along = floor(length ÷ spacing). The default (1.2 m × 2.4 m, lettuce at 30 cm) gives 4 × 8 =
32 plants.
- Validation: width and length must be numbers between 0.2 and 10 metres (
PLANNER_LIMITS; a comma works as a decimal point). Errors are inline, witharia-invalidand focus on the first problem. The form hasnovalidate. - Too small: when a bed is narrower or shorter than one spacing, it explains why, for example "A 0.3 m × 2 m bed is too narrow for kale at 45 cm spacing. Make it at least 0.45 m each way, or choose a crop that sits closer together."
- Drawing: the bed is drawn to scale with one plant mark per plant (a different drawing for each crop type in
MARKS). Beds with more than 24 plants across or 30 along (DRAW_MAX) are drawn as one corner, with a dashed edge and a note: "The drawing shows one corner of the bed (24 × 30 = 720 plants). The full bed holds 4800." - Suggested course: each crop names a course (
CROPS[…].course); the card links to it, gives its next date and has a Save button for the shortlist. - The result is a polite live region, and the last plan is remembered in
sprout:planner:v1.?crop=<id>preselects a crop.
Shortlist#
Every course card, course page and the planner's suggestion have a Save toggle ([data-save], aria-pressed).
The header link shows the number saved and its accessible name says it ("Shortlist, 2 courses saved"). saved.html
lists the saved courses with their next dates, and the enrolment requests made on this device, each with a Remove
button. Changes are announced through a polite live region (#announcer).
In the garden this month#
The home page note is MONTH_NOTES[month - 1] for the current UK month, with a link to that month's course.
Header, mobile menu and scroll reveal#
The header is sticky. Below 1024 px the menu button (#menu-button, aria-expanded, aria-controls="mobile-menu")
opens the mobile menu; Escape closes it and returns focus to the button. Elements with .reveal fade in as they
scroll into view (everything above a revealed item is shown too); with reduced motion they appear at once. The
copyright year is filled in by the script.
Forms#
None of the forms sends anything until you connect it: each shows its confirmation in the page and saves nothing to a server.
| Form | Where | Validates |
|---|---|---|
| Enrolment request | enrol.html, [data-enrol-form] |
Course, date, people, experience, name, email, optional UK phone, terms |
| Enquiry | visit.html, [data-enquiry-form] |
Name (2+ characters), email, message (10–1,500 characters); topic is a select |
| Bed planner | planner.html, [data-planner] |
Width and length (0.2–10 m), crop |
visit.html?topic=<course id> (or general, visit, access, group) preselects the enquiry topic; course pages
and waiting-list links use it.
To connect the enquiry form to a service such as Formspree, set the form's action to your
endpoint and replace the success branch in section 14 of assets/js/main.js with a request:
const response = await fetch('https://formspree.io/f/your-id', {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' }
});
if (!response.ok) { /* show an error message and keep the form */ return; }
// then hide the form and show [data-enquiry-done]Make the submit handler async to use await. The enrolment form works the same way (section 11, the submit
handler): send new FormData(form) to your booking system or form service, and only show the confirmation and save
the request once the response is OK. Then delete the "This website is a demonstration" lines in enrol.html and
visit.html. If you take payment online, link to your payment page from the confirmation rather than collecting card
details in this form.
Saved data (localStorage)#
| Key | Shape | Validated on read |
|---|---|---|
sprout:shortlist:v1 |
Array of course ids, ["first-bed","cuttings"] |
Must be an array; unknown ids and duplicates are dropped |
sprout:enrolments:v1 |
Array of { ref, course, date, people, name, made } |
Each entry needs a LS-0000 reference, a known course, a real date that hasn't passed, 1–4 people and a name up to 80 characters; anything else is dropped (at most 20 are kept) |
sprout:planner:v1 |
{ w, l, crop } |
Numbers within the planner limits and a known crop, otherwise ignored |
All access goes through one safeStorage object. When the browser blocks localStorage (a sandboxed iframe, strict
privacy settings), it falls back to memory, so saving, the shortlist count and enrolment requests still work for the
page view. Damaged JSON is treated as empty. To reset, clear the keys in the browser's developer tools, or use Clear
shortlist and Remove on saved.html.
URL parameters#
| Parameter | Page | Accepted values |
|---|---|---|
course |
course.html, timetable.html, enrol.html |
A course id (case-insensitive) |
session |
enrol.html |
YYYY-MM-DD, preselected when it is one of that course's dates |
level, topic, season |
courses.html |
Keys of LEVELS, TOPICS, SEASONS |
level |
timetable.html |
A key of LEVELS |
crop |
planner.html |
A key of CROPS |
topic |
visit.html |
A course id or general, visit, access, group |
now |
any page | YYYY-MM-DD: preview the site on that UK date |
Every value is checked with an own-key lookup (Object.prototype.hasOwnProperty.call), so values such as
?course=constructor or __proto__ are treated as unknown.
SEO and sharing#
Each page has its own <title>, meta description, Open Graph tags (og:title, og:description, og:url,
og:image), theme-color and favicon in its <head>. course.html updates its title, description and Open Graph
title and description for the chosen course in the browser; for link previews of each course on social sites (which
don't run JavaScript), generate one static page per course on your host or build step.
Replace https://loamstead.example/ in every og:url with your domain, and make og:image an absolute URL
(https://your-domain/assets/img/og-image.png): social sites need an absolute image URL. Add a sitemap.xml listing
the pages and a robots.txt that points to it. For richer search results you could add Course or
EducationalOrganization structured data (JSON-LD).
Accessibility#
- A skip link,
header/nav/main/footerlandmarks, oneh1per page and headings in order. - Visible focus rings (3 px rust outline) on every interactive element; text and controls meet WCAG AA contrast.
aria-current="page"on the current navigation link,aria-pressedon filter and Save buttons,aria-expandedon the menu button, and an accessible name on the icon-only shortlist link that includes the count.- Forms use visible labels,
novalidatewith inline errors linked byaria-describedby,aria-invalidand focus on the first problem; enrolment steps move focus to each step's heading. - Results and counts that change (course count, planner result, saved and removed messages) are live regions.
- Drawings are decorative (
aria-hidden) where the text says the same thing; the hero, plot map and sketch map have descriptivealttext. prefers-reduced-motionturns off the reveal animation and smooth scrolling.
When editing, keep one h1 per page, give new inputs a <label>, and keep aria-describedby pointing at error ids
that exist.
Building and deploying#
Rebuilding the CSS#
The compiled assets/css/style.css is included. After changing src/input.css or adding classes that aren't used
anywhere yet, rebuild it (Node.js 24):
npm install
npm run build # or: npm run watchTailwind scans the HTML files and assets/js/main.js, so classes written in the script's markup strings are
included. Keep class names whole (not built from pieces) so the scanner can find them.
Deploying#
Upload the folder to any static host: Netlify Drop, Cloudflare Pages, GitHub Pages or plain web hosting. There is no
server code. 404.html is picked up automatically by most static hosts.
Troubleshooting#
- The timetable shows different dates from the screenshots. Dates are worked out from today in UK time. Use
?now=YYYY-MM-DDto see a particular day. - A seasonal course has no dates in the timetable. It is out of season in the next twelve weeks; its course page and the enrolment form still show its next four dates, and the timetable says when it next runs.
- New Tailwind classes have no effect. Rebuild the CSS (
npm run build). Classes put together from strings in JavaScript ('bg-' + colour) aren't found by the scanner; write the full class name. - Saved courses disappear when I close the tab. The browser is blocking storage (a private window or sandboxed preview), so the shortlist lives in memory for the page view. It persists on a normal site.
- A course link shows "We couldn't find that course". The
?course=value doesn't match anidinCOURSES. Check the spelling, or keep old ids working by adding them back. - The plan drawing shows only part of the bed. Beds with more than 24 plants across or 30 along are drawn as one
corner to stay readable; the count is still for the whole bed. Change
DRAW_MAXto draw more. - Fonts look different offline. The fonts come from Google Fonts; without a connection the system fallbacks (Georgia and the system sans-serif) are used.
License#
MIT. The code and all included artwork are free for personal and commercial use; the full text is in LICENSE.txt,
included in the download.
Commissioner and Vollkorn are licensed under the SIL Open Font License.
Loamstead field school, its tutors, learners, quotes and address are fictional.