Overview#
Heatloop is a complete website for a technology startup, especially a hardware or climate-tech company with a physical product, shown as Heatloop, a fictional Aberdeen company that makes a thermal battery: a cabinet that charges from cheap off-peak electricity, stores the energy as heat in phase-change cells and feeds radiators and hot taps later. The look is editorial deep-tech: off-white paper, forest green and copper, a large serif for headlines, numbered sections with hairline rules, technical line drawings and charts drawn in SVG, and a deep green-black dark mode. This is the HTML + Tailwind CSS v4 version (1.0.0): plain HTML files, one stylesheet and one vanilla JavaScript file, with no build step needed to run it.
Live demo: freeframe.dev/templates/heatloop
Quick start#
- Unzip the download.
- Open
index.htmlin a browser. Every page and feature works straight from the file system.
To preview it the way a host serves it, run any static server in the folder:
python3 -m http.server 8000 # then open http://localhost:8000You only need Node.js 24 (for npm) if you change src/input.css or add Tailwind classes that aren't used yet
(see Building and deploying).
Pages#
| File | Page | Notable sections and features |
|---|---|---|
index.html |
Home | Hero with the product cutaway (Fig. 1) and a numbered key, key figures, how it works in three steps, the "how much could you shift" teaser, press quotes, milestones, latest news, reservation call to action |
technology.html |
Technology | Exploded view (Fig. 2), the interactive 24-hour charge and tariff diagram, three charts about the store (melting curve, heat loss, controls), specification table (#specs), questions in <details> |
estimator.html |
Savings and carbon estimator | Home, heating, tariff and region inputs; yearly cost and CO2e with bars; suggested model; copy-link button; the formula with your numbers; assumptions table |
reserve.html |
Reserve | Coverage map (Fig. 3), the three-step reservation, saved reservation for returning visitors, list of covered postcode districts |
careers.html |
Careers | Workshop drawing, roles with team, location and remote filters, saved roles, role details dialog, benefits, hiring steps |
company.html |
Company | Story and key figures, principles, team with drawn portraits, backers with their marks, timeline, press kit downloads (#press) |
news.html |
News | Nine dated posts that expand in place (#post-<id>), topic filter, coming-up events, press contact |
contact.html |
Contact | Enquiry form with type and role from the URL, office and workshop addresses, phone numbers, sketch map |
404.html |
Not found | Friendly 404 page with a cooling chart, for your host to serve |
Project structure#
heatloop/
index.html, technology.html, estimator.html, reserve.html,
careers.html, company.html, news.html, contact.html, 404.html
assets/
css/style.css compiled Tailwind CSS (generated, don't edit by hand)
js/main.js all interactions; settings and data at the top
img/
favicon.svg loop mark on green
og-image.svg social image source
og-image.png social image (1200x630) used by og:image
press/
heatloop-logo.svg press kit: logo, green on paper
heatloop-logo-reversed.svg press kit: logo, cream on green
heatloop-28-cutaway.svg press kit: product cutaway drawing
heatloop-fact-sheet.txt press kit: fact sheet
src/input.css Tailwind source: colour tokens, fonts and components
package.json npm scripts to rebuild the CSS
README.md, DOCS.md, CHANGELOG.md
LICENSE.txt MIT license (in the download)Most artwork is inline SVG inside the pages, so it follows the light and dark themes.
Customising#
Brand and colours#
Colours are CSS variables at the top of src/input.css: :root holds the light theme and .dark the dark
theme. Tailwind reads them through @theme inline, so bg-paper, text-copper, border-rule and so on switch
with the theme automatically.
| Variable | Light | Dark | Used for |
|---|---|---|---|
--paper |
#f5f2ea |
#0c1511 |
Page background |
--paper-2, --paper-3 |
#ece7da, #e1dac8 |
#122019, #1a2c23 |
Panels, chart fills |
--ink |
#14251d |
#e8e5da |
Text and line drawings |
--muted |
#4d5a52 |
#a8b3aa |
Secondary text |
--rule |
#c9c1ad |
#2c4035 |
Hairline rules and borders |
--forest, --forest-2 |
#1f4a37, #2b6149 |
#7cc29d, #9bd3b4 |
Buttons, state of charge, pressed toggles |
--copper |
#8c4f17 |
#e3a663 |
Labels, section numbers, links, focus outline |
--ochre, --ember |
#c98a3a, #d4682a |
#e0a052, #ee8a4c |
Tariff line and heat in drawings (decorative) |
--field, --danger |
#fbf9f4, #a3301c |
#0f1b15, #ff9a85 |
Form inputs, error messages |
Three fixed colours in @theme don't change with the theme: --color-pine and --color-pine-deep (the green
band and footer), --color-cream (text on it) and --color-wheat (labels on it). To change the brand green:
:root { --forest: #1d3f5c; }
.dark { --forest: #8fc0e6; }Then rebuild the CSS. SVG drawings use the same variables (fill="var(--ochre)"), so they follow along.
Typography#
Three Google Fonts are loaded with a <link> in each page's <head>:
- Hedvig Letters Serif (
--font-serif,font-serif): headlines, big numbers and quotes. - Hedvig Letters Sans (
--font-sans,font-sans): body text. - Source Code Pro (
--font-mono,font-mono): labels, section numbers, buttons and chart ticks.
Both Hedvig families come in a single weight, so body sets font-synthesis: none to stop the browser faking
bold; hierarchy comes from size, the serif and the mono labels. To swap fonts, change the <link> in every page
and the three --font-* tokens in src/input.css, then rebuild.
Content#
Page text lives in the HTML files. Data that drives the interactions is at the top of assets/js/main.js
(section 1):
| Constant | What it holds |
|---|---|
MODELS, MODEL_ORDER |
The three models: capacity (kWh of heat), power (kW element), indicative price, fits |
NIGHT_SHARE |
Home teaser: share of a day's heat used in the cheap hours anyway (0.12) |
TARIFF_BANDS |
The illustrative time-of-use tariff for the technology diagram, pence per kWh by time band |
CHARGE_BELOW, TOPUP_BELOW, TOPUP_TARGET |
When the simulated store charges (at or below 7p), tops up (at or below 15p, to 85%) |
DEMAND_WEIGHTS, DAY_HEAT |
Hourly shape of a cold day's heat use and its total (46 kWh) |
HOMES, HEATING, TARIFFS, REGIONS |
Estimator inputs: heat use, fuel prices, efficiencies, carbon factors, tariffs, climate factors |
HEATLOOP_EFF, GRID_CO2, ESTIMATE_DEFAULTS |
Estimator: overall efficiency (0.95), grid carbon at night and by day, default inputs |
COVERAGE, COMING_NEXT |
Postcode districts installed now, and next |
RESERVATIONS_AT_ANCHOR, RESERVATIONS_PER_WEEK, SURVEY_LEAD_MONTHS |
Reservation count and queue place, survey month |
TEAMS, LOCATIONS, MODES, ROLES |
Careers: filters and the eleven roles |
EVENT_RULES, EVENTS_SHOWN |
Coming-up events on the news page |
CONTACT_TYPES, CONTACT_REPLY |
Contact enquiry types and which team replies |
Adding a role is one object in ROLES (the team and location keys must exist in TEAMS and LOCATIONS):
{ id: 'electrical-engineer', title: 'Electrical Design Engineer', team: 'engineering', location: 'aberdeen', mode: 'hybrid', salary: '£48,000–£58,000',
summary: 'Design the power electronics and protection for the charging element.',
do: ['Design and test power boards', 'Own electrical safety documentation'],
bring: ['Power electronics experience', 'Knowledge of UK wiring regulations'] }News posts are written in news.html, one <details id="post-<id>" data-topic="..."> each, with a
<time data-news-date="YYYY-MM-DD">. The three on the home page are separate links in index.html (#post-<id>).
If you change a model's capacity or price, update MODELS and the specification table and model cards in
technology.html, reserve.html and index.html.
Images and artwork#
All artwork is SVG drawn for this template: the cutaway in index.html (also assets/press/heatloop-28-cutaway.svg),
the exploded view and three charts in technology.html, the home-type icons in estimator.html, the coverage map
and model drawings in reserve.html, the workshop drawing in careers.html, backer marks in company.html, a
small drawing per news topic, the contact sketch map and the 404 chart. Team portraits are drawn by
initPortraits() from data-portrait="hair,glasses,beard,tone" (hair: short, long, bun, curly, crop,
side; g for glasses, b for a beard; tone 0, 1 or 2). To use photos instead, put an <img> inside the
aspect-square box and remove its data-portrait attribute.
The social image is assets/img/og-image.png (1200 x 630), made from og-image.svg. The favicon is
assets/img/favicon.svg. Press kit files in assets/press/ are linked from company.html#press with download.
Navigation, header and footer#
The header and footer are repeated in every page. To add a page, copy an existing page, change its <title>, meta
description, Open Graph tags and data-page, then add a link to the desktop <nav aria-label="Main">, the mobile
menu (#mobile-menu) and the footer of every page. Mark the current page's links with aria-current="page".
Dark mode#
The moon/sun button ([data-theme-toggle]) switches the .dark class on <html> and saves the choice as
heatloop:theme. A small script in each page's <head> applies the saved choice, or the visitor's system
setting, before the page paints. To launch in light mode only, delete the matchMedia(...) part of that script;
to remove dark mode, delete the button, the script's classList.add('dark') and the .dark { ... } block.
Features#
How much could you shift (home)#
initShift() reads the model toggles ([data-shift-model]) and the heat slider (#shift-need, 20 to 80 kWh).
Cheap-rate heat is min(need, need × NIGHT_SHARE + capacity). Expected values for 46 kWh: Heatloop 18, 51%
(23.5 kWh cheap); Heatloop 28, 73% (33.5 kWh); Heatloop 40, 99% (45.5 kWh). The bar is an SVG rect whose width
is set from the share. Toggles use aria-pressed; the slider has a visible label with the current value.
A day of charge and release (technology)#
initDayChart() runs simulateDay(model, DAY_HEAT) in 48 half-hour slots: at or below CHARGE_BELOW the element
charges flat out (half the model's kW per slot) and the home is heated directly; once full it holds; at or below
TOPUP_BELOW it tops up to TOPUP_TARGET; otherwise heat comes from the store, and from the grid ("Backup") if
the store is empty. The day runs twice so it starts as it ended. It then draws an SVG with the tariff (ochre step
line), the state of charge (green area), shaded cheap hours and a cursor for the time slider (#day-time, 0 to
47; default 35 = 17:30). On narrow screens it redraws with a narrower viewBox and larger labels.
Expected values for the Heatloop 28 at 17:30: 34p / kWh, Releasing, 62% · 17.4 kWh, 1.93 kWh heat to the home;
at 04:00 it is full (Charging, 100%); at 13:00 it is Topping up. Over the day: Heatloop 18 buys 46.0 kWh, 59% at
the cheap rate, about £5; Heatloop 28, 79%, about £4; Heatloop 40, 84%, about £4. "Play the day" steps through
the slots (slower with reduced motion) and stops at 23:30; the slider has an aria-valuetext and the readouts are
in a polite live region.
Savings and carbon estimator#
initEstimator() reads the radios and selects in [data-estimator] and calls estimate():
- Heat =
HOMES[size].heat × REGIONS[region].climate. - Now: heat ÷
eff= fuel kWh; ×price= cost; ×co2= kg CO2e. - Blended Heatloop price =
shift × off + (1 − shift) × peak. - Heatloop: heat ÷
HEATLOOP_EFF= electricity; × blended price = cost; carbon usesGRID_CO2.offand.peak.
Expected results: semi, oil, time-of-use, North East Scotland (the defaults): £1,262 now, £1,149 with a Heatloop, "£113 less a year", 4.0 t to 1.7 t (58% less), Heatloop 28. Semi with gas: "£130 more a year", 3.0 t to 1.7 t. Flat, direct electric, Economy 7, Southern England: "£687 less a year", Heatloop 18. The estimate is labelled as an estimate, says when a Heatloop would cost more to run, and the formula section shows every step with the visitor's numbers. Inputs are kept in the URL (see URL parameters); "Copy link to this estimate" copies it, with the link shown as text if the clipboard isn't available.
Reservation#
initReserve() runs three <fieldset data-step> steps with a step indicator (aria-current="step"):
- Model: radio cards for
hl18,hl28,hl40(preselected from?model=). - Postcode: checked against a UK postcode pattern; the outward code (e.g.
AB15) is looked up inCOVERAGE("Good news: we install in AB15", with first surveys from the monthSURVEY_LEAD_MONTHSahead), thenCOMING_NEXT("next on our list") or neither ("We don't install in … yet"; you can still reserve). - Contact: name, email, optional UK phone, optional newsletter.
On submit it saves the reservation (see Saved data) and shows the success panel with a
reference like HL-AB15-7K2Q, the place in the queue (RESERVATIONS_AT_ANCHOR + 1, plus growth while
KEEP_DEMO_CURRENT is on; 2,141 on 27 October 2026) and what happens next. Returning visitors see the saved
panel ("Welcome back, …"), and the home page and header buttons change to "View your reservation" / "Your place".
"Change details" reopens the form filled in (keeping the reference and place); "Cancel reservation" deletes it.
Careers: filters, saved roles and role dialog#
initCareers() renders ROLES into [data-roles], filtered by team (#filter-team), location
(#filter-location) and "Remote only" (#filter-remote, roles with mode: 'remote'); the count reads "11 open
roles" (or "1 open role"). Filters are kept in the URL. Each role has a Save toggle ([data-save-role],
aria-pressed) and Details, which opens the native <dialog id="role-dialog"> with what you'll do, what you'll
bring, salary, a save toggle and "Apply for this role" (to contact.html?type=careers&role=<id>). Saved roles are
listed in the sidebar ([data-saved-list], count in [data-saved-count]). ?role=<id> opens a role directly.
Esc and the close buttons close the dialog and focus returns to the button that opened it.
News, rolling dates and events#
initNewsFilter() filters posts by data-topic with [data-topic-filter] toggles, kept in ?topic=; the count
reads "9 posts". A link to news.html#post-<id> opens that post. initEvents() builds the coming-up list from
EVENT_RULES (first Saturday open morning, second Wednesday webinar, last Thursday installer evening): the next
date of each rule after now, in London time, so the list is never empty or in the past.
Demo dates#
Demos stay online for years, so dated content rolls forward. With KEEP_DEMO_CURRENT = true (top of main.js),
everything written for DEMO_ANCHOR ('2026-10-27') moves forward by the whole weeks since that day
(weekdays stay right):
- News dates (
time[data-news-date], innews.htmland on the home page). - Milestone and timeline years (
[data-shift-year]), by the whole years in that shift. - The reservation count (
[data-reservations]) and new queue places, byRESERVATIONS_PER_WEEK.
On 27 October 2026 nothing moves, so the page shows exactly the text it was written with. Always computed from
today, regardless of the switch: the copyright year ([data-year]), the coming-up events and the survey month
([data-survey-month]). For a real site with fixed dates, set KEEP_DEMO_CURRENT = false.
Contact type and role#
contact.html?type=press preselects the enquiry type (own keys of CONTACT_TYPES); ?role=<id> (an id in
ROLES) selects Careers and starts the message with "Application: <role title>".
Header, mobile menu, scroll reveals and back to top#
The menu button toggles #mobile-menu with aria-expanded; Esc closes it and returns focus. Elements with
reveal fade in as they scroll into view (and everything above them); with reduced motion they show at once.
initBackToTop() adds a back-to-top button after scrolling, which moves focus to #main.
Forms#
| Form | Page | Validates |
|---|---|---|
[data-reserve] |
reserve.html |
Model chosen; postcode format; name; email; optional UK phone |
#contact-form |
contact.html |
Enquiry type, name, email, optional UK phone, message of at least 20 characters |
#newsletter |
every page |
Errors appear under each field (linked with aria-describedby) and set aria-invalid; focus moves to the first
problem. The contact and newsletter forms use data-validate and data-success (the id of the success panel),
handled by initForms(). The contact panel thanks the sender by first name, names the team that will reply
(CONTACT_REPLY) and shows a reference like HL-E-7K2Q.
The forms don't send anything until you connect them. To use a form service such as Formspree, replace the
comment marked "To connect a real endpoint" in initForms() (and "To connect a real backend" in initReserve())
with a request, and show the success state only when it answers OK:
fetch('https://formspree.io/f/your-id', {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' }
}).then(function (res) {
if (!res.ok) throw new Error('Send failed');
panel.hidden = false;
form.hidden = true;
panel.focus();
}).catch(function () {
announce('Sorry, that didn’t send. Please call us on 01632 960776.');
});The reservation form takes no payment; the page says so.
Saved data (localStorage)#
| Key | Shape | Validated on read |
|---|---|---|
heatloop:theme |
"dark" or "light" |
Anything else is ignored and the system setting is used |
heatloop:reservation |
{ ref, model, postcode, name, email, phone, updates, place, created } |
ref matches HL-XXXX-XXXX; model an own key of MODELS; postcode a valid UK postcode; name non-empty; email a string; place a positive number; created a YYYY-MM-DD date. Otherwise the reservation is ignored |
heatloop:saved-roles |
["firmware-engineer", "surveyor"] |
Only ids in ROLES, without duplicates |
All storage goes through one safeStorage object: localStorage, or an in-memory stand-in when the browser blocks
storage (a sandboxed iframe, strict privacy settings), so features keep working for the page view. To reset, cancel
the reservation, unsave roles, or run localStorage.removeItem('heatloop:reservation') in the console.
URL parameters#
| Page | Parameters |
|---|---|
estimator.html |
size (flat, terrace, semi, detached), heat (gas, oil, lpg, storage, electric), tariff (tou, e7, single), region (nescot, highland, central, north, midlands, south) |
reserve.html |
model (hl18, hl28, hl40) |
careers.html |
team, location, remote=1, role=<id> |
news.html |
topic (product, installs, research, company) |
contact.html |
type (a CONTACT_TYPES key), role=<id> |
Every value is checked with an own-key lookup (Object.prototype.hasOwnProperty.call), so unknown values fall back
to the defaults.
Time zone#
All dates use TIME_ZONE = 'Europe/London', not the visitor's clock.
SEO and sharing#
Every page has its own <title>, meta description, theme-color (light and dark), Open Graph tags (og:locale
en_GB) and a Twitter card. og:image is the relative path assets/img/og-image.png; social sites need an
absolute URL, so after deploying change it to e.g. https://www.example.co.uk/assets/img/og-image.png on every
page. To regenerate the PNG after editing the SVG, open og-image.svg in a browser at 1200 x 630 and take a
screenshot, or use any SVG-to-PNG converter.
Add a sitemap.xml listing the eight pages and a robots.txt that points to it. You could add Organization and
Product structured data (JSON-LD) to index.html and technology.html.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), one<h1>per page and logical headings. - Visible copper focus outlines, AA contrast in both themes; ochre and ember are used only for decoration.
- Toggle buttons with
aria-pressed(filters, models, saves, theme),aria-expandedon the menu button,aria-current="page"in the navigation andaria-current="step"in the reservation steps. - Native
<dialog>for role details,<details>for questions and news posts, radio buttons inside the model and home cards. - Informative SVGs have
role="img"and a<title>; decorative ones arearia-hidden. The day chart's readouts repeat its values as text. - Polite live regions for results and announcements (
#live-region). prefers-reduced-motionturns off reveals, the chart pulse and flow animation, and smooth scrolling.
When editing, keep one <h1>, label every new input, and make sure every aria-controls, aria-labelledby and
aria-describedby points at an id on the page.
Building and deploying#
Rebuilding the CSS#
assets/css/style.css is compiled from src/input.css by Tailwind CSS v4, which scans the HTML files and
assets/js/ for class names. With Node.js 24:
npm install
npm run build # once
npm run watch # rebuild on every changeClasses that only appear inside JavaScript strings must be written out in full in assets/js/main.js.
Deploying#
The folder is a static site. Upload it as-is to any static host: Netlify Drop, GitHub Pages, Cloudflare Pages or
plain web hosting. Configure your host to serve 404.html for missing pages. You don't need to upload src/,
package.json or the Markdown files.
Troubleshooting#
- The estimator says a Heatloop costs more than gas. That's the honest result with the illustrative prices
in
HEATINGandTARIFFS. Update them to current prices for your market. - A postcode I cover shows as not covered. Add its outward code (the part before the space) to
COVERAGE, and to the list onreserve.html. - News dates look a year later than I wrote them.
KEEP_DEMO_CURRENTis on. Set it tofalseonce the posts are real. - The day chart labels are tiny. It redraws with larger labels below 560 px wide; if you put it in a narrower
column, lower that breakpoint in
initDayChart(). - New Tailwind classes have no effect. Rebuild the CSS (
npm run build), and write class names in full. - The saved reservation disappears after closing the tab. The browser is blocking storage (private mode, a sandboxed iframe); the site keeps it in memory for that page view.
- The page flashes light before turning dark. Keep the small theme script in each page's
<head>, before the stylesheet loads the body.
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. Hedvig Letters Serif, Hedvig Letters Sans and Source Code Pro are
licensed under the SIL Open Font License. Heatloop, its people, backers, press quotes, products and figures are
fictional.