Commonwatt Home Battery Virtual Power Plant AI website prompt
A climate-tech page for a neighbourhood battery network. A live grid-frequency meter ticks in the nav, an x-ray hero reveals a second layer under a cursor lens, a canvas town map plays through chapters as you scroll, and an earnings calculator rolls its digits. Ten scripts, six self-hosted fonts, no build step.
Now put it in WordPress
You have a site. Now make it yours.
An AI build hands you a front end and stops there. No CMS, no client login, no real ownership: you cannot hand it over, and you cannot change it without going back and re-prompting. UiChemy turns that build into native, editable WordPress you own, in the builder you already use.
Bolt to WordPressClaude to WordPressCodex to WordPressCursor to WordPressLovable to WordPressv0 to WordPressAI to WordPress
Get Started
More prompts
Showing 3 prompts
Frequently asked questions
What does the Commonwatt Home Battery Virtual Power Plant prompt build?
A climate-tech page for a neighbourhood battery network. A live grid-frequency meter ticks in the nav, an x-ray hero reveals a second layer under a cursor lens, a canvas town map plays through chapters as you scroll, and an earnings calculator rolls its digits. Ten scripts, six self-hosted fonts, no build step.
Which AI tools does the Commonwatt Home Battery Virtual Power Plant prompt work with?
It's written for Claude. Paste the whole prompt into Claude and it builds the complete site for you, then bring the result into WordPress with UiChemy. This prompt also has a WordPress version: with the UiChemy MCP connected, Claude builds the page straight into WordPress.
How do I turn the result into a WordPress site?
After the AI builds your site, UiChemy Composer rebuilds it as native, editable WordPress in Elementor or Gutenberg (Bricks coming soon) — you own it and edit it in your builder. See AI to WordPress.
Is the Commonwatt Home Battery Virtual Power Plant prompt free?
Yes — it's free to copy, no account needed.
Can I edit the website after it's built?
Yes. Once it's in WordPress with UiChemy, every section is native and editable — no re-prompting, no lock-in.
Copy prompt
Copy code
Coming soon
Copy SKILL.md
Coming soon
Commonwatt Home Battery Virtual Power Plant · Section prompt
# MASTER PROMPT — Commonwatt · Your battery is bored. Give it a job.
### Plain HTML, CSS and JavaScript. No WordPress, no page builder, no framework, no build step.
## ▶ HOW TO USE THIS PROMPT
**Paste this whole document into Claude. Nothing to attach.** It carries the complete source of the site plus the address of every image, so Claude can assemble a folder that owns its own assets and hand it back as a zip.
**INSTRUCTION TO CLAUDE — BUILD IT, DO NOT ASK.** Every decision is already made in this document. Do not stop to ask the user questions, do not offer options, and do not ask for confirmation before starting. Write the files, zip them, hand over the archive, then report what you built.
1. Create a folder named `commonwatt` and write out every file in the appendix below **exactly as written** — do not reformat, rename, shorten, reorder or "improve" anything.
2. **Download the 16 images and 6 fonts into `assets/`.** The code already points at `assets/…` — nothing in it references a remote host. Run this from inside the folder; it reads the ASSET MANIFEST at the end of this document:
```bash
mkdir -p assets
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-hanken-grotesk-arrows.woff2' -o 'assets/hanken-grotesk-arrows.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-hanken-grotesk-latin.woff2' -o 'assets/hanken-grotesk-latin.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-instrument-sans-arrows.woff2' -o 'assets/instrument-sans-arrows.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-instrument-sans-latin.woff2' -o 'assets/instrument-sans-latin.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-jetbrains-mono-arrows.woff2' -o 'assets/jetbrains-mono-arrows.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-jetbrains-mono-latin.woff2' -o 'assets/jetbrains-mono-latin.woff2'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-calc-install.jpg' -o 'assets/calc-install.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-cta-street.jpg' -o 'assets/cta-street.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-garage.jpg' -o 'assets/feature-garage.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-hand-phone.jpg' -o 'assets/feature-hand-phone.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-kitchen.jpg' -o 'assets/feature-kitchen.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-storm-house.jpg' -o 'assets/feature-storm-house.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-batteries.jpg' -o 'assets/fly-batteries.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-heatwave.jpg' -o 'assets/fly-heatwave.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-kettles.jpg' -o 'assets/fly-kettles.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-storm.jpg' -o 'assets/fly-storm.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-substation.jpg' -o 'assets/fly-substation.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-wind.jpg' -o 'assets/fly-wind.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-hero-roof.jpg' -o 'assets/hero-roof.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-member-01.jpg' -o 'assets/member-01.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-member-02.jpg' -o 'assets/member-02.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-stat-aerial.jpg' -o 'assets/stat-aerial.jpg'
```
Then check every file arrived and none is a zero-byte or HTML error page:
```bash
ls assets | wc -l # expect 22 files
find assets -size -1k # expect no output
```
If any download fails, retry it — the host is occasionally slow. Do **not** fall back to hotlinking the remote URL: the whole point is a folder that works offline.
3. **Zip that folder and give the user the archive.** Its contents:
```
commonwatt/
index.html
css/style.css
js/calc.js
js/core.js
js/hero.js
js/main.js
js/map.js
js/meter.js
js/sees.js
js/stats.js
js/tour.js
assets/
hanken-grotesk-arrows.woff2
hanken-grotesk-latin.woff2
instrument-sans-arrows.woff2
instrument-sans-latin.woff2
… 18 more images
```
```bash
zip -r commonwatt.zip commonwatt -x '*.DS_Store'
```
Then hand over `commonwatt.zip` as a file the user can download, not as text in the chat.
4. Before handing it over, serve the folder and confirm it runs:
```bash
cd commonwatt && python3 -m http.server 8000 # then open http://localhost:8000
```
Check the page renders, the images load from `assets/` with the network throttled or offline, and scrolling drives the animations.
The finished folder needs no network for its images — they are in `assets/`.
Do not rebuild the design from the description below. The description is there so a person can understand and edit the code. The appendix is the deliverable.
---
## ▶ WHAT YOU GET
Commonwatt links the home batteries of a neighbourhood into one virtual power plant, sells your stored sun back to the grid at peak time and pays you every month.
**15 code files plus an `assets/` folder holding 16 images and 6 fonts, delivered as `commonwatt.zip`.**
| File | What it is |
|---|---|
| `index.html` | a page, 26 KB |
| `css/style.css` | the stylesheet, 39 KB |
| `js/calc.js` | the script, 4 KB |
| `js/core.js` | the script, 5 KB |
| `js/hero.js` | the script, 5 KB |
| `js/main.js` | the script, 6 KB |
| `js/map.js` | the script, 21 KB |
| `js/meter.js` | the script, 2 KB |
| `js/sees.js` | the script, 6 KB |
| `js/stats.js` | the script, 1 KB |
| `js/tour.js` | the script, 4 KB |
| `assets/` | 16 images and 6 fonts, downloaded at build time |
**Sections (7):** `#top` · `#grid-sees` · `#map` · `#features` · `#numbers` · `#calculator` · `#survey`.
---
## ▶ WHERE THE MEDIA COMES FROM
All 22 files (16 images and 6 fonts) live in `assets/` inside the folder, and every reference in the markup, the CSS and the script is a relative `assets/…` path. Once the folder is assembled it owns its own media: no CDN, no media library, no external image host. Open it on a laptop with the wifi off and it still renders.
They are fetched **once, at build time**, from the WordPress media library on `demo.uichemy.com` — the same `commonwatt__` files the WordPress build of this page uses. That is only how they travel into the folder; nothing in the delivered code points back at that host. Replacing an image means dropping a new file into `assets/` under the same name.
The manifest at the end of this document lists every source URL against the filename it is saved as, so the download step is reproducible and checkable.
---
## ▶ HOW THE MOTION IS PUT TOGETHER
Nearly every visual effect is scroll-driven: 71 GSAP calls and 17 ScrollTrigger attachments.
- Reveals generally run both ways, so scrolling back up replays them in reverse rather than leaving them finished.
- Pinned sections own their scroll distance in the markup, so the document height is authored rather than grown by script.
- The numbers are inline — durations, eases, stagger, parallax speeds. There is no config layer and no control panel; it is plain GSAP.
---
## ▶ DESIGN TOKENS
Everything is driven from custom properties at the top of the stylesheet:
`--volt` `--cobalt` `--white` `--black` `--ink-04` `--ink-08` `--ink-12` `--ink-22` `--ink-60` `--ink-72` `--paper-10` `--paper-18` `--paper-50` `--paper-72` `--cobalt-08` `--cobalt-16` `--f-title` `--f-ui` `--f-mono` `--ease` `--ease-io` `--gut` `--nav-h`
---
## ▶ WHAT THIS PROMPT DELIBERATELY DOES NOT CONTAIN
- No WordPress, no page builder, and none of the wrapper classes or compatibility CSS a builder needs. The WordPress build is a separate prompt.
- No framework, no TypeScript, no bundler, no `package.json`.
- No backend. Any form here validates and replies in place; it posts nowhere.
- No CMS. The content lives in the files.
- No external image host. The images ship inside the zip; nothing in the code calls out to a media library at runtime.
---
## ASSET MANIFEST — download these into `assets/`
Source is the media library on `demo.uichemy.com`. Save each one under the name in the second column; the code already refers to it by that name.
| Download from | Save as |
|---|---|
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-hanken-grotesk-arrows.woff2` | `assets/hanken-grotesk-arrows.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-hanken-grotesk-latin.woff2` | `assets/hanken-grotesk-latin.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-instrument-sans-arrows.woff2` | `assets/instrument-sans-arrows.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-instrument-sans-latin.woff2` | `assets/instrument-sans-latin.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-jetbrains-mono-arrows.woff2` | `assets/jetbrains-mono-arrows.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__fonts-jetbrains-mono-latin.woff2` | `assets/jetbrains-mono-latin.woff2` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-calc-install.jpg` | `assets/calc-install.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-cta-street.jpg` | `assets/cta-street.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-garage.jpg` | `assets/feature-garage.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-hand-phone.jpg` | `assets/feature-hand-phone.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-kitchen.jpg` | `assets/feature-kitchen.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-feature-storm-house.jpg` | `assets/feature-storm-house.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-batteries.jpg` | `assets/fly-batteries.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-heatwave.jpg` | `assets/fly-heatwave.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-kettles.jpg` | `assets/fly-kettles.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-storm.jpg` | `assets/fly-storm.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-substation.jpg` | `assets/fly-substation.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-fly-wind.jpg` | `assets/fly-wind.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-hero-roof.jpg` | `assets/hero-roof.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-member-01.jpg` | `assets/member-01.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-member-02.jpg` | `assets/member-02.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/commonwatt__images-stat-aerial.jpg` | `assets/stat-aerial.jpg` |
---
# APPENDIX · VERBATIM SOURCE
> **The four vendor libraries load from their CDNs** rather than being transcribed here —
> together they are ~140 KB of minified source, which belongs in a `<script src>`, not in a
> prompt. The versions are the ones this project pinned (GSAP 3.13, ScrollTrigger, CustomEase,
> Lenis 1.3) and `index.html` already points at them, in the original load order: GSAP,
> ScrollTrigger, CustomEase, Lenis, then the project's own scripts. Everything else in the
> appendix is the project's own code, verbatim.
The complete source of every file. **Write them out exactly** — do not change a value, a comment, a class name, or the order of a declaration. Recreate this file layout:
```
index.html
css/style.css
js/calc.js
js/core.js
js/hero.js
js/main.js
js/map.js
js/meter.js
js/sees.js
js/stats.js
js/tour.js
```
## `index.html`
```html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Commonwatt · Your battery is bored. Give it a job.</title>
<meta name="description" content="Commonwatt links the home batteries of a neighbourhood into one virtual power plant, sells your stored sun back to the grid at peak time and pays you every month.">
<meta name="theme-color" content="#0038FF">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230038FF'/%3E%3Crect x='9' y='7.5' width='14' height='19' rx='2' fill='none' stroke='%23FFFFFF' stroke-width='2'/%3E%3Crect x='13' y='4' width='6' height='3' fill='%23FFFFFF'/%3E%3Crect x='12' y='15' width='8' height='8.5' fill='%2319F59A'/%3E%3C/svg%3E">
<script>
(function () {
var d = document.documentElement, q = location.search;
var stat = /[?&]static\b/.test(q), skip = stat || /[?&]skip-intro\b/.test(q);
var rm = stat || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
d.className += ' js' + (rm ? ' rm' : ' motion') + (skip || rm ? '' : ' has-intro') + (stat ? ' is-static' : '');
})();
</script>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Blur filters for the hero blur-in (stdDeviation animated 50 → 0) -->
<svg class="svg-defs" aria-hidden="true" focusable="false" width="0" height="0">
<filter id="cwBlur0" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="50"/></filter>
<filter id="cwBlur1" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="50"/></filter>
<filter id="cwBlur2" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="50"/></filter>
</svg>
<!-- Intro: the network charging 0 → 100 -->
<div class="loader" aria-hidden="true">
<div class="loader__inner">
<p class="mono loader__label">Charging Harrow Vale</p>
<p class="loader__count"><span class="loader__num">0</span><span class="loader__pct">%</span></p>
<div class="loader__bar"><i></i></div>
</div>
</div>
<header class="nav" data-state="full">
<div class="nav__bar">
<a class="nav__logo" href="#top" aria-label="Commonwatt, back to top">
<svg class="nav__mark" viewBox="0 0 20 28" aria-hidden="true"><rect x="1" y="4" width="18" height="23" rx="2.5" fill="none" stroke="currentColor" stroke-width="2"/><rect x="6" y="0.5" width="8" height="3" fill="currentColor"/><rect class="nav__markfill" x="5" y="13" width="10" height="10"/></svg>
<span class="nav__word">Commonwatt</span>
</a>
<nav class="nav__links" aria-label="Sections">
<a class="roll" href="#grid-sees">The idea</a>
<a class="roll" href="#map">The map</a>
<a class="roll" href="#features">Features</a>
<a class="roll" href="#calculator">Earnings</a>
<a class="roll" href="#grids">For grids</a>
</nav>
<div class="meter" data-mode="surplus" role="group" aria-label="Simulated grid frequency">
<svg class="meter__gauge" viewBox="0 0 40 24" aria-hidden="true">
<path class="meter__arc" d="M4 21 A16 16 0 0 1 36 21" fill="none" stroke-width="1.5"/>
<g class="meter__ticks" stroke-width="1.2">
<line x1="4" y1="21" x2="7" y2="21"/><line x1="8.7" y1="9.7" x2="10.8" y2="11.8"/><line x1="20" y1="5" x2="20" y2="8"/><line x1="31.3" y1="9.7" x2="29.2" y2="11.8"/><line x1="36" y1="21" x2="33" y2="21"/>
</g>
<line class="meter__needle" x1="20" y1="21" x2="20" y2="7.5" stroke-width="1.6" stroke-linecap="round"/>
<circle class="meter__hub" cx="20" cy="21" r="2.2"/>
</svg>
<span class="meter__read">
<span class="meter__hz"><b class="meter__val">50.00</b> Hz</span>
<span class="meter__state"><span class="meter__mode">Surplus</span> <span class="meter__clock">13:00</span></span>
</span>
</div>
<button class="nav__menu" type="button" aria-expanded="false" aria-controls="navDrop"><span class="roll">Menu</span></button>
<a class="nav__cta btn btn--cobalt" href="#calculator"><span class="roll">Join</span></a>
</div>
<div class="nav__drop" id="navDrop" hidden>
<a class="roll" href="#grid-sees">The idea</a>
<a class="roll" href="#map">The map</a>
<a class="roll" href="#features">Features</a>
<a class="roll" href="#calculator">Earnings</a>
<a class="roll" href="#grids">For grids</a>
</div>
</header>
<main>
<!-- 1 · X-ray hero -->
<section id="top" class="hero" aria-label="Introduction">
<div class="hero__stage">
<div class="hero__layer hero__layer--base">
<div class="hero__photo"><img src="assets/hero-roof.jpg" alt="Rooftop solar panels on a row of terraced houses at golden hour" width="2400" height="1600" fetchpriority="high"></div>
<!-- x-ray only: blueprint grid + energy flow, roof → battery → street -->
<div class="xr-only xr-grid"></div>
<svg class="xr-only xr-flow" viewBox="0 0 1000 600" preserveAspectRatio="none" aria-hidden="true">
<path class="xr-flow__line" d="M905 70 C 860 160, 760 190, 690 250 S 590 330, 560 360"/>
<path class="xr-flow__line" d="M745 45 C 745 140, 700 200, 640 250"/>
<path class="xr-flow__line xr-flow__line--thick" d="M560 360 C 520 420, 430 470, 330 520 S 180 560, 0 560"/>
<path class="xr-flow__line" d="M560 360 C 640 430, 760 470, 1000 560"/>
<path class="xr-flow__bus" d="M0 575 L1000 575"/>
</svg>
<span class="xr-only xr-node" style="left:90.5%;top:11.6%"><i></i>Roof 4.2 kW</span>
<span class="xr-only xr-node" style="left:74.5%;top:7.5%"><i></i>Roof 3.6 kW</span>
<span class="xr-only xr-node xr-node--batt" style="left:56%;top:60%"><i></i>Battery 78%</span>
<span class="xr-only xr-node" style="left:33%;top:86.6%"><i></i>Street feeder 11 kV</span>
<div class="hero__grid">
<aside class="hero__meta">
<p class="mono hero__kicker">Virtual power plant</p>
<p class="hero__lede">We link the home batteries of a whole town into one power plant, sell your stored sun when the grid is desperate for it, and pay you on the first of every month.</p>
<a class="btn btn--cobalt hero__cta" href="#calculator"><span class="roll">See what you'd earn</span></a>
<p class="hero__place">Harrow Vale, UK<br><span class="hero__homes">38,400 homes on the network</span></p>
</aside>
<div class="hero__rule" aria-hidden="true"></div>
<h1 class="hero__title t-title">
<span class="hero__block" data-b="0">Your battery</span>
<span class="hero__block hero__block--step" data-b="1">is bored.</span>
<span class="hero__block" data-b="2">Give it a job.</span>
</h1>
</div>
</div>
<!-- lens chrome (not clipped): disc, crosshair, annotation -->
<div class="hero__lens" aria-hidden="true">
<span class="lens__disc"></span>
<span class="lens__h"></span><span class="lens__v"></span>
<span class="mono lens__note"><span class="lens__mode">Surplus</span> 3.2 kWh → Grid <span class="lens__time">17:42</span></span>
</div>
<p class="mono hero__hint" aria-hidden="true">Move to see the current</p>
</div>
</section>
<!-- 2 · What the grid sees: line list + depth fly-through -->
<section id="grid-sees" class="sees" aria-label="What the grid sees">
<div class="sees__stage">
<p class="mono sees__kicker">What the grid sees, any Tuesday</p>
<ol class="sees__list">
<li class="sees__item"><span>A heatwave at 18:00.</span></li>
<li class="sees__item"><span>Forty thousand kettles at half-time.</span></li>
<li class="sees__item"><span>A wind farm that went quiet.</span></li>
<li class="sees__item"><span>A substation at its limit.</span></li>
<li class="sees__item"><span>A street of dark windows in a storm.</span></li>
<li class="sees__item"><span>And 38,400 batteries, ready.</span></li>
</ol>
<div class="sees__space" aria-hidden="true">
<div class="sees__floor"></div>
<figure class="sees__card"><figcaption class="tag">18:00 · 31°C · load +2.4 GW</figcaption><img src="assets/fly-heatwave.jpg" alt="" width="1200" height="900"></figure>
<figure class="sees__card"><figcaption class="tag">Half-time · +1.1 GW in 3 min</figcaption><img src="assets/fly-kettles.jpg" alt="" width="1200" height="900"></figure>
<figure class="sees__card"><figcaption class="tag">Wind output −68%</figcaption><img src="assets/fly-wind.jpg" alt="" width="1200" height="900"></figure>
<figure class="sees__card"><figcaption class="tag">Substation HV-14 · 98% load</figcaption><img src="assets/fly-substation.jpg" alt="" width="1200" height="900"></figure>
<figure class="sees__card"><figcaption class="tag">Storm · 212 homes dark</figcaption><img src="assets/fly-storm.jpg" alt="" width="1200" height="900"></figure>
<figure class="sees__card"><figcaption class="tag">38,400 units · 212 MWh ready</figcaption><img src="assets/fly-batteries.jpg" alt="" width="1200" height="900"></figure>
</div>
<p class="sees__outro">We see all of it, second by second, and we move your stored sun to where it's needed.</p>
</div>
</section>
<!-- 3 · Harrow Vale grid map, chaptered -->
<section id="map" class="map" aria-label="How the network works, in five steps">
<div class="map__stage">
<canvas class="map__canvas" aria-hidden="true"></canvas>
<div class="map__head">
<p class="mono map__kicker">Harrow Vale · live network · 17:42</p>
<div class="map__titles">
<h2 class="map__title t-title is-on">Sense.</h2>
<h2 class="map__title t-title">Forecast.</h2>
<h2 class="map__title t-title">Pool.</h2>
<h2 class="map__title t-title">Dispatch.</h2>
<h2 class="map__title t-title">Pay.</h2>
</div>
</div>
<div class="map__foot">
<div class="map__caps">
<p class="map__cap is-on">Every battery in town reports its charge, its roof and its plans for tonight. Every two seconds.</p>
<p class="map__cap">We see the six o'clock rush coming an hour early. The Crofts will want 18% more than its wires like.</p>
<p class="map__cap">Batteries across town chip in a little each. Nobody runs flat, and nobody notices.</p>
<p class="map__cap">Stored sun goes out at the peak, when it's worth the most. The substation breathes out.</p>
<p class="map__cap">The grid pays for the help. We pass it on, itemised, on the first of the month.</p>
</div>
<ol class="map__steps mono" aria-hidden="true">
<li class="is-on">Sense</li><li>Forecast</li><li>Pool</li><li>Dispatch</li><li>Pay</li>
</ol>
</div>
<div class="map__hud mono" aria-hidden="true"><div class="map__log"></div></div>
</div>
</section>
<!-- 4 · Feature tour on a white sheet -->
<section id="features" class="tour" aria-label="Features">
<div class="tour__rail" aria-hidden="true">
<div class="tour__track">
<span>Mill Lane <b>214</b></span><span>Orchard Rise <b>96</b></span><span>The Crofts <b>388</b></span><span>Station Row <b>152</b></span><span>Kiln Field <b>271</b></span><span>Weir Side <b>119</b></span><span>Tanners Way <b>64</b></span><span>Chapel Green <b>203</b></span><span>Mill Lane <b>214</b></span><span>Orchard Rise <b>96</b></span><span>The Crofts <b>388</b></span><span>Station Row <b>152</b></span>
</div>
</div>
<div class="tour__grid">
<div class="tour__side">
<p class="mono tour__kicker">What your battery gets out of it</p>
<ul class="tour__titles t-title">
<li class="is-on"><a href="#f-health">Battery health</a></li>
<li><a href="#f-earn">Earnings</a></li>
<li><a href="#f-outage">Outage shield</a></li>
<li><a href="#f-app">The app</a></li>
</ul>
<p class="tour__aside">We never cycle your battery harder than its warranty allows. It's in the contract, in plain English.</p>
</div>
<div class="tour__panels">
<article class="tour__panel" id="f-health">
<h3 class="tour__inline t-title">Battery health</h3>
<div class="tour__frame">
<img class="tour__img" src="assets/feature-garage.jpg" alt="A white home battery unit mounted on a garage wall beside a bicycle" width="1600" height="1200">
<div class="ui ui-health">
<div class="ui__head"><span class="mono">Capacity vs cycles</span><span class="mono ui__chip">Warranty floor 70%</span></div>
<div class="ui-health__bars">
<i style="--h:100%"><b>0</b></i><i style="--h:99%"><b>500</b></i><i style="--h:98%"><b>1k</b></i><i style="--h:97%"><b>1.5k</b></i><i style="--h:96%"><b>2k</b></i><i style="--h:95%"><b>2.5k</b></i><i style="--h:94%"><b>3k</b></i><i style="--h:93%"><b>3.5k</b></i><i style="--h:92%"><b>4k</b></i><i style="--h:91.5%"><b>4.5k</b></i><i class="is-hi" style="--h:91%"><b>5k</b></i>
<span class="ui-health__floor"><span class="mono">70% floor</span></span>
</div>
<p class="ui__foot mono">After 5,000 cycles: <b>91%</b> of new</p>
</div>
</div>
<h4 class="tour__cap">Worked, not worn out.</h4>
<p class="tour__body">We read every cell's temperature and state of charge before each dispatch, and we skip your battery when it needs a rest. Ten years in, it should still hold nine-tenths of what it did on day one.</p>
</article>
<article class="tour__panel" id="f-earn">
<h3 class="tour__inline t-title">Earnings</h3>
<div class="tour__frame">
<img class="tour__img" src="assets/feature-kitchen.jpg" alt="A kitchen in the evening, a kettle and a teapot on the counter under warm light" width="1600" height="1200">
<div class="ui ui-earn">
<div class="ui__head"><span class="mono">Paid to you, 2026</span><span class="t-num ui-earn__total">£612</span></div>
<div class="ui-earn__chart">
<i style="--h:28%"></i><i style="--h:34%"></i><i style="--h:46%"></i><i style="--h:52%"></i><i style="--h:61%"></i><i style="--h:70%"></i><i class="is-hi" style="--h:96%"><em class="mono">£88</em></i><i style="--h:78%"></i><i style="--h:55%"></i><i style="--h:0%" class="is-future"></i><i style="--h:0%" class="is-future"></i><i style="--h:0%" class="is-future"></i>
</div>
<div class="ui-earn__months mono"><span>J</span><span>F</span><span>M</span><span>A</span><span>M</span><span>J</span><span>J</span><span>A</span><span>S</span><span>O</span><span>N</span><span>D</span></div>
</div>
</div>
<h4 class="tour__cap">A payslip for your garage.</h4>
<p class="tour__body">Every kilowatt-hour we sell for you is itemised: when it left, what it fetched, what you kept. July's heatwave was a good month. We'll tell you when one is a bad month, too.</p>
</article>
<article class="tour__panel" id="f-outage">
<h3 class="tour__inline t-title">Outage shield</h3>
<div class="tour__frame">
<img class="tour__img" src="assets/feature-storm-house.jpg" alt="A detached house with lit windows during a storm while the street around it is dark" width="1600" height="1200">
<div class="ui ui-outage">
<div class="ui__head"><span class="mono">Storm night, Weir Side</span><span class="mono ui__chip ui__chip--cobalt">Reserve 20% held</span></div>
<div class="ui-outage__track">
<div class="ui-outage__lane"><span class="mono">Street</span><i class="ui-outage__grid"></i><i class="ui-outage__down"><em class="mono">Grid down 2 h 14 m</em></i></div>
<div class="ui-outage__lane"><span class="mono">Your house</span><i class="ui-outage__home"></i></div>
</div>
<div class="ui-outage__axis mono"><span>19:00</span><span>20:00</span><span>21:00</span><span>22:00</span><span>23:00</span></div>
<svg class="ui-outage__house" viewBox="0 0 64 56" aria-hidden="true"><path d="M6 26 L32 6 L58 26 V52 H6 Z" fill="none" stroke="currentColor" stroke-width="2.5"/><rect class="win" x="17" y="30" width="10" height="10"/><rect class="win" x="37" y="30" width="10" height="10"/></svg>
</div>
</div>
<h4 class="tour__cap">The lights stay on. Just yours.</h4>
<p class="tour__body">We always keep a slice of your battery in reserve. When the street goes dark, your house switches over in under 20 milliseconds, and the fridge never finds out.</p>
</article>
<article class="tour__panel" id="f-app">
<h3 class="tour__inline t-title">The app</h3>
<div class="tour__frame">
<img class="tour__img" src="assets/feature-hand-phone.jpg" alt="A hand holding a phone at a kitchen window, screen blurred" width="1600" height="1200">
<div class="phone">
<div class="phone__screen">
<div class="phone__top mono"><span>17:42</span><span>Commonwatt</span></div>
<svg class="phone__ring" viewBox="0 0 120 120" aria-hidden="true">
<circle cx="60" cy="60" r="50" class="phone__ringbg"/>
<circle cx="60" cy="60" r="50" class="phone__ringfg" pathLength="100"/>
</svg>
<p class="phone__pct t-num"><span class="phone__pctv">78</span><small>%</small></p>
<p class="phone__lbl mono">Charged · 7.8 of 10 kWh</p>
<div class="phone__today">
<span class="mono">Earned today</span>
<span class="t-num phone__earn">£1.84</span>
</div>
<button class="phone__toggle" type="button" role="switch" aria-checked="true"><span class="phone__knob"></span><span class="phone__tlabel">Sell at peak</span></button>
<p class="phone__foot mono">Next dispatch 18:05 · 1.9 kWh</p>
</div>
</div>
</div>
<h4 class="tour__cap">One switch. Honestly, that's it.</h4>
<p class="tour__body">Watch the charge, see today's pennies land, and switch peak selling off whenever you like: for a party, a heat pump, or no reason at all. No penalty, no phone call.</p>
</article>
</div>
</div>
</section>
<!-- 5 · Stat band on volt, outline word behind -->
<section id="numbers" class="stats" aria-label="The network in numbers">
<div class="stats__word" aria-hidden="true"><span>Share the sun · Share the sun ·</span></div>
<div class="stats__grid">
<div class="stats__left">
<p class="mono stats__kicker">Last month, Harrow Vale</p>
<h2 class="stats__title t-title reveal">Small boxes.<br>Big numbers.</h2>
<p class="stats__body">One battery can't move the grid. A town's worth can hold a substation steady through a heatwave, and did, eleven times this summer.</p>
<figure class="stats__img"><img src="assets/stat-aerial.jpg" alt="Terraced rooftops covered in solar panels, seen from above" width="2400" height="1200"></figure>
</div>
<ul class="stats__list">
<li class="stats__item"><span class="stats__num t-num" data-to="38400" data-fmt="int">38,400</span><span class="stats__cap">homes on the network</span></li>
<li class="stats__item"><span class="stats__num t-num"><span data-to="212" data-fmt="int">212</span><small>MWh</small></span><span class="stats__cap">shared last month</span></li>
<li class="stats__item"><span class="stats__num t-num">£<span data-to="6.1" data-fmt="dec1">6.1</span>M</span><span class="stats__cap">paid to members</span></li>
</ul>
</div>
<div class="stats__members">
<figure class="member">
<img src="assets/member-01.jpg" alt="A member seen from behind, looking up at solar panels on her roof" width="900" height="1100" loading="lazy">
<blockquote><p>"It paid the gas bill in February. I didn't do anything. That's the whole review."</p><footer class="mono">Priya · Orchard Rise</footer></blockquote>
</figure>
<figure class="member">
<img src="assets/member-02.jpg" alt="A member in a workshop apron, cropped at the jaw, holding a mug" width="900" height="1100" loading="lazy">
<blockquote><p>"I was braced for greenwash. Instead I got a spreadsheet every month. I love a spreadsheet."</p><footer class="mono">Tom · Kiln Field</footer></blockquote>
</figure>
</div>
</section>
<!-- 5b · Earnings calculator -->
<section id="calculator" class="calc" aria-label="Earnings calculator">
<div class="calc__head">
<p class="mono calc__kicker">Earnings calculator</p>
<h2 class="calc__title t-title reveal">What would<br>yours earn?</h2>
<p class="calc__intro">Three questions. No email address. The number moves as you do.</p>
</div>
<div class="calc__body">
<form class="calc__controls" action="#" onsubmit="return false">
<div class="calc__field">
<div class="calc__row"><label for="calcRoof">Roof solar</label><output class="calc__out t-num" for="calcRoof" id="outRoof">4.0 kW</output></div>
<input class="range" type="range" id="calcRoof" min="0" max="10" step="0.5" value="4">
<div class="calc__ticks mono"><span>None</span><span>10 kW</span></div>
</div>
<div class="calc__field">
<div class="calc__row"><label for="calcBatt">Battery</label><output class="calc__out t-num" for="calcBatt" id="outBatt">10 kWh</output></div>
<input class="range" type="range" id="calcBatt" min="5" max="20" step="1" value="10">
<div class="calc__ticks mono"><span>5 kWh</span><span>20 kWh</span></div>
</div>
<fieldset class="calc__field calc__region">
<legend>Region</legend>
<div class="seg">
<label><input type="radio" name="region" value="south" checked><span>South</span></label>
<label><input type="radio" name="region" value="midlands"><span>Midlands</span></label>
<label><input type="radio" name="region" value="north"><span>North</span></label>
<label><input type="radio" name="region" value="scotland"><span>Scotland</span></label>
</div>
</fieldset>
</form>
<div class="calc__result" aria-live="polite">
<div class="calc__batt">
<svg class="batt" viewBox="0 0 80 132" aria-hidden="true">
<rect x="26" y="2" width="28" height="9" rx="2" class="batt__cap"/>
<rect x="4" y="11" width="72" height="117" rx="8" class="batt__body"/>
<clipPath id="battClip"><rect x="11" y="18" width="58" height="103" rx="3"/></clipPath>
<g clip-path="url(#battClip)"><rect class="batt__fill" x="11" y="18" width="58" height="103"/></g>
<g class="batt__cells"><line x1="11" y1="43.75" x2="69" y2="43.75"/><line x1="11" y1="69.5" x2="69" y2="69.5"/><line x1="11" y1="95.25" x2="69" y2="95.25"/></g>
</svg>
<p class="mono calc__battlbl"><span class="calc__battv">10</span> kWh stored</p>
</div>
<div class="calc__money">
<p class="mono calc__lbl">Paid to you, per year</p>
<p class="calc__sum t-num"><span class="calc__cur">£</span><span class="calc__digits" aria-hidden="true"></span><span class="sr calc__sr">£704 per year</span><span class="calc__per">/year</span></p>
<div class="calc__split">
<div class="calc__bar"><i class="calc__self"></i><i class="calc__sold"></i></div>
<div class="calc__legend mono"><span><i class="k k--self"></i>Self-use <b class="calc__selfv">32%</b></span><span><i class="k k--sold"></i>Sold <b class="calc__soldv">68%</b></span></div>
</div>
<p class="calc__note">An estimate for a typical home, based on last year's peak prices in your region. Your survey gives you the real figure.</p>
</div>
</div>
<figure class="calc__img"><img src="assets/calc-install.jpg" alt="An installer seen from behind, fitting a battery unit to an outside wall" width="1600" height="1200"></figure>
</div>
</section>
<!-- CTA on cobalt -->
<section id="survey" class="cta" aria-label="Book a survey">
<div class="cta__grid">
<div class="cta__copy">
<p class="mono cta__kicker">Free · 40 minutes · no hard sell</p>
<h2 class="cta__title t-title reveal">Book a<br>survey.</h2>
<p class="cta__body">A local installer looks at your roof, your fuse box and your loft, then tells you straight whether a battery pays for itself. Sometimes the answer is "not yet". We'll say so.</p>
<form class="cta__form" action="#" novalidate>
<label class="sr" for="ctaPost">Your postcode</label>
<input id="ctaPost" class="field" type="text" name="postcode" placeholder="Your postcode" autocomplete="off" maxlength="9">
<button class="btn btn--volt" type="submit"><span class="roll">Book a survey</span></button>
</form>
<p class="mono cta__msg" role="status" aria-live="polite"></p>
</div>
<figure class="cta__img"><img src="assets/cta-street.jpg" alt="A residential street at dusk, warm light in the windows of terraced houses" width="2400" height="1350" loading="lazy"></figure>
</div>
</section>
</main>
<footer id="grids" class="foot">
<div class="foot__cols">
<div class="foot__col">
<p class="mono foot__h">Homeowners</p>
<a class="roll" href="#grid-sees">The idea</a>
<a class="roll" href="#calculator">Earnings</a>
<a class="roll" href="#f-app">The app</a>
<a class="roll" href="#survey">Book a survey</a>
</div>
<div class="foot__col">
<p class="mono foot__h">For grids</p>
<a class="roll" href="#map">Flexibility on demand</a>
<a class="roll" href="#map">Dispatch data</a>
<a class="roll" href="#survey">Partner with us</a>
</div>
<div class="foot__col">
<p class="mono foot__h">Company</p>
<a class="roll" href="#top">About</a>
<a class="roll" href="#top">Careers</a>
<a class="roll" href="#top">Press</a>
</div>
<form class="foot__news" action="#" novalidate>
<p class="mono foot__h">The monthly watt</p>
<p class="foot__newsp">One email a month. Prices, payouts, and the occasional graph we're proud of.</p>
<div class="foot__newsrow">
<label class="sr" for="newsMail">Email address</label>
<input id="newsMail" class="field field--line" type="email" placeholder="[email protected]" autocomplete="off">
<button class="btn btn--volt" type="submit"><span class="roll">Sign up</span></button>
</div>
<p class="mono foot__msg" role="status" aria-live="polite"></p>
</form>
</div>
<div class="foot__mark" aria-hidden="true">
<span class="foot__outline t-title">Commonwatt</span>
<span class="foot__fill t-title">Commonwatt</span>
</div>
<div class="foot__legal mono">
<span>© 2026 Commonwatt Ltd · Harrow Vale</span>
<span>Figures on this page are illustrative</span>
<a href="#top" class="roll">Back to top</a>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/CustomEase.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js" crossorigin="anonymous"></script>
<script src="js/core.js"></script>
<script src="js/meter.js"></script>
<script src="js/hero.js"></script>
<script src="js/sees.js"></script>
<script src="js/map.js"></script>
<script src="js/tour.js"></script>
<script src="js/stats.js"></script>
<script src="js/calc.js"></script>
<script src="js/main.js"></script>
</body>
</html>
```
## `css/style.css`
```css
/* Commonwatt · landing page styles */
@font-face { font-family: 'Instrument Sans'; font-style: normal; font-weight: 400 700; font-stretch: 75% 100%; font-display: swap;
src: url(../assets/instrument-sans-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Instrument Sans'; font-style: normal; font-weight: 400 700; font-stretch: 75% 100%; font-display: swap;
src: url(../assets/instrument-sans-arrows.woff2) format('woff2'); unicode-range: U+2192, U+2197, U+2248; }
@font-face { font-family: 'Hanken Grotesk'; font-style: normal; font-weight: 400 500; font-display: swap;
src: url(../assets/hanken-grotesk-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Hanken Grotesk'; font-style: normal; font-weight: 400 500; font-display: swap;
src: url(../assets/hanken-grotesk-arrows.woff2) format('woff2'); unicode-range: U+2192, U+2197, U+2248; }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap;
src: url(../assets/jetbrains-mono-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap;
src: url(../assets/jetbrains-mono-arrows.woff2) format('woff2'); unicode-range: U+2192, U+2197, U+2248; }
:root {
/* palette: the only four colours on the page */
--volt: #19F59A;
--cobalt: #0038FF;
--white: #FFFFFF;
--black: #000000;
/* transparencies of the tokens */
--ink-04: rgba(0, 0, 0, .04);
--ink-08: rgba(0, 0, 0, .08);
--ink-12: rgba(0, 0, 0, .12);
--ink-22: rgba(0, 0, 0, .22);
--ink-60: rgba(0, 0, 0, .6);
--ink-72: rgba(0, 0, 0, .72);
--paper-10: rgba(255, 255, 255, .1);
--paper-18: rgba(255, 255, 255, .18);
--paper-50: rgba(255, 255, 255, .5);
--paper-72: rgba(255, 255, 255, .72);
--cobalt-08: rgba(0, 56, 255, .08);
--cobalt-16: rgba(0, 56, 255, .16);
--f-title: 'Instrument Sans', 'Arial Narrow', sans-serif;
--f-ui: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
--f-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--ease: cubic-bezier(.25, 1, .5, 1); /* cw */
--ease-io: cubic-bezier(.76, 0, .24, 1); /* cwInOut */
--gut: clamp(16px, 2.2vw, 32px);
--nav-h: 72px;
}
*, *::before, *::after { box-sizing: border-box; }
html { overflow-x: clip; -webkit-text-size-adjust: 100%; background: var(--white); }
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }
body { margin: 0; background: var(--white); color: var(--black); font-family: var(--f-ui); font-weight: 400; font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overflow-x: clip; }
img { display: block; max-width: 100%; color: transparent; }
img.is-broken { visibility: hidden; }
a { color: inherit; text-decoration: none; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; padding: 0; }
p, h1, h2, h3, h4, ol, ul, figure, blockquote { margin: 0; padding: 0; }
ol, ul { list-style: none; }
.sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
:focus-visible { outline: 2px solid var(--cobalt); outline-offset: 3px; }
.t-title { font-family: var(--f-title); font-stretch: 75%; font-weight: 700; text-transform: uppercase; letter-spacing: -0.01em; line-height: .86; }
.t-num { font-family: var(--f-title); font-stretch: 100%; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.mono { font-family: var(--f-mono); font-weight: 400; text-transform: uppercase; letter-spacing: .04em; font-size: 12px; line-height: 1.5; }
/* reveal: blur-in on enter (filter blur 20px, opacity 0 → clear) */
html.motion .reveal { opacity: 0; filter: blur(20px); }
/* ---------- char roll ---------- */
.roll { display: inline-block; }
.roll__in { position: relative; display: inline-block; overflow: hidden; vertical-align: top; line-height: 1.2; padding-bottom: .04em; }
.roll__row { display: block; white-space: pre; }
.roll__row--b { position: absolute; left: 0; top: 0; }
.roll__row--b { visibility: hidden; }
.roll.is-ready .roll__row--b { visibility: visible; }
.roll__c { display: inline-block; will-change: transform; }
/* ---------- buttons, fields ---------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 10px; height: 48px; padding: 0 22px; border-radius: 999px; font-family: var(--f-ui); font-weight: 500; font-size: 15px; letter-spacing: 0; white-space: nowrap; transition: background-color .3s var(--ease), color .3s var(--ease); }
.btn--cobalt { background: var(--cobalt); color: var(--white); }
.btn--cobalt:hover { background: var(--black); }
.btn--volt { background: var(--volt); color: var(--black); }
.btn--volt:hover { background: var(--white); }
.field { height: 48px; border-radius: 999px; border: 1px solid var(--paper-50); background: transparent; padding: 0 20px; color: var(--white); font-size: 15px; min-width: 0; }
.field::placeholder { color: var(--paper-72); }
.field:focus { outline: none; border-color: var(--volt); }
/* ---------- loader ---------- */
.loader { display: none; }
html.has-intro .loader { display: flex; position: fixed; inset: 0; z-index: 100; background: var(--white); align-items: center; justify-content: center; }
.loader__inner { width: min(560px, 80vw); }
.loader__label { color: var(--ink-60); }
.loader__count { font-family: var(--f-title); font-stretch: 100%; font-weight: 600; font-size: clamp(88px, 14vw, 200px); line-height: .9; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; margin: 12px 0 18px; }
.loader__pct { font-size: .4em; margin-left: .06em; vertical-align: .9em; }
.loader__bar { position: relative; height: 12px; border: 1.5px solid var(--black); padding: 2px; }
.loader__bar::after { content: ''; position: absolute; right: -6px; top: 2px; bottom: 2px; width: 3px; background: var(--black); }
.loader__bar i { display: block; height: 100%; width: 100%; background: var(--volt); transform: scaleX(0); transform-origin: 0 50%; }
/* ---------- nav ---------- */
.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 50; pointer-events: none; padding: 12px var(--gut) 0; }
.nav__bar { pointer-events: auto; margin: 0 auto; width: 100%; height: 56px; display: flex; align-items: center; gap: 18px; padding: 0 6px 0 14px; border-radius: 999px; background: rgba(255, 255, 255, 0); border: 1px solid rgba(0, 0, 0, 0); transition: background-color .4s var(--ease), border-color .4s var(--ease); overflow: hidden; }
.nav[data-state="compact"] .nav__bar { background: var(--white); border-color: var(--ink-12); }
.nav__logo { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
.nav__mark { width: 15px; height: 21px; color: var(--black); }
.nav__markfill { fill: var(--volt); }
.nav__word { font-family: var(--f-title); font-stretch: 75%; font-weight: 700; text-transform: uppercase; font-size: 22px; letter-spacing: .01em; line-height: 1; }
.nav__links { display: flex; gap: 26px; margin: 0 auto; font-size: 15px; font-weight: 500; }
.nav__links a, .nav__drop a { padding: 4px 0; }
.meter { display: flex; align-items: center; gap: 9px; height: 42px; padding: 0 14px 0 10px; border-radius: 999px; background: var(--volt); color: var(--black); transition: background-color .6s var(--ease), color .6s var(--ease); flex: 0 0 auto; }
.meter[data-mode="demand"] { background: var(--cobalt); color: var(--white); }
.meter__gauge { width: 34px; height: 21px; overflow: visible; }
.meter__arc, .meter__ticks { stroke: currentColor; }
.meter__ticks { opacity: .55; }
.meter__needle { stroke: currentColor; transform-origin: 20px 21px; }
.meter__hub { fill: currentColor; }
.meter__read { display: flex; flex-direction: column; line-height: 1.05; }
.meter__hz { font-family: var(--f-mono); font-size: 13px; letter-spacing: 0; white-space: nowrap; }
.meter__val { font-weight: 400; font-variant-numeric: tabular-nums; }
.meter__state { font-family: var(--f-mono); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; opacity: .8; white-space: nowrap; margin-top: 2px; }
.nav__menu { display: none; height: 42px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--ink-22); font-size: 14px; font-weight: 500; flex: 0 0 auto; }
.nav__cta { height: 42px; padding: 0 20px; flex: 0 0 auto; }
.nav[data-state="compact"] .nav__links,
.nav[data-state="compact"] .nav__cta { display: none; }
.nav[data-state="compact"] .nav__menu { display: inline-flex; align-items: center; }
.nav[data-state="compact"] .nav__bar { justify-content: space-between; }
.nav__drop { pointer-events: auto; width: 29.45rem; max-width: 100%; margin: 8px auto 0; background: var(--white); border: 1px solid var(--ink-12); border-radius: 22px; padding: 14px 20px; display: grid; gap: 2px; font-size: 18px; font-weight: 500; }
.nav__drop[hidden] { display: none; }
/* ---------- 1 · hero ---------- */
.hero { position: relative; background: var(--white); }
.hero__stage { position: relative; height: 100vh; height: 100svh; min-height: 680px; overflow: hidden; }
.hero__layer { position: absolute; inset: 0; }
.hero__layer--xray { pointer-events: none; background: var(--white); clip-path: circle(80px at var(--x, -200px) var(--y, -200px)); z-index: 2; }
.hero__photo { position: absolute; top: 0; right: 0; bottom: 0; left: 24vw; overflow: hidden; }
.hero__photo img { width: 100%; height: 100%; object-fit: cover; opacity: .2; }
/* top fades to white so nothing in the photo sits behind the nav; bottom fades under the headline */
.hero__photo::after { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, var(--white) 0, var(--white) 11%, rgba(255, 255, 255, 0) 24%, rgba(255, 255, 255, 0) 32%, var(--white) 82%); }
.hero__grid { position: absolute; inset: 0; display: grid; grid-template-columns: 24vw 1px 1fr; }
.hero__meta { grid-column: 1; padding: calc(var(--nav-h) + 12vh) 2.4vw 5.2vh var(--gut); display: flex; flex-direction: column; }
.hero__kicker { color: var(--ink-60); }
.hero__lede { margin-top: 14px; font-size: 16px; line-height: 1.45; max-width: 30ch; font-weight: 400; }
.hero__cta { margin-top: 26px; align-self: flex-start; }
.hero__place { margin-top: auto; font-weight: 500; font-size: 15px; line-height: 1.3; }
.hero__homes { font-weight: 400; color: var(--ink-72); }
.hero__rule { grid-column: 2; background: var(--black); margin-top: calc(var(--nav-h) + 8px); }
.hero__title { grid-column: 3; align-self: end; padding: 0 var(--gut) 4.6vh 2.2vw; font-size: 10.4vw; display: flex; flex-direction: column; }
.hero__block { display: block; will-change: filter, opacity; }
.hero__block--step { padding-left: 15.4vw; }
html.motion .hero__layer--base .hero__block { opacity: 0; filter: blur(20px); }
.xr-only { display: none; }
.hero__layer--xray .xr-only { display: block; }
.hero__layer--xray .hero__photo { display: none; }
.hero__layer--xray .hero__block { color: transparent; -webkit-text-stroke: 1.5px var(--cobalt); }
.hero__layer--xray .hero__rule { background: var(--cobalt); }
.hero__layer--xray .hero__meta { color: var(--cobalt); }
.hero__layer--xray .hero__homes, .hero__layer--xray .hero__kicker { color: var(--cobalt); }
.hero__layer--xray .btn--cobalt { background: transparent; color: var(--cobalt); box-shadow: inset 0 0 0 1.5px var(--cobalt); }
.xr-grid { position: absolute; inset: 0; background-color: var(--ink-04);
background-image: linear-gradient(var(--cobalt-08) 1px, transparent 1px), linear-gradient(90deg, var(--cobalt-08) 1px, transparent 1px);
background-size: 24px 24px; }
.xr-flow { position: absolute; inset: 0; width: 100%; height: 100%; }
.xr-flow__line { fill: none; stroke: var(--cobalt); stroke-width: 2; vector-effect: non-scaling-stroke; stroke-dasharray: 6 9; animation: xrflow 1.4s linear infinite; }
.xr-flow__line--thick { stroke-width: 3; }
.xr-flow__bus { fill: none; stroke: var(--cobalt); stroke-width: 1; vector-effect: non-scaling-stroke; }
@keyframes xrflow { to { stroke-dashoffset: -30; } }
html.rm .xr-flow__line { animation: none; }
.xr-node { position: absolute; transform: translate(-6px, -6px); font-family: var(--f-mono); font-size: 10px; letter-spacing: .04em; text-transform: uppercase; color: var(--cobalt); white-space: nowrap; }
.xr-node i { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--cobalt); background: var(--white); border-radius: 50%; vertical-align: -2px; margin-right: 6px; }
.xr-node--batt i { background: var(--volt); }
.hero__lens { position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; width: 0; height: 0; opacity: 0; will-change: transform; }
.lens__disc { position: absolute; left: -80px; top: -80px; width: 160px; height: 160px; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--cobalt-16); transform: scale(var(--s, 1)); }
.lens__h, .lens__v { position: absolute; background: var(--volt); }
.lens__h { left: -170px; top: -.75px; width: 340px; height: 1.5px; }
.lens__v { top: -170px; left: -.75px; height: 340px; width: 1.5px; }
.lens__note { position: absolute; left: 12px; top: -64px; font-size: 9.5px; line-height: 1.35; color: var(--cobalt); white-space: nowrap; background: var(--white); padding: 2px 5px; box-shadow: inset 0 0 0 1px var(--cobalt); }
.hero__hint { position: absolute; right: var(--gut); top: calc(var(--nav-h) + 20px); color: var(--ink-60); z-index: 4; }
/* ---------- 2 · what the grid sees ---------- */
.sees { position: relative; background: var(--cobalt); color: var(--white); }
.sees__stage { position: relative; height: 100vh; overflow: hidden; }
.sees__kicker { position: absolute; left: 0; right: 0; top: calc(var(--nav-h) + 18px); text-align: center; color: var(--paper-72); z-index: 3; }
.sees__list { position: absolute; left: 0; right: 0; top: calc(var(--nav-h) + 52px); text-align: center; z-index: 3; font-family: var(--f-ui); font-weight: 500; font-size: clamp(20px, 2.35vw, 36px); line-height: 1.22; letter-spacing: -0.015em; }
.sees__item { will-change: transform, opacity; }
.sees__item span { opacity: .5; transition: opacity .4s var(--ease); }
.sees__item.is-on span { opacity: 1; }
.sees__space { position: absolute; inset: 0; perspective: 17.36vw; perspective-origin: 50% 50%; transform-style: preserve-3d; z-index: 2; pointer-events: none; }
.sees__floor { position: absolute; left: 50%; top: 50%; transform-origin: 50% 0; pointer-events: none;
background-image: linear-gradient(var(--paper-18) 1px, transparent 1px), linear-gradient(90deg, var(--paper-10) 1px, transparent 1px);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 45%, #000 80%, transparent 100%); mask-image: linear-gradient(180deg, transparent 0%, #000 45%, #000 80%, transparent 100%); }
html.rm .sees__floor { display: none; }
.sees__card { position: absolute; left: 50%; top: 50%; width: 33.68vw; margin-left: -16.84vw; margin-top: -12.63vw; aspect-ratio: 4 / 3; opacity: 0; visibility: hidden; will-change: transform, opacity; backface-visibility: hidden; }
.sees__card img { width: 100%; height: 100%; object-fit: cover; }
.tag { position: absolute; bottom: 100%; left: 0; font-family: var(--f-mono); text-transform: uppercase; letter-spacing: .04em; font-size: 10px; line-height: 1; padding: 5px 7px; background: var(--volt); color: var(--black); white-space: nowrap; }
.sees__card:nth-child(even) .tag { left: auto; right: 0; }
.sees__outro { position: absolute; left: 50%; bottom: 11vh; width: min(860px, 86vw); transform: translateX(-50%); text-align: center; font-family: var(--f-ui); font-weight: 500; font-size: clamp(26px, 3.3vw, 50px); line-height: 1.12; letter-spacing: -0.02em; z-index: 3; }
.sees__outro .ln { display: block; will-change: transform, opacity; }
html.motion .sees__outro .ln { opacity: 0; }
/* reduced motion: cards as a grid */
html.rm .sees__stage { height: auto; overflow: visible; padding: calc(var(--nav-h) + 40px) var(--gut) 90px; }
html.rm .sees__kicker, html.rm .sees__list, html.rm .sees__outro { position: static; transform: none; width: auto; }
html.rm .sees__list { margin-top: 16px; }
html.rm .sees__item span { opacity: 1; }
html.rm .sees__space { position: static; perspective: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 34px 18px; margin: 56px 0; }
html.rm .sees__card { position: relative; left: auto; top: auto; margin: 0; width: auto; opacity: 1; visibility: visible; }
html.rm .sees__card:nth-child(even) .tag { left: 0; right: auto; }
html.rm .sees__outro { max-width: 860px; margin: 0 auto; }
/* ---------- 3 · map ---------- */
.map { position: relative; background: var(--black); color: var(--white); z-index: 1; }
.map__stage { position: relative; height: 100vh; overflow: hidden; background: var(--black); }
.map__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.map__head { position: absolute; left: var(--gut); top: calc(var(--nav-h) + 22px); z-index: 2; pointer-events: none; }
.map__kicker { color: var(--paper-72); }
.map__titles { position: relative; margin-top: 10px; height: 7.4vw; min-height: 64px; }
.map__title { position: absolute; left: 0; top: 0; font-size: max(64px, 8.4vw); white-space: nowrap; opacity: 0; }
.map__title.is-on { opacity: 1; }
.map__foot { position: absolute; left: var(--gut); bottom: 30px; z-index: 2; width: min(420px, 36vw); pointer-events: none; }
.map__caps { position: relative; min-height: 5.6em; }
.map__cap { position: absolute; left: 0; bottom: 0; font-size: 17px; line-height: 1.4; opacity: 0; }
.map__cap.is-on { opacity: 1; }
.map__steps { display: flex; gap: 4px; margin-top: 18px; font-size: 10px; }
.map__steps li { flex: 1; padding-top: 8px; border-top: 2px solid var(--paper-18); color: var(--paper-50); transition: border-color .4s var(--ease), color .4s var(--ease); }
.map__steps li.is-on { border-color: var(--volt); color: var(--white); }
.map__steps li.is-done { border-color: var(--paper-72); }
.map__hud { position: absolute; right: var(--gut); bottom: 22px; z-index: 2; height: 96px; overflow: hidden; text-align: right; font-size: 12px; pointer-events: none; }
.map__log { will-change: transform; }
.map__log p { height: 24px; line-height: 24px; white-space: nowrap; opacity: 0; transition: opacity .45s var(--ease); }
.map__log p b { font-weight: 400; color: var(--volt); }
.map__log p em { font-style: normal; color: var(--white); background: var(--cobalt); padding: 1px 4px; }
/* ---------- 4 · feature tour ---------- */
.tour { position: relative; z-index: 3; background: var(--white); border-radius: 28px 28px 0 0; margin-top: -100vh; padding-bottom: 10vh; }
html.rm .tour { margin-top: -28px; }
/* rail starts below the fixed nav pill (12 px offset + 56 px bar + 24 px) */
.tour__rail { overflow: hidden; padding: 92px 0 30px; border-bottom: 1px solid var(--ink-12); }
.tour__track { display: flex; gap: 46px; width: max-content; padding-left: var(--gut); will-change: transform; font-family: var(--f-title); font-stretch: 75%; font-weight: 700; text-transform: uppercase; font-size: 30px; line-height: 1; color: var(--ink-22); }
.tour__track span { display: inline-flex; align-items: baseline; gap: 10px; white-space: nowrap; }
.tour__track b { font-family: var(--f-mono); font-weight: 400; font-size: 12px; color: var(--black); background: var(--volt); padding: 3px 6px; letter-spacing: .04em; transform: translateY(-4px); }
.tour__grid { display: grid; grid-template-columns: 44% 1fr; padding: 0 var(--gut); }
.tour__side { position: sticky; top: 0; height: 100vh; padding-top: calc(var(--nav-h) + 60px); align-self: start; display: flex; flex-direction: column; }
.tour__kicker { color: var(--ink-60); }
.tour__titles { margin-top: 22px; font-size: 5.2vw; line-height: .9; }
.tour__titles li { color: var(--ink-22); transition: color .3s var(--ease); }
.tour__titles li.is-on { color: var(--black); }
.tour__titles a { display: inline-block; }
.tour__aside { margin-top: auto; margin-bottom: 9vh; max-width: 34ch; font-size: 15px; color: var(--ink-72); }
.tour__panels { padding-top: 7vh; }
.tour__panel { padding: 7vh 0 11vh; min-height: 100vh; }
.tour__inline { display: none; }
.tour__frame { position: relative; width: 100%; aspect-ratio: 670 / 560; overflow: hidden; border-radius: 16px; background: var(--cobalt); }
.tour__img { position: absolute; left: 0; top: -12%; width: 100%; height: 124%; object-fit: cover; will-change: transform; }
.tour__cap { margin-top: 30px; font-family: var(--f-ui); font-weight: 500; font-size: 28px; line-height: 1.15; letter-spacing: -0.015em; }
.tour__body { margin-top: 14px; max-width: 54ch; font-size: 16px; line-height: 1.55; color: var(--ink-72); }
.ui { position: absolute; left: 9%; right: 9%; top: 50%; transform: translateY(-50%); background: var(--white); border-radius: 12px; padding: 20px 22px; color: var(--black); }
.ui__head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.ui__head .mono { font-size: 11px; }
.ui__chip { padding: 3px 7px; background: var(--black); color: var(--white); font-size: 10px !important; }
.ui__chip--cobalt { background: var(--cobalt); }
.ui__foot { margin-top: 12px; font-size: 11px; color: var(--ink-60); }
.ui__foot b { color: var(--black); font-weight: 400; background: var(--volt); padding: 1px 4px; }
.ui-health__bars { position: relative; display: flex; align-items: flex-end; gap: 6px; height: 170px; margin-top: 22px; }
.ui-health__bars i { position: relative; flex: 1; height: calc((var(--h) - 60%) * 2.5); background: var(--cobalt); transform-origin: 50% 100%; }
.ui-health__bars i.is-hi { background: var(--volt); }
.ui-health__bars b { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 6px; font-family: var(--f-mono); font-weight: 400; font-size: 9px; color: var(--ink-60); }
.ui-health__floor { position: absolute; left: -4px; right: -4px; bottom: 25%; height: 0; border-top: 1.5px dashed var(--black); pointer-events: none; }
.ui-health__floor span { position: absolute; right: 0; top: -20px; font-size: 9px; color: var(--black); background: var(--white); padding: 1px 4px; }
.ui-health .ui__foot { margin-top: 34px; }
.ui-earn__total { font-size: 34px; line-height: 1; }
.ui-earn__chart { display: flex; align-items: flex-end; gap: 7px; height: 190px; margin-top: 18px; border-bottom: 1px solid var(--ink-22); }
.ui-earn__chart i { position: relative; flex: 1; height: var(--h); background: var(--black); transform-origin: 50% 100%; }
.ui-earn__chart i.is-hi { background: var(--volt); box-shadow: inset 0 0 0 1px var(--black); }
.ui-earn__chart i.is-future { height: 4px; background: var(--ink-12); }
.ui-earn__chart em { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 6px; font-style: normal; font-size: 10px; background: var(--black); color: var(--white); padding: 2px 5px; }
.ui-earn__months { display: flex; gap: 7px; margin-top: 8px; font-size: 10px; color: var(--ink-60); }
.ui-earn__months span { flex: 1; text-align: center; }
.ui-outage { padding-right: 104px; }
.ui-outage__track { margin-top: 22px; display: grid; gap: 14px; }
.ui-outage__lane { position: relative; height: 34px; display: flex; align-items: center; }
.ui-outage__lane > .mono { position: absolute; left: 0; top: -15px; font-size: 9px; color: var(--ink-60); }
.ui-outage__grid, .ui-outage__home { position: absolute; left: 0; right: 0; top: 6px; bottom: 6px; }
.ui-outage__grid { background: var(--ink-22); }
.ui-outage__home { background: var(--volt); transform-origin: 0 50%; }
.ui-outage__down { position: absolute; left: 34%; width: 44%; top: 0; bottom: 0; background: var(--black); transform-origin: 0 50%; }
.ui-outage__down em { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); font-style: normal; font-size: 9px; color: var(--white); white-space: nowrap; }
.ui-outage__axis { display: flex; justify-content: space-between; margin-top: 12px; font-size: 9px; color: var(--ink-60); }
.ui-outage__house { position: absolute; right: 22px; top: 50%; width: 64px; height: 56px; transform: translateY(-40%); color: var(--black); }
.ui-outage__house .win { fill: var(--volt); }
.phone { position: absolute; left: 50%; top: 50%; width: 38%; aspect-ratio: 9 / 18.5; transform: translate(-50%, -50%); background: var(--black); border-radius: 30px; padding: 8px; }
.phone__screen { position: relative; height: 100%; border-radius: 23px; background: var(--white); padding: 14px 14px 12px; display: flex; flex-direction: column; align-items: center; overflow: hidden; }
.phone__top { align-self: stretch; display: flex; justify-content: space-between; font-size: 9px; }
.phone__ring { width: 68%; margin-top: 12%; transform: rotate(-90deg); }
.phone__ringbg { fill: none; stroke: var(--ink-08); stroke-width: 10; }
.phone__ringfg { fill: none; stroke: var(--volt); stroke-width: 10; stroke-dasharray: 78 100; stroke-linecap: butt; }
.phone__pct { position: absolute; top: 22%; left: 0; right: 0; text-align: center; font-size: clamp(22px, 2.5vw, 36px); line-height: 1; margin-top: 8%; }
.phone__pct small { font-size: .5em; }
.phone__lbl { margin-top: 8px; font-size: 8.5px; color: var(--ink-60); text-align: center; }
.phone__today { align-self: stretch; margin-top: auto; display: flex; justify-content: space-between; align-items: baseline; border-top: 1px solid var(--ink-12); padding-top: 9px; }
.phone__today .mono { font-size: 8.5px; color: var(--ink-60); }
.phone__earn { font-size: clamp(16px, 1.6vw, 22px); }
.phone__toggle { align-self: stretch; margin-top: 10px; display: flex; align-items: center; gap: 9px; height: 34px; border-radius: 999px; background: var(--black); color: var(--white); padding: 4px; font-size: 12px; font-weight: 500; transition: background-color .3s var(--ease); }
.phone__knob { width: 26px; height: 26px; border-radius: 50%; background: var(--volt); transition: transform .35s var(--ease-io), background-color .3s; }
.phone__toggle[aria-checked="false"] { background: var(--ink-22); color: var(--black); }
.phone__toggle[aria-checked="false"] .phone__knob { background: var(--white); }
.phone__foot { margin-top: 8px; font-size: 8px; color: var(--ink-60); text-align: center; }
/* ---------- 5 · stats on volt ---------- */
.stats { position: relative; background: var(--volt); color: var(--black); overflow: hidden; padding: 16vh 0 12vh; }
.stats__word { position: absolute; left: 0; top: 7vh; white-space: nowrap; font-family: var(--f-title); font-stretch: 75%; font-weight: 700; text-transform: uppercase; font-size: 24vw; line-height: .8; letter-spacing: -0.01em; color: transparent; -webkit-text-stroke: 1px rgba(0, 0, 0, .12); pointer-events: none; will-change: transform; }
.stats__grid { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 4vw; padding: 0 var(--gut); }
.stats__kicker { color: var(--ink-72); }
.stats__title { margin-top: 16px; font-size: 7.4vw; }
.stats__body { margin-top: 26px; max-width: 40ch; font-size: 17px; line-height: 1.5; }
.stats__img { margin-top: 44px; aspect-ratio: 2 / 1; overflow: hidden; }
.stats__img img { width: 100%; height: 100%; object-fit: cover; }
.stats__list { padding-top: 6vh; }
.stats__item { display: block; padding: 22px 0 26px; border-bottom: 1px solid var(--black); }
.stats__item:first-child { border-top: 1px solid var(--black); }
.stats__num { display: block; font-size: 9.3vw; line-height: .92; }
.stats__num small { font-size: .3em; margin-left: .12em; letter-spacing: 0; }
.stats__cap { display: block; margin-top: 12px; font-size: 16px; font-weight: 500; }
.stats__members { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 4vw; padding: 13vh var(--gut) 0; }
.member { display: grid; grid-template-columns: 38% 1fr; gap: 26px; align-items: end; }
.member img { width: 100%; height: auto; aspect-ratio: 9 / 11; object-fit: cover; }
.member blockquote p { font-family: var(--f-ui); font-weight: 500; font-size: clamp(20px, 1.85vw, 28px); line-height: 1.2; letter-spacing: -0.015em; }
.member footer { margin-top: 18px; }
/* ---------- 5b · calculator ---------- */
.calc { position: relative; background: var(--white); padding: 16vh var(--gut) 14vh; }
.calc__kicker { color: var(--ink-60); }
.calc__title { margin-top: 14px; font-size: 7.4vw; }
.calc__intro { margin-top: 20px; font-size: 17px; color: var(--ink-72); }
.calc__body { margin-top: 8vh; display: grid; grid-template-columns: 1.05fr 1.2fr .9fr; gap: 3vw; align-items: stretch; }
.calc__controls { display: flex; flex-direction: column; gap: 42px; padding-top: 8px; }
.calc__field { border: 0; margin: 0; padding: 0; min-width: 0; }
.calc__row { display: flex; justify-content: space-between; align-items: baseline; }
.calc__row label, .calc__region legend { font-size: 16px; font-weight: 500; }
.calc__out { font-size: 30px; line-height: 1; }
.calc__ticks { display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px; color: var(--ink-60); }
.range { -webkit-appearance: none; appearance: none; width: 100%; height: 30px; margin: 12px 0 0; background: transparent; cursor: pointer; --p: 40%; }
.range::-webkit-slider-runnable-track { height: 8px; border-radius: 99px; background: linear-gradient(90deg, var(--volt) 0 var(--p), var(--ink-08) var(--p) 100%); box-shadow: inset 0 0 0 1px var(--ink-22); }
.range::-moz-range-track { height: 8px; border-radius: 99px; background: linear-gradient(90deg, var(--volt) 0 var(--p), var(--ink-08) var(--p) 100%); box-shadow: inset 0 0 0 1px var(--ink-22); }
.range::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; margin-top: -9px; border-radius: 50%; background: var(--black); border: 5px solid var(--white); box-shadow: 0 0 0 1px var(--black); }
.range::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--black); border: 5px solid var(--white); box-shadow: 0 0 0 1px var(--black); }
.calc__region legend { margin-bottom: 14px; padding: 0; }
.seg { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--black); border-radius: 999px; padding: 3px; }
.seg label { position: relative; cursor: pointer; }
.seg input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.seg span { display: flex; align-items: center; justify-content: center; height: 40px; border-radius: 999px; font-size: 14px; font-weight: 500; transition: background-color .3s var(--ease), color .3s var(--ease); }
.seg input:checked + span { background: var(--black); color: var(--white); }
.seg input:focus-visible + span { outline: 2px solid var(--cobalt); outline-offset: 1px; }
.calc__result { background: var(--black); color: var(--white); border-radius: 18px; padding: 30px; display: grid; grid-template-columns: 96px 1fr; gap: 28px; align-items: start; }
.calc__batt { text-align: center; }
.batt { width: 80px; height: 132px; }
.batt__cap { fill: var(--white); }
.batt__body { fill: none; stroke: var(--white); stroke-width: 3; }
.batt__fill { fill: var(--volt); transform-box: fill-box; transform-origin: 50% 100%; }
.batt__cells line { stroke: var(--black); stroke-width: 3; }
.calc__battlbl { margin-top: 10px; font-size: 10px; color: var(--paper-72); }
.calc__lbl { color: var(--paper-72); font-size: 11px; }
.calc__sum { position: relative; display: flex; align-items: flex-start; margin-top: 10px; font-size: clamp(56px, 6.4vw, 96px); line-height: 1; }
.calc__cur { font-size: .56em; margin-top: .08em; margin-right: .04em; }
.calc__per { font-family: var(--f-mono); font-size: 13px; font-weight: 400; letter-spacing: .04em; align-self: flex-end; margin: 0 0 .5em .4em; color: var(--paper-72); }
.calc__digits { display: inline-flex; height: 1em; overflow: hidden; }
.cw-d { position: relative; display: inline-block; height: 1em; overflow: hidden; }
.cw-d__strip { display: flex; flex-direction: column; transition: transform .9s var(--ease); }
.cw-d__strip span { display: block; height: 1em; line-height: 1; }
.cw-d--sep { width: .26em; }
.calc__split { margin-top: 26px; }
.calc__bar { display: flex; height: 14px; gap: 2px; }
.calc__bar i { display: block; height: 100%; transition: flex-grow .9s var(--ease); }
.calc__self { background: var(--white); flex: 32 1 0; }
.calc__sold { background: var(--volt); flex: 68 1 0; }
.calc__legend { display: flex; justify-content: space-between; margin-top: 10px; font-size: 10px; }
.calc__legend b { font-weight: 400; }
.k { display: inline-block; width: 8px; height: 8px; margin-right: 6px; }
.k--self { background: var(--white); }
.k--sold { background: var(--volt); }
.calc__note { margin-top: 22px; font-size: 13px; line-height: 1.45; color: var(--paper-72); }
.calc__img { overflow: hidden; border-radius: 18px; min-height: 320px; }
.calc__img img { width: 100%; height: 100%; object-fit: cover; }
/* ---------- CTA + footer on cobalt ---------- */
.cta { background: var(--cobalt); color: var(--white); padding: 15vh var(--gut) 12vh; }
.cta__grid { display: grid; grid-template-columns: 1fr 1.25fr; gap: 4vw; align-items: end; }
.cta__kicker { color: var(--paper-72); }
.cta__title { margin-top: 16px; font-size: 11.5vw; }
.cta__body { margin-top: 28px; max-width: 44ch; font-size: 17px; line-height: 1.5; }
.cta__form { margin-top: 34px; display: flex; gap: 10px; max-width: 460px; }
.cta__form .field { flex: 1; }
.cta__msg, .foot__msg { margin-top: 12px; min-height: 18px; color: var(--volt); }
.cta__img { aspect-ratio: 16 / 10; overflow: hidden; }
.cta__img img { width: 100%; height: 100%; object-fit: cover; }
.foot { position: relative; background: var(--cobalt); color: var(--white); padding: 9vh var(--gut) 28px; border-top: 1px solid var(--paper-18); overflow: hidden; }
.foot__cols { display: grid; grid-template-columns: repeat(3, 1fr) 1.6fr; gap: 3vw; }
.foot__col { display: flex; flex-direction: column; gap: 6px; font-size: 16px; font-weight: 500; }
.foot__col a { align-self: flex-start; }
.foot__h { color: var(--paper-72); margin-bottom: 10px; }
.foot__newsp { font-size: 15px; max-width: 40ch; color: var(--paper-72); }
.foot__newsrow { display: flex; gap: 10px; margin-top: 16px; }
.foot__newsrow .field { flex: 1; }
.foot__mark { position: relative; margin-top: 12vh; line-height: .8; font-size: calc((100vw - 2 * var(--gut)) / 5.3); }
.foot__outline, .foot__fill { display: block; white-space: nowrap; }
.foot__outline { color: transparent; -webkit-text-stroke: 1.5px var(--white); }
.foot__fill { position: absolute; left: 0; top: 0; color: var(--volt); clip-path: inset(100% 0 0 0); }
.foot__legal { display: flex; justify-content: space-between; gap: 20px; margin-top: 30px; font-size: 11px; color: var(--paper-72); }
/* ---------- responsive ---------- */
@media (max-width: 1180px) {
.nav__links { gap: 18px; font-size: 14px; }
.calc__body { grid-template-columns: 1fr 1.2fr; }
.calc__img { display: none; }
}
@media (max-width: 991px) {
.nav__links { display: none; }
.nav__menu { display: inline-flex; align-items: center; margin-left: auto; }
.nav__cta { display: none; }
.hero__grid { grid-template-columns: 30vw 1px 1fr; }
.hero__photo { left: 30vw; }
.hero__title { font-size: 11vw; }
.hero__block--step { padding-left: 8vw; }
.tour__grid { grid-template-columns: 40% 1fr; }
.map__foot { width: 46vw; }
.stats__grid, .stats__members { grid-template-columns: 1fr; }
.stats__num { font-size: 13vw; }
.member { grid-template-columns: 30% 1fr; }
.calc__body { grid-template-columns: 1fr; }
.calc__result { max-width: 640px; }
.cta__grid { grid-template-columns: 1fr; }
.foot__cols { grid-template-columns: repeat(3, 1fr); }
.foot__news { grid-column: 1 / -1; }
}
@media (max-width: 767px) {
:root { --nav-h: 64px; }
.nav { padding: 10px 12px 0; }
.nav__bar { height: 52px; gap: 8px; padding: 0 5px 0 12px; background: var(--white); border-color: var(--ink-12); justify-content: space-between; }
.nav__word { font-size: 19px; }
.nav__menu { margin-left: 0; height: 40px; padding: 0 14px; }
.meter { height: 40px; padding: 0 11px 0 8px; gap: 7px; }
.meter__gauge { width: 28px; height: 18px; }
.meter__hz { font-size: 12px; }
.nav__drop { width: auto; }
.hero__stage { min-height: 660px; }
.hero__grid { grid-template-columns: 34vw 1px 1fr; grid-template-rows: 1fr auto auto auto; }
.hero__photo { left: 34vw; bottom: auto; height: 58%; }
.hero__meta { display: contents; }
.hero__kicker { grid-column: 1; grid-row: 1; align-self: start; padding: calc(var(--nav-h) + 26px) 12px 0 var(--gut); font-size: 10px; }
.hero__place { grid-column: 1; grid-row: 1; align-self: end; margin: 0; padding: 0 12px 16px var(--gut); font-size: 13px; }
.hero__rule { grid-row: 1; }
.hero__title { grid-column: 1 / -1; grid-row: 2; font-size: 15.2vw; padding: 14px var(--gut) 0; }
.hero__block--step { padding-left: 34vw; }
.hero__lede { grid-column: 1 / -1; grid-row: 3; margin: 0; padding: 18px var(--gut) 0; font-size: 15px; max-width: none; }
.hero__cta { grid-column: 1 / -1; grid-row: 4; justify-self: start; margin: 16px 0 5vh var(--gut); height: 46px; padding: 0 18px; font-size: 14px; }
.hero__hint { display: none; }
.sees__list { font-size: 19px; line-height: 1.25; padding: 0 16px; top: calc(var(--nav-h) + 44px); }
.sees__kicker { top: calc(var(--nav-h) + 14px); font-size: 10px; }
.sees__space { top: 24%; }
.sees__card { width: 50vw; margin-left: -25vw; margin-top: -18.75vw; }
.sees__outro { font-size: 25px; bottom: 9vh; }
html.rm .sees__space { grid-template-columns: 1fr 1fr; }
.map__stage { display: flex; flex-direction: column; }
.map__head { position: relative; left: auto; top: auto; padding: calc(var(--nav-h) + 18px) var(--gut) 0; }
.map__titles { height: 15vw; min-height: 0; }
.map__title { font-size: 16vw; }
.map__canvas { position: absolute; top: auto; bottom: 0; height: 70vh; }
.map__foot { position: relative; left: auto; bottom: auto; width: auto; padding: 10px var(--gut) 0; }
.map__caps { min-height: 4.4em; }
.map__cap { font-size: 15px; top: 0; bottom: auto; }
.map__steps { margin-top: 12px; font-size: 8.5px; }
.map__hud { height: 72px; font-size: 10px; bottom: 14px; }
.map__log p { height: 24px; line-height: 24px; }
.tour { border-radius: 22px 22px 0 0; }
.tour__rail { padding: 86px 0 20px; }
.tour__track { font-size: 22px; gap: 30px; }
.tour__grid { grid-template-columns: 1fr; }
.tour__side { position: relative; height: auto; padding-top: 48px; }
.tour__titles { display: none; }
.tour__aside { margin: 16px 0 0; }
.tour__panels { padding-top: 10px; }
.tour__panel { min-height: 0; padding: 44px 0 20px; }
.tour__inline { display: block; font-size: 13vw; margin-bottom: 18px; }
.tour__frame { aspect-ratio: 4 / 4.2; border-radius: 14px; }
.ui { left: 5%; right: 5%; padding: 16px; }
.ui-health__bars { height: 130px; gap: 4px; }
.ui-earn__chart { height: 150px; gap: 4px; }
.ui-earn__months { gap: 4px; }
.ui-outage { padding-right: 16px; padding-bottom: 76px; }
.ui-outage__house { right: 50%; top: auto; bottom: 12px; transform: translateX(50%); width: 52px; height: 46px; }
.phone { width: 58%; }
.tour__cap { font-size: 23px; margin-top: 22px; }
.tour__body { font-size: 15px; }
.stats { padding: 12vh 0 10vh; }
.stats__word { font-size: 38vw; top: 4vh; }
.stats__title { font-size: 15vw; }
.stats__num { font-size: 16vw; }
.stats__list { padding-top: 0; }
.member { grid-template-columns: 40% 1fr; gap: 16px; }
.stats__members { gap: 44px; padding-top: 9vh; }
.calc { padding: 12vh var(--gut) 10vh; }
.calc__title { font-size: 15vw; }
.calc__result { grid-template-columns: 1fr; padding: 24px; gap: 18px; }
.calc__batt { display: flex; align-items: center; gap: 16px; text-align: left; }
.batt { width: 44px; height: 72px; }
.calc__battlbl { margin-top: 0; }
.seg span { font-size: 13px; }
.cta { padding: 12vh var(--gut) 10vh; }
.cta__title { font-size: 21vw; }
.cta__form { flex-direction: column; }
.foot__cols { grid-template-columns: 1fr 1fr; gap: 34px 20px; }
.foot__newsrow { flex-direction: column; }
.foot__legal { flex-direction: column; gap: 6px; }
}
```
## `js/calc.js`
```js
/* 5b · Signature 2 · earnings calculator.
Model (illustrative): yearly solar = kW × regional yield; the home uses up to 1,300 kWh of it
directly; the battery shifts up to 250 cycles × kWh of the rest into the evening peak (£0.24/kWh),
anything left is exported at £0.06; unused battery cycles trade off-peak → peak (£0.14 spread);
plus £9 per kWh per year for being on call. Regional price factor on the total.
Digits roll on vertical strips (0.9 s, brand ease, 40 ms stagger left → right). */
(function () {
'use strict';
var CW = window.CW;
var YIELD = { south: 1010, midlands: 930, north: 860, scotland: 820 };
var MULT = { south: 1, midlands: 0.97, north: 0.95, scotland: 0.93 };
function compute(roof, batt, region) {
var gen = roof * YIELD[region];
var self = Math.min(gen, 1300), excess = gen - self;
var cap = batt * 250, stored = Math.min(excess, cap);
var e = (stored * 0.24 + (excess - stored) * 0.06 + (cap - stored) * 0.14 + batt * 9) * MULT[region];
return { pounds: Math.round(e / 5) * 5, self: gen ? self / gen : 0 };
}
CW.calc = function () {
var sec = document.querySelector('.calc'); if (!sec) return;
var roof = sec.querySelector('#calcRoof'), batt = sec.querySelector('#calcBatt');
var outRoof = sec.querySelector('#outRoof'), outBatt = sec.querySelector('#outBatt');
var digitsEl = sec.querySelector('.calc__digits'), srEl = sec.querySelector('.calc__sr');
var selfBar = sec.querySelector('.calc__self'), soldBar = sec.querySelector('.calc__sold');
var selfV = sec.querySelector('.calc__selfv'), soldV = sec.querySelector('.calc__soldv');
var fill = sec.querySelector('.batt__fill'), battV = sec.querySelector('.calc__battv');
/* slots: d , d d d (up to £9,999) */
var slots = [];
function makeDigit() {
var d = document.createElement('span'); d.className = 'cw-d';
var s = document.createElement('span'); s.className = 'cw-d__strip';
for (var i = 0; i < 10; i++) { var n = document.createElement('span'); n.textContent = i; s.appendChild(n); }
d.appendChild(s); digitsEl.appendChild(d); return { el: d, strip: s, v: 0 };
}
var d0 = makeDigit();
var comma = document.createElement('span'); comma.className = 'cw-d cw-d--sep'; comma.textContent = ','; digitsEl.appendChild(comma);
slots = [d0, makeDigit(), makeDigit(), makeDigit()];
slots.forEach(function (s, i) { s.strip.style.transitionDelay = (i * 0.04) + 's'; });
if (CW.reduced) slots.forEach(function (s) { s.strip.style.transition = 'none'; });
function setMoney(v) {
v = Math.max(0, Math.min(9999, v));
var str = ('0000' + v).slice(-4), big = v >= 1000;
d0.el.style.display = big ? '' : 'none'; comma.style.display = big ? '' : 'none';
for (var i = 0; i < 4; i++) { var n = +str[i]; slots[i].strip.style.transform = 'translateY(' + (-n * 10) + '%)'; }
srEl.textContent = '£' + v.toLocaleString('en-GB') + ' per year';
}
function fillBattery(kwh, animate) {
var s = kwh / 20;
battV.textContent = kwh;
if (animate && !CW.reduced) {
gsap.to(fill, { scaleY: s, duration: 0.8, ease: 'cw', overwrite: true });
} else { gsap.set(fill, { scaleY: s }); }
}
function pct(input) { var p = (input.value - input.min) / (input.max - input.min) * 100; input.style.setProperty('--p', p + '%'); }
function region() { var r = sec.querySelector('input[name="region"]:checked'); return r ? r.value : 'south'; }
var lastBatt = -1;
function update(animate) {
var r = +roof.value, b = +batt.value, res = compute(r, b, region());
outRoof.textContent = r === 0 ? 'None' : r.toFixed(1) + ' kW';
outBatt.textContent = b + ' kWh';
pct(roof); pct(batt);
setMoney(res.pounds);
var sp = Math.round(res.self * 100);
selfBar.style.flexGrow = Math.max(0.001, sp); soldBar.style.flexGrow = Math.max(0.001, 100 - sp);
selfV.textContent = sp + '%'; soldV.textContent = (100 - sp) + '%';
if (b !== lastBatt) { fillBattery(b, animate); lastBatt = b; }
}
[roof, batt].forEach(function (i) { i.addEventListener('input', function () { update(true); }); });
sec.querySelectorAll('input[name="region"]').forEach(function (i) { i.addEventListener('change', function () { update(true); }); });
/* first view: count up from zero */
if (CW.reduced) { update(false); return; }
setMoney(0); fillBattery(0, false); pct(roof); pct(batt);
ScrollTrigger.create({ trigger: sec.querySelector('.calc__result'), start: 'top 80%', once: true, onEnter: function () { update(true); } });
};
})();
```
## `js/core.js`
```js
/* Commonwatt · core: environment flags, eases, smooth scroll, shared helpers. Plain script, no modules. */
(function () {
'use strict';
var CW = window.CW = window.CW || {};
var root = document.documentElement;
CW.static = root.classList.contains('is-static');
CW.reduced = root.classList.contains('rm');
CW.intro = root.classList.contains('has-intro');
CW.touch = window.matchMedia ? matchMedia('(hover: none), (pointer: coarse)').matches : false;
CW.mobile = function () { return window.innerWidth < 768; };
/* colour tokens, read from :root so JS/canvas never drifts from CSS */
var cs = getComputedStyle(root);
function tok(n, fb) { var v = cs.getPropertyValue(n).trim(); return v || fb; }
CW.col = { volt: tok('--volt', '#19F59A'), cobalt: tok('--cobalt', '#0038FF'), white: tok('--white', '#FFFFFF'), black: tok('--black', '#000000') };
CW.rgba = function (hex, a) {
var h = hex.replace('#', ''); var n = parseInt(h, 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
};
if (!window.gsap) { CW.noGsap = true; return; }
gsap.registerPlugin(ScrollTrigger, CustomEase);
/* brand eases (measured beziers) */
CustomEase.create('cw', 'M0,0 C0.25,1 0.5,1 1,1'); // "Out"
CustomEase.create('cwInOut', 'M0,0 C0.76,0 0.24,1 1,1'); // "InOut"
CustomEase.create('cwIn', 'M0,0 C0.5,0 0.75,0 1,1'); // "In"
CW.ease = gsap.parseEase('cw');
CW.easeIO = gsap.parseEase('cwInOut');
/* smooth scroll, wired to ScrollTrigger */
CW.lenis = null;
if (!CW.reduced && window.Lenis) {
CW.lenis = new Lenis({ lerp: 0.1, wheelMultiplier: 1, smoothWheel: true });
CW.lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add(function (t) { CW.lenis.raf(t * 1000); });
gsap.ticker.lagSmoothing(0);
if (CW.intro) CW.lenis.stop();
}
CW.scrollTo = function (target) {
if (CW.lenis) CW.lenis.scrollTo(target, { duration: 1.4, easing: function (t) { return CW.easeIO(t); } });
else { var el = typeof target === 'string' ? document.querySelector(target) : target; if (el && el.scrollIntoView) el.scrollIntoView(); }
};
/* in-page links go through Lenis */
document.addEventListener('click', function (e) {
var a = e.target.closest && e.target.closest('a[href^="#"]');
if (!a) return;
var id = a.getAttribute('href'); if (id.length < 2) return;
var el = document.querySelector(id); if (!el) return;
e.preventDefault();
CW.scrollTo(el);
if (history.replaceState) history.replaceState(null, '', id);
});
/* char roll: chars yPercent 0 → -100 out, 100 → 0 in; 0.4 s, stagger amount 0.075, cwInOut */
CW.roll = function (el) {
if (el.__roll) return;
var text = el.textContent.replace(/\s+/g, ' ').trim();
function row(cls) {
var r = document.createElement('span'); r.className = 'roll__row ' + cls; r.setAttribute('aria-hidden', 'true');
for (var i = 0; i < text.length; i++) { var c = document.createElement('span'); c.className = 'roll__c'; c.textContent = text[i]; r.appendChild(c); }
return r;
}
var box = document.createElement('span'); box.className = 'roll__in';
var a = row('roll__row--a'), b = row('roll__row--b');
box.appendChild(a); box.appendChild(b);
var sr = document.createElement('span'); sr.className = 'sr'; sr.textContent = text;
el.textContent = ''; el.appendChild(box); el.appendChild(sr);
el.__roll = true;
if (CW.reduced) return;
var ca = a.children, cb = b.children;
gsap.set(cb, { yPercent: 100 });
el.classList.add('is-ready');
var tl = gsap.timeline({ paused: true });
tl.to(ca, { yPercent: -100, duration: 0.4, stagger: { amount: 0.075 }, ease: 'cwInOut' }, 0)
.fromTo(cb, { yPercent: 100 }, { yPercent: 0, duration: 0.4, stagger: { amount: 0.075 }, ease: 'cwInOut', immediateRender: false }, 0);
var host = el.closest('a, button') || el;
host.addEventListener('mouseenter', function () { tl.play(); });
host.addEventListener('mouseleave', function () { tl.reverse(); });
host.addEventListener('focus', function () { tl.play(); });
host.addEventListener('blur', function () { tl.reverse(); });
};
/* reveal: filter blur(20px), opacity 0 → clear; 1.2 s cw; trigger start 'top bottom', once */
CW.reveals = function () {
var els = document.querySelectorAll('.reveal');
if (CW.reduced) return;
els.forEach(function (el) {
ScrollTrigger.create({
trigger: el, start: 'top bottom', once: true,
onEnter: function () { gsap.to(el, { filter: 'blur(0px)', opacity: 1, duration: 1.2, ease: 'cw', onComplete: function () { el.style.filter = 'none'; } }); }
});
});
};
/* images: retry once, then hide (never show alt text) */
CW.images = function () {
document.querySelectorAll('img').forEach(function (img) {
function fail() {
if (!img.__retried) { img.__retried = true; var s = img.getAttribute('src'); img.src = s + (s.indexOf('?') < 0 ? '?r=1' : '&r=1'); }
else img.classList.add('is-broken');
}
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) fail();
img.addEventListener('error', fail);
});
};
/* pause helper: run fn while element is on screen */
CW.onScreen = function (el, cb, margin) {
if (!('IntersectionObserver' in window)) { cb(true); return; }
new IntersectionObserver(function (es) { es.forEach(function (e) { cb(e.isIntersecting); }); }, { rootMargin: margin || '0px' }).observe(el);
};
/* simulated clock shared by the meter, hero lens note and map: 1 sim hour = 3 s, starts 13:00 */
CW.sim = { hour: 13, mode: 'surplus' };
CW.simTime = function (h) {
var hh = Math.floor(h) % 24, mm = Math.floor((h % 1) * 60);
return (hh < 10 ? '0' : '') + hh + ':' + (mm < 10 ? '0' : '') + mm;
};
})();
```
## `js/hero.js`
```js
/* 1 · X-ray hero.
Blur-in: each heading block starts at filter blur(20px) / opacity 0, then an SVG feGaussianBlur
runs stdDeviation 50 → 0 in 1.2 s, ease cw (0.25,1,0.5,1), delays 0 / 0.1 / 0.2 s; opacity
rises over the first 0.42 s.
Lens (estimated from the reference still): 160 px disc (radius 80), light grey fill, crosshair
running ±90 px past the rim (340 px lines), follows the pointer with lerp 0.14 per 60 fps frame.
The x-ray layer is a clone of the base layout, clipped by circle(80px at var(--x) var(--y)).
Touch: the lens drifts on a Lissajous path over the headline; a tap sends it there for 3.5 s. */
(function () {
'use strict';
var CW = window.CW;
var isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(navigator.userAgent);
CW.hero = function () {
var stage = document.querySelector('.hero__stage'); if (!stage) return;
var base = stage.querySelector('.hero__layer--base');
var lens = stage.querySelector('.hero__lens');
var title = base.querySelector('.hero__title');
/* x-ray layer: an identical copy of the layout, restyled by CSS */
var xr = base.cloneNode(true);
xr.className = 'hero__layer hero__layer--xray';
xr.setAttribute('aria-hidden', 'true');
xr.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
xr.querySelectorAll('a, button').forEach(function (n) { n.setAttribute('tabindex', '-1'); });
xr.querySelectorAll('.hero__photo').forEach(function (n) { n.parentNode.removeChild(n); });
xr.querySelectorAll('h1').forEach(function (h) {
var d = document.createElement('div'); d.className = h.className; d.innerHTML = h.innerHTML; h.parentNode.replaceChild(d, h);
});
stage.insertBefore(xr, lens);
var R = 80, x = 0, y = 0, tx = 0, ty = 0, r = 0;
var mode = 'drift', tapUntil = 0, running = true, shown = false, t = 0;
var rect = stage.getBoundingClientRect();
function tRect() {
var a = title.getBoundingClientRect(), s = stage.getBoundingClientRect();
return { x: a.left - s.left, y: a.top - s.top, w: a.width, h: a.height };
}
var tr = tRect();
function home() { return { x: tr.x + tr.w * 0.46, y: tr.y + tr.h * 0.5 }; }
var h0 = home(); x = tx = h0.x; y = ty = h0.y;
function apply() {
stage.style.setProperty('--x', x.toFixed(1) + 'px');
stage.style.setProperty('--y', y.toFixed(1) + 'px');
xr.style.clipPath = 'circle(' + r.toFixed(1) + 'px at ' + x.toFixed(1) + 'px ' + y.toFixed(1) + 'px)';
lens.style.transform = 'translate3d(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px,0)';
}
apply();
window.addEventListener('resize', function () { rect = stage.getBoundingClientRect(); tr = tRect(); });
stage.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
rect = stage.getBoundingClientRect();
mode = 'follow'; tx = e.clientX - rect.left; ty = e.clientY - rect.top;
});
stage.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') mode = 'drift'; });
stage.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
rect = stage.getBoundingClientRect();
mode = 'tap'; tx = e.clientX - rect.left; ty = e.clientY - rect.top; tapUntil = performance.now() + 3500;
});
CW.onScreen(stage, function (v) { running = v; });
var prev = performance.now();
function tick(now) {
var dt = Math.min(0.05, (now - prev) / 1000); prev = now;
if (running && shown) {
if (mode === 'tap' && now > tapUntil) mode = 'drift';
if (mode === 'drift' && !CW.reduced) {
t += dt;
tx = tr.x + tr.w * (0.5 + 0.36 * Math.sin(t * 0.37));
ty = tr.y + tr.h * (0.5 + 0.34 * Math.sin(t * 0.61 + 0.9));
}
var a = 1 - Math.pow(1 - 0.14, dt * 60);
x += (tx - x) * a; y += (ty - y) * a;
apply();
}
requestAnimationFrame(tick);
}
if (!CW.static) requestAnimationFrame(tick);
/* intro: blur-in the three blocks, fade the meta column, then open the lens */
CW.heroIntro = function (delay) {
delay = delay || 0;
var blocks = base.querySelectorAll('.hero__block');
var meta = base.querySelectorAll('.hero__kicker, .hero__lede, .hero__cta, .hero__place, .hero__rule');
if (CW.reduced) {
gsap.set(blocks, { opacity: 1, filter: 'none' });
openLens(0);
return;
}
blocks.forEach(function (b, i) {
var d = delay + i * 0.1;
if (isSafari) {
gsap.fromTo(b, { filter: 'blur(20px)', opacity: 0 }, { filter: 'blur(0px)', opacity: 1, duration: 1.2, delay: d, ease: 'cw', clearProps: 'filter' });
return;
}
var fe = document.querySelector('#cwBlur' + i + ' feGaussianBlur');
fe.setAttribute('stdDeviation', '50');
b.style.filter = 'url(#cwBlur' + i + ')';
gsap.to(b, { opacity: 1, duration: 0.42, delay: d, ease: 'none' });
gsap.to(fe, { attr: { stdDeviation: 0 }, duration: 1.2, delay: d, ease: 'cw', onComplete: function () { b.style.filter = 'none'; } });
});
gsap.from(meta, { opacity: 0, y: 14, duration: 0.9, delay: delay + 0.25, stagger: 0.06, ease: 'cw', clearProps: 'transform,opacity' });
openLens(delay + 1.15);
};
function openLens(d) {
var o = { r: 0 };
shown = true;
if (CW.static || CW.reduced) { r = R; gsap.set(lens, { opacity: 1 }); apply(); return; }
gsap.to(lens, { opacity: 1, duration: 0.5, delay: d, ease: 'none' });
gsap.fromTo(o, { r: 0 }, { r: R, duration: 0.9, delay: d, ease: 'cw', onUpdate: function () { r = o.r; lens.style.setProperty('--s', (o.r / R).toFixed(3)); if (!running) apply(); } });
}
};
})();
```
## `js/main.js`
```js
/* Commonwatt · boot: loader, nav, footer fill, forms, then every section. */
(function () {
'use strict';
var CW = window.CW;
if (!CW || CW.noGsap) { document.documentElement.classList.remove('motion', 'has-intro'); document.documentElement.classList.add('rm'); return; }
/* sticky name bar: width 100% → 29.45rem in 0.8 s cwInOut; contents opacity 0.1 → 1 in 0.4 s at −=0.4 */
function nav() {
var navEl = document.querySelector('.nav'), bar = navEl.querySelector('.nav__bar');
var links = navEl.querySelector('.nav__links'), cta = navEl.querySelector('.nav__cta');
var menu = navEl.querySelector('.nav__menu'), drop = navEl.querySelector('.nav__drop');
var inner = [navEl.querySelector('.nav__logo'), navEl.querySelector('.meter'), menu];
var compact = false, tl = null;
var CW_REM = 29.45 * parseFloat(getComputedStyle(document.documentElement).fontSize || 16);
function closeDrop() { drop.hidden = true; menu.setAttribute('aria-expanded', 'false'); }
menu.addEventListener('click', function () {
var open = drop.hidden; drop.hidden = !open; menu.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open && !CW.reduced) gsap.fromTo(drop, { opacity: 0, y: -8 }, { opacity: 1, y: 0, duration: 0.4, ease: 'cw' });
});
drop.addEventListener('click', function (e) { if (e.target.closest('a')) closeDrop(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeDrop(); });
function set(c) {
if (c === compact) return; compact = c;
if (window.innerWidth < 768) { navEl.setAttribute('data-state', c ? 'compact' : 'full'); return; }
if (tl) tl.kill();
if (CW.reduced) { navEl.setAttribute('data-state', c ? 'compact' : 'full'); bar.style.width = c ? CW_REM + 'px' : ''; closeDrop(); return; }
var from = bar.offsetWidth;
tl = gsap.timeline();
if (c) {
tl.to([links, cta], { opacity: 0, duration: 0.2, ease: 'none' })
.call(function () { navEl.setAttribute('data-state', 'compact'); })
.fromTo(bar, { width: from }, { width: CW_REM, duration: 0.8, ease: 'cwInOut' })
.fromTo(inner, { opacity: 0.1 }, { opacity: 1, duration: 0.4, ease: 'none' }, '-=0.4');
} else {
closeDrop();
var full = navEl.clientWidth - parseFloat(getComputedStyle(navEl).paddingLeft) * 2;
tl.fromTo(inner, { opacity: 1 }, { opacity: 0.1, duration: 0.15, ease: 'none' })
.fromTo(bar, { width: from }, { width: full, duration: 0.8, ease: 'cwInOut' })
.call(function () { navEl.setAttribute('data-state', 'full'); bar.style.width = ''; })
.fromTo([links, cta], { opacity: 0 }, { opacity: 1, duration: 0.3, ease: 'none', clearProps: 'opacity' })
.fromTo(inner, { opacity: 0.1 }, { opacity: 1, duration: 0.4, ease: 'none', clearProps: 'opacity' }, '-=0.4');
}
}
ScrollTrigger.create({ start: 140, end: 'max', onEnter: function () { set(true); }, onLeaveBack: function () { set(false); } });
if (window.scrollY > 140) set(true);
window.addEventListener('resize', function () { if (compact && window.innerWidth >= 768) bar.style.width = CW_REM + 'px'; if (window.innerWidth < 768) bar.style.width = ''; });
}
/* footer word fills with volt from the bottom as the page ends (scrub) */
function footer() {
var fill = document.querySelector('.foot__fill'); if (!fill) return;
if (CW.reduced) { fill.style.clipPath = 'inset(0% 0 0 0)'; return; }
gsap.fromTo(fill, { clipPath: 'inset(100% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', ease: 'none',
scrollTrigger: { trigger: '.foot__mark', start: 'top bottom', end: 'bottom bottom', scrub: true } });
}
/* forms stay local: nothing is sent anywhere */
function forms() {
var cta = document.querySelector('.cta__form'), ctaMsg = document.querySelector('.cta__msg');
cta.addEventListener('submit', function (e) {
e.preventDefault();
var v = cta.postcode.value.trim().toUpperCase();
if (!/^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/.test(v)) { ctaMsg.textContent = 'That doesn\'t look like a UK postcode. Try again?'; return; }
ctaMsg.textContent = 'Noted: ' + v + '. An installer near you will call within two working days.';
cta.reset();
});
var news = document.querySelector('.foot__news'), newsMsg = document.querySelector('.foot__msg'), mail = document.querySelector('#newsMail');
news.addEventListener('submit', function (e) {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail.value.trim())) { newsMsg.textContent = 'We need a working email address for that.'; return; }
newsMsg.textContent = 'You\'re on the list. The first issue lands on the 1st.';
news.reset();
});
}
/* intro: counter 0 → 100 over 2 s cwInOut, then the white sheet lifts */
function intro(done) {
if (!CW.intro) { done(0.1); return; }
var loader = document.querySelector('.loader'), num = loader.querySelector('.loader__num'), bar = loader.querySelector('.loader__bar i');
var o = { value: 0 };
var tl = gsap.timeline();
tl.to(o, { value: 100, duration: 2, ease: 'cwInOut', onUpdate: function () { num.textContent = Math.round(o.value); } }, 0)
.to(bar, { scaleX: 1, duration: 2, ease: 'cwInOut' }, 0)
.to(loader, { yPercent: -100, duration: 0.8, ease: 'cwInOut', onComplete: function () { loader.style.display = 'none'; if (CW.lenis) CW.lenis.start(); } }, 2.15)
.call(function () { done(0); }, null, 2.45);
}
function boot() {
CW.images();
document.querySelectorAll('.roll').forEach(CW.roll);
CW.meter();
CW.hero();
nav();
forms();
CW.sees();
CW.map();
CW.tour();
CW.stats();
CW.calc();
footer();
CW.reveals();
var fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
var once = false;
function late() {
if (once) return; once = true;
if (CW.seesSplit) CW.seesSplit();
ScrollTrigger.refresh();
}
fontsReady.then(late); setTimeout(late, 1500);
intro(function (d) { CW.heroIntro(d); });
window.addEventListener('load', function () { ScrollTrigger.refresh(); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
```
## `js/map.js`
```js
/* 3 · Harrow Vale grid map, five chapters on a pinned stage.
Measured: the HUD log column steps translateY −96 px per chapter (4 lines × 24 px line box);
visible-line opacity ramp newest → oldest 1, 1, 0.75, 0.5, then 0. Chapters are pinned, one
viewport of scroll each (5 × 100vh), plus 100vh held while the next sheet slides over.
Estimated from frames (read in order, 300 px apart):
- hotspots pulse: ring scale 1 → 1.6 while opacity 0.8 → 0, on a 1.6 s loop
- craft/comets travel a quadratic curve in 1.2 s, trailing a line (~22% of the path)
- zone outlines are a 1 px stroke that draws on over ~1.2 s; a second outline 5 px lower at
~28% gives each plate its thickness; plates carry a soft ~4% inner fill
- the ground grid is a fine line lattice with brighter dots on its intersections, viewed
at a tilt (≈ 36° here) that the camera eases between per chapter (1.2 s, cwInOut) */
(function () {
'use strict';
var CW = window.CW;
function rng(seed) { return function () { seed |= 0; seed = seed + 0x6D2B79F5 | 0; var t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function inPoly(x, y, pts) { var c = false; for (var i = 0, j = pts.length - 1; i < pts.length; j = i++) { var a = pts[i], b = pts[j]; if (((a[1] > y) !== (b[1] > y)) && (x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0])) c = !c; } return c; }
var ZONES = [
{ id: '01', name: 'Mill End', c: [-520, -180], r: 150, s: 1.7 },
{ id: '02', name: 'Orchard Rise', c: [-160, -350], r: 140, s: 4.1 },
{ id: '03', name: 'The Crofts', c: [170, -60], r: 200, s: 2.6 },
{ id: '04', name: 'Station Row', c: [-380, 185], r: 150, s: 5.3 },
{ id: '05', name: 'Kiln Field', c: [520, -330], r: 130, s: 0.8 },
{ id: '06', name: 'Weir Side', c: [470, 215], r: 140, s: 3.4 }
];
var GRID_NODE = [880, 20];
var CAMS = [
{ x: 0, y: -20, zoom: 1.0, tilt: 0.62 },
{ x: 170, y: -60, zoom: 1.32, tilt: 0.70 },
{ x: 40, y: -40, zoom: 1.06, tilt: 0.64 },
{ x: 250, y: -20, zoom: 0.96, tilt: 0.6 },
{ x: 0, y: -30, zoom: 0.94, tilt: 0.78 }
];
var LOG = [
['17:41:52 SYNC <b>38,400</b> UNITS ONLINE', '17:41:55 ROOF OUTPUT <b>61.4 MW</b>', '17:41:58 FLEET CHARGE 74%', '17:42:01 ZONES 6/6 REPORTING'],
['17:42:04 FORECAST PEAK 18:00', '17:42:08 <em>ZONE-03</em> DEMAND +18%', '17:42:09 HV-14 LOAD 96%', '17:42:10 RESERVE CALL QUEUED'],
['17:42:11 DISPATCH <b>212</b> UNITS', '17:42:13 POOL ZONES 01 02 04 05 06', '17:42:15 FLOW TO <em>ZONE-03</em> <b>3.8 MW</b>', '17:42:17 FLEET CHARGE 69%'],
['17:42:20 GRID FREQ 49.97 HZ', '17:42:22 EXPORT <b>4.1 MW</b> AT PEAK', '17:42:24 PEAK SHAVED <b>4.1 MW</b>', '17:42:25 HV-14 LOAD 81%'],
['17:42:31 SETTLEMENT RUN', '17:42:33 PEAK RATE <b>£0.31/KWH</b>', '17:42:35 PAID <b>38,400</b> MEMBERS', '17:42:36 NEXT PAYOUT 01 OCT']
];
var EXPORT = [0.34, 0.18, 0.78, 0.96, 1];
var BLOB = [0, 1, 0.78, 0.28, 0];
CW.map = function () {
var sec = document.querySelector('.map'); if (!sec) return;
var stage = sec.querySelector('.map__stage');
var canvas = sec.querySelector('.map__canvas');
var ctx = canvas.getContext && canvas.getContext('2d');
var titles = [].slice.call(sec.querySelectorAll('.map__title'));
var caps = [].slice.call(sec.querySelectorAll('.map__cap'));
var steps = [].slice.call(sec.querySelectorAll('.map__steps li'));
var logEl = sec.querySelector('.map__log');
var C = CW.col, rgba = CW.rgba;
/* HUD log lines */
var logLines = [];
LOG.forEach(function (grp) { grp.forEach(function (h) { var p = document.createElement('p'); p.innerHTML = h; logEl.appendChild(p); logLines.push(p); }); });
var shown = 0;
function visibleCount() { return CW.mobile() ? 3 : 4; }
function paintLog(animate) {
var V = visibleCount(), LH = 24;
var y = (V - shown) * LH;
if (animate && !CW.reduced) gsap.to(logEl, { y: y, duration: 0.4, ease: 'cw', overwrite: true });
else gsap.set(logEl, { y: y });
var ramp = V === 4 ? [1, 1, 0.75, 0.5] : [1, 0.75, 0.5];
logLines.forEach(function (p, i) {
var d = shown - 1 - i;
p.style.opacity = (i < shown && d < ramp.length) ? ramp[d] : 0;
});
}
var logQueue = null;
function pushLog(k, animate) {
var target = (k + 1) * 4;
if (logQueue) { logQueue.kill(); logQueue = null; }
if (!animate || target < shown) { shown = target; paintLog(false); return; }
var tl = gsap.timeline(); logQueue = tl;
for (var n = shown + 1; n <= target; n++) (function (n, i) { tl.call(function () { shown = n; paintLog(true); }, null, i * 0.16); })(n, n - shown - 1);
}
if (!ctx) { chapterDOM(4, false); pushLog(4, false); return; }
/* ---------- world ---------- */
var R = rng(20260927);
ZONES.forEach(function (z) {
var n = 46, pts = [];
for (var i = 0; i < n; i++) {
var a = i / n * Math.PI * 2, s = z.s;
var rr = z.r * (1 + 0.14 * Math.sin(3 * a + s) + 0.07 * Math.sin(5 * a + 2 * s) + 0.04 * Math.sin(11 * a + 3 * s));
if (Math.sin(2 * a + s * 2) > 0.72) rr = z.r * 0.92; // flat, street-cut edges
pts.push([z.c[0] + rr * Math.cos(a), z.c[1] + rr * Math.sin(a)]);
}
z.pts = pts;
var top = pts[0]; pts.forEach(function (p) { if (p[1] < top[1]) top = p; }); z.top = top;
z.hub = [z.c[0] + z.r * 0.12, z.c[1] + z.r * 0.05];
z.phase = R() * 1.6;
});
var homes = [];
ZONES.forEach(function (z, zi) {
var count = zi === 2 ? 28 : 18, tries = 0, got = 0;
while (got < count && tries < 4000) {
tries++;
var x = z.c[0] + (R() * 2 - 1) * z.r * 1.1, y = z.c[1] + (R() * 2 - 1) * z.r * 1.1;
if (!inPoly(x, y, z.pts)) continue;
var ok = true; for (var h = 0; h < homes.length; h++) { var dx = homes[h].x - x, dy = homes[h].y - y; if (dx * dx + dy * dy < 22 * 22) { ok = false; break; } }
if (!ok) continue;
homes.push({ x: x, y: y, z: zi, q: R(), lit: 0, mix: 0, ph: R() * 6.28 }); got++;
}
});
homes = homes.slice(0, 120);
var roads = [[0, 3], [0, 1], [1, 2], [2, 4], [2, 5], [3, 2], [5, 6]];
/* ---------- state ---------- */
var cam = { x: CAMS[0].x, y: CAMS[0].y, zoom: CAMS[0].zoom, tilt: CAMS[0].tilt };
var st = { draw: 0, blob: 0, exp: EXPORT[0], tag: 0, pay: 0, lit: 0 };
var chapter = -1, comets = [], ticks = [], spawnAcc = 0, payAcc = 0;
var W = 0, H = 0, DPR = 1, U = 1, cx = 0, cy = 0, D = 1500;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
W = canvas.clientWidth; H = canvas.clientHeight;
canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
var m = CW.mobile();
U = m ? Math.min(W / 1180, H / 900) : Math.min(W / 1980, H / 1180);
cx = W * (m ? 0.5 : 0.6); cy = H * (m ? 0.46 : 0.6);
if (!running) draw(performance.now());
}
function P(wx, wy) {
var x = (wx - cam.x) * cam.zoom, y = (wy - cam.y) * cam.zoom;
var ct = Math.cos(cam.tilt), s = Math.sin(cam.tilt);
var depth = Math.max(200, D - y * s), k = D / depth;
return [cx + x * k * U, cy + y * ct * k * U, k];
}
function quad(a, c, b, u) { var v = 1 - u; return [v * v * a[0] + 2 * v * u * c[0] + u * u * b[0], v * v * a[1] + 2 * v * u * c[1] + u * u * b[1]]; }
function ctrl(a, b, lift) { var mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2, dx = b[0] - a[0], dy = b[1] - a[1]; return [mx - dy * lift, my + dx * lift]; }
function spawnComet(a, b, now) { comets.push({ a: a, b: b, c: ctrl(a, b, 0.32 * (R() > 0.5 ? 1 : -1)), t0: now, dur: 1200 }); }
/* ---------- draw ---------- */
function draw(now) {
var t = now / 1000;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = C.black; ctx.fillRect(0, 0, W, H);
/* ground grid */
var STEP = 60, GX = 1560, GY = 1140;
ctx.lineWidth = 1; ctx.strokeStyle = rgba(C.white, 0.055);
ctx.beginPath();
for (var gx = -GX; gx <= GX; gx += STEP) { var a = P(gx, -GY), b = P(gx, GY); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); }
for (var gy = -GY; gy <= GY; gy += STEP) { var c = P(-GX, gy), d = P(GX, gy); ctx.moveTo(c[0], c[1]); ctx.lineTo(d[0], d[1]); }
ctx.stroke();
ctx.fillStyle = rgba(C.white, 0.2);
for (gx = -GX; gx <= GX; gx += STEP) for (gy = -GY; gy <= GY; gy += STEP) {
var p = P(gx, gy); if (p[0] < -4 || p[0] > W + 4 || p[1] < -4 || p[1] > H + 4) continue;
var sz = 1.6 * p[2]; ctx.fillRect(p[0] - sz / 2, p[1] - sz / 2, sz, sz);
}
/* vignette */
var vg = ctx.createRadialGradient(cx, cy, Math.min(W, H) * 0.25, cx, cy, Math.max(W, H) * 0.72);
vg.addColorStop(0, rgba(C.black, 0)); vg.addColorStop(1, rgba(C.black, 0.92));
ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
/* roads */
ctx.strokeStyle = rgba(C.white, 0.12 * st.draw); ctx.lineWidth = 1; ctx.setLineDash([2, 5]);
ctx.beginPath();
roads.forEach(function (r) {
var A = r[0] === 6 ? GRID_NODE : ZONES[r[0]].hub, B = r[1] === 6 ? GRID_NODE : ZONES[r[1]].hub;
var q0 = P(A[0], A[1]), q1 = P(B[0], B[1]); ctx.moveTo(q0[0], q0[1]); ctx.lineTo(q1[0], q1[1]);
});
ctx.stroke(); ctx.setLineDash([]);
/* demand blob over zone 3 */
if (st.blob > 0.01) {
var z3 = ZONES[2], bc = P(z3.c[0], z3.c[1]);
var br = z3.r * 1.35 * st.blob * cam.zoom * U * bc[2] * (1 + 0.04 * Math.sin(t * 2.2));
ctx.globalCompositeOperation = 'lighter';
var bg = ctx.createRadialGradient(bc[0], bc[1], 0, bc[0], bc[1], Math.max(1, br));
bg.addColorStop(0, rgba(C.cobalt, 0.75 * Math.min(1, st.blob * 1.2))); bg.addColorStop(0.55, rgba(C.cobalt, 0.28 * st.blob)); bg.addColorStop(1, rgba(C.cobalt, 0));
ctx.fillStyle = bg; ctx.beginPath(); ctx.ellipse(bc[0], bc[1], br, br * Math.cos(cam.tilt), 0, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
/* zone plates */
ZONES.forEach(function (z, zi) {
var pr = z.pts.map(function (q) { return P(q[0], q[1]); });
var n = pr.length, lim = st.draw * n;
ctx.beginPath(); pr.forEach(function (q, i) { if (i) ctx.lineTo(q[0], q[1]); else ctx.moveTo(q[0], q[1]); }); ctx.closePath();
ctx.fillStyle = zi === 2 && st.blob > 0.05 ? rgba(C.cobalt, 0.1 + 0.08 * st.blob) : rgba(C.white, 0.04 * st.draw);
ctx.fill();
function outline(off, alpha, w) {
ctx.beginPath();
for (var i = 0; i <= Math.min(n, Math.ceil(lim)); i++) {
var a = pr[i % n], f = Math.min(1, lim - (i - 1));
if (i === 0) { ctx.moveTo(a[0], a[1] + off); continue; }
var b0 = pr[(i - 1) % n];
if (f < 1) { ctx.lineTo(b0[0] + (a[0] - b0[0]) * f, b0[1] + off + (a[1] - b0[1]) * f); break; }
ctx.lineTo(a[0], a[1] + off);
}
ctx.strokeStyle = alpha; ctx.lineWidth = w; ctx.stroke();
}
outline(5 * pr[0][2], rgba(C.white, 0.28), 1);
outline(0, zi === 2 && st.blob > 0.3 ? rgba(C.white, 0.95) : rgba(C.white, 0.82), 1);
});
/* hotspots: ring scale 1 → 1.6, opacity 0.8 → 0, 1.6 s loop */
ZONES.forEach(function (z, zi) {
var hp = P(z.hub[0], z.hub[1]);
var u = ((t + z.phase) % 1.6) / 1.6;
var col = st.pay > 0.5 ? C.volt : (zi === 2 && st.blob > 0.3 ? C.cobalt : C.white);
var r0 = 12 * hp[2] * cam.zoom;
ctx.strokeStyle = CW.rgba(col, 0.8 * (1 - u) * st.draw); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.ellipse(hp[0], hp[1], r0 * (1 + 0.6 * u), r0 * (1 + 0.6 * u) * Math.cos(cam.tilt), 0, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = CW.rgba(col, 0.9 * st.draw);
ctx.beginPath(); ctx.arc(hp[0], hp[1], 2.6 * hp[2], 0, Math.PI * 2); ctx.fill();
});
/* grid supply point */
var gp = P(GRID_NODE[0], GRID_NODE[1]);
ctx.strokeStyle = rgba(C.white, 0.7 * st.draw); ctx.lineWidth = 1;
var gs = 9 * gp[2]; ctx.strokeRect(gp[0] - gs / 2, gp[1] - gs / 2, gs, gs);
if (st.tag > 0.01) {
var u2 = (t % 1.6) / 1.6, rr0 = 16 * gp[2];
ctx.strokeStyle = rgba(C.volt, 0.8 * (1 - u2) * st.tag);
ctx.beginPath(); ctx.ellipse(gp[0], gp[1], rr0 * (1 + 0.6 * u2), rr0 * (1 + 0.6 * u2) * Math.cos(cam.tilt), 0, 0, Math.PI * 2); ctx.stroke();
}
/* comets: quadratic curve, 1.2 s, trailing line */
ctx.lineCap = 'round';
for (var ci = comets.length - 1; ci >= 0; ci--) {
var cm = comets[ci], u3 = (now - cm.t0) / cm.dur;
if (u3 >= 1.25) { comets.splice(ci, 1); continue; }
var head = Math.min(1, u3), tail = Math.max(0, u3 - 0.22), SEG = 9;
var prevP = null;
for (var sI = 0; sI <= SEG; sI++) {
var uu = tail + (head - tail) * sI / SEG, wp = quad(cm.a, cm.c, cm.b, uu), sp = P(wp[0], wp[1]);
if (prevP) { ctx.strokeStyle = rgba(C.volt, 0.85 * sI / SEG * (u3 > 1 ? 1 - (u3 - 1) / 0.25 : 1)); ctx.lineWidth = 1.6 * sp[2]; ctx.beginPath(); ctx.moveTo(prevP[0], prevP[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); }
prevP = sp;
}
if (u3 < 1) {
ctx.fillStyle = rgba(C.volt, 0.25); ctx.beginPath(); ctx.arc(prevP[0], prevP[1], 6 * prevP[2], 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = C.volt; ctx.beginPath(); ctx.arc(prevP[0], prevP[1], 2.2 * prevP[2], 0, Math.PI * 2); ctx.fill();
}
}
/* homes: volt exporting, cobalt charging */
homes.forEach(function (h) {
if (h.lit < 0.01) return;
var hp = P(h.x, h.y), rr = 2.5 * hp[2] * Math.sqrt(cam.zoom);
var colr = h.mix > 0.5 ? C.volt : C.cobalt;
var glow = 0.22 + 0.08 * Math.sin(t * 2 + h.ph);
ctx.fillStyle = rgba(colr, glow * h.lit); ctx.beginPath(); ctx.arc(hp[0], hp[1], rr * 2.6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = rgba(colr, h.lit); ctx.beginPath(); ctx.arc(hp[0], hp[1], rr, 0, Math.PI * 2); ctx.fill();
});
/* £ ticks float up from homes */
ctx.font = '11px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (var ti = ticks.length - 1; ti >= 0; ti--) {
var tk = ticks[ti], u4 = (now - tk.t0) / 1400;
if (u4 >= 1) { ticks.splice(ti, 1); continue; }
var tp = P(tk.x, tk.y), e = 1 - Math.pow(1 - u4, 3);
ctx.fillStyle = rgba(C.volt, u4 < 0.15 ? u4 / 0.15 : 1 - (u4 - 0.15) / 0.85);
ctx.fillText(tk.txt, tp[0], tp[1] - 8 - 30 * e);
}
/* tags */
ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'left';
ZONES.forEach(function (z, zi) {
var tp = P(z.top[0] - z.r * 0.25, z.top[1]);
var hot = zi === 2 && st.blob > 0.3;
var label = 'Z-' + z.id + ' ' + z.name.toUpperCase() + (hot ? ' DEMAND +18%' : '');
tag(tp[0], tp[1] - 14, label, hot ? C.cobalt : rgba(C.white, 0.1), hot ? C.white : rgba(C.white, 0.92), hot ? null : rgba(C.white, 0.35), st.draw);
});
if (st.tag > 0.01) {
ctx.strokeStyle = rgba(C.volt, st.tag); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(gp[0], gp[1] - 8); ctx.lineTo(gp[0], gp[1] - 70); ctx.stroke();
tag(gp[0] - 6, gp[1] - 84, 'PEAK SHAVED 4.1 MW', C.volt, C.black, null, st.tag);
}
var gl = P(GRID_NODE[0], GRID_NODE[1] + 34);
ctx.textAlign = 'center'; ctx.fillStyle = rgba(C.white, 0.6 * st.draw); ctx.fillText('GRID SUPPLY HV-14', gl[0], gl[1]); ctx.textAlign = 'left';
}
function tag(x, y, text, bg, fg, border, alpha) {
if (alpha < 0.01) return;
ctx.globalAlpha = alpha;
var w = ctx.measureText(text).width + 14, h = 18;
x = Math.round(x); y = Math.round(y);
ctx.fillStyle = bg; ctx.fillRect(x, y - h / 2, w, h);
if (border) { ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y - h / 2 + 0.5, w - 1, h - 1); }
ctx.fillStyle = fg; ctx.textBaseline = 'middle'; ctx.fillText(text, x + 7, y + 0.5);
ctx.globalAlpha = 1;
}
/* ---------- simulation step ---------- */
var prev = performance.now();
function step(now) {
var dt = Math.min(0.05, (now - prev) / 1000); prev = now;
var k = Math.max(0, chapter);
homes.forEach(function (h, i) {
var target = k === 2 && h.z === 2 ? 0 : (h.q < st.exp ? 1 : 0);
h.mix += (target - h.mix) * Math.min(1, dt * 3);
var litT = st.lit * homes.length > i ? 1 : 0;
h.lit += (litT - h.lit) * Math.min(1, dt * 6);
});
spawnAcc += dt; payAcc += dt;
var z3 = ZONES[2];
if (k === 2 && spawnAcc > 0.09) {
spawnAcc = 0;
var src = homes[Math.floor(R() * homes.length)];
if (src.z !== 2) spawnComet([src.x, src.y], [z3.hub[0] + (R() - 0.5) * 80, z3.hub[1] + (R() - 0.5) * 60], now);
} else if (k === 3 && spawnAcc > 0.13) {
spawnAcc = 0;
var s2 = R() < 0.6 ? [z3.hub[0] + (R() - 0.5) * 90, z3.hub[1] + (R() - 0.5) * 70] : (function () { var h = homes[Math.floor(R() * homes.length)]; return [h.x, h.y]; })();
spawnComet(s2, [GRID_NODE[0], GRID_NODE[1]], now);
} else if (k === 0 && spawnAcc > 0.9 && st.lit > 0.9) {
spawnAcc = 0;
var za = ZONES[Math.floor(R() * 6)], zb = ZONES[Math.floor(R() * 6)];
if (za !== zb) spawnComet(za.hub, zb.hub, now);
}
if (k === 4 && payAcc > 0.12) {
payAcc = 0;
var hh = homes[Math.floor(R() * homes.length)];
ticks.push({ x: hh.x, y: hh.y, t0: now, txt: R() < 0.7 ? '+£' : '+£' + (1 + Math.floor(R() * 4)) });
}
if (comets.length > 80) comets.splice(0, comets.length - 80);
}
/* ---------- chapters ---------- */
function chapterDOM(k, animate) {
titles.forEach(function (el, i) {
if (i === k) {
el.classList.add('is-on');
if (animate && !CW.reduced) gsap.fromTo(el, { opacity: 0, y: 18, filter: 'blur(20px)' }, { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.9, ease: 'cw', overwrite: true, clearProps: 'filter' });
else gsap.set(el, { opacity: 1, y: 0 });
} else if (el.classList.contains('is-on')) {
el.classList.remove('is-on');
if (animate && !CW.reduced) gsap.to(el, { opacity: 0, y: -18, duration: 0.45, ease: 'cw', overwrite: true });
else gsap.set(el, { opacity: 0 });
}
});
caps.forEach(function (el, i) {
var on = i === k; el.classList.toggle('is-on', on);
if (animate && !CW.reduced) gsap.to(el, { opacity: on ? 1 : 0, y: on ? 0 : -8, duration: on ? 0.7 : 0.35, delay: on ? 0.15 : 0, ease: 'cw', overwrite: true });
else gsap.set(el, { opacity: on ? 1 : 0, y: 0 });
});
steps.forEach(function (el, i) { el.classList.toggle('is-on', i === k); el.classList.toggle('is-done', i < k); });
}
function go(k, animate) {
if (k === chapter) return;
chapter = k;
chapterDOM(k, animate);
pushLog(k, animate);
var dur = animate ? 1.2 : 0;
gsap.to(cam, { x: CAMS[k].x, y: CAMS[k].y, zoom: CAMS[k].zoom, tilt: CAMS[k].tilt, duration: dur, ease: 'cwInOut', overwrite: true });
gsap.to(st, { exp: EXPORT[k], duration: dur, ease: 'none', overwrite: 'auto' });
gsap.to(st, { blob: BLOB[k], duration: animate ? 1.6 : 0, ease: 'cw', overwrite: 'auto' });
gsap.to(st, { tag: k >= 3 ? 1 : 0, duration: animate ? 0.5 : 0, delay: animate && k === 3 ? 0.6 : 0, ease: 'none', overwrite: 'auto' });
st.pay = k === 4 ? 1 : 0;
if (st.draw < 1) gsap.to(st, { draw: 1, duration: animate ? 1.2 : 0, ease: 'cw' });
if (st.lit < 1) gsap.to(st, { lit: 1, duration: animate ? 1.6 : 0, delay: animate ? 0.3 : 0, ease: 'none' });
}
/* ---------- loop ---------- */
var running = false, visible = false, raf = 0;
function loop(now) { step(now); draw(now); raf = requestAnimationFrame(loop); }
function setRun(on) {
if (CW.reduced || CW.static) on = false;
if (on === running) return; running = on;
if (on) { prev = performance.now(); raf = requestAnimationFrame(loop); } else cancelAnimationFrame(raf);
}
CW.onScreen(stage, function (v) { visible = v; setRun(v && !document.hidden); }, '100px');
document.addEventListener('visibilitychange', function () { setRun(visible && !document.hidden); });
window.addEventListener('resize', resize);
if (CW.reduced) {
/* final chapter as a still */
go(4, false);
st.draw = 1; st.lit = 1; st.exp = 1; st.blob = 0; st.tag = 1;
homes.forEach(function (h) { h.lit = 1; h.mix = 1; });
var now0 = performance.now();
for (var i = 0; i < 16; i++) ticks.push({ x: homes[i * 7 % homes.length].x, y: homes[i * 7 % homes.length].y, t0: now0 - 500, txt: '+£' });
resize();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { draw(now0); });
draw(now0);
return;
}
resize();
go(0, false);
st.draw = 0; st.lit = 0;
var started = false;
ScrollTrigger.create({
trigger: sec, start: 'top top', end: function () { return '+=' + window.innerHeight * 6; },
pin: stage, pinSpacing: true, anticipatePin: 1,
onUpdate: function (self) { var k = Math.min(4, Math.floor(self.progress * 6)); go(k, true); }
});
ScrollTrigger.create({
trigger: sec, start: 'top 70%', once: true,
onEnter: function () { if (started) return; started = true; gsap.to(st, { draw: 1, duration: 1.2, ease: 'cw' }); gsap.to(st, { lit: 1, duration: 1.6, delay: 0.4, ease: 'none' }); }
});
draw(performance.now());
};
})();
```
## `js/meter.js`
```js
/* Signature 1 · live grid-frequency meter in the nav.
50 Hz ± 0.04 Hz. A random target is drawn every 0.25 s and the reading follows it through a
one-pole low-pass filter (time constant 0.45 s), so it drifts like a real system frequency
instead of jittering. Surplus biases the reading slightly high, demand slightly low.
The simulated day runs at 1 hour per 3 s; 08:00–16:30 is surplus (volt), otherwise demand (cobalt). */
(function () {
'use strict';
var CW = window.CW;
CW.meter = function () {
var m = document.querySelector('.meter'); if (!m) return;
var val = m.querySelector('.meter__val'), needle = m.querySelector('.meter__needle');
var modeEl = m.querySelector('.meter__mode'), clock = m.querySelector('.meter__clock');
var lensMode = document.querySelector('.lens__mode'), lensTime = document.querySelector('.lens__time');
var f = 50, target = 50, nextT = 0, lastText = 0, prev = performance.now();
var mode = '';
var HZ_RANGE = 0.04, NEEDLE_DEG = 58;
function setMode(md) {
if (md === mode) return; mode = md; CW.sim.mode = md;
m.setAttribute('data-mode', md);
var label = md === 'surplus' ? 'Surplus' : 'Demand';
modeEl.textContent = label; if (lensMode) lensMode.textContent = label;
m.setAttribute('aria-label', 'Simulated grid frequency, ' + label.toLowerCase());
document.dispatchEvent(new CustomEvent('cw:mode', { detail: md }));
}
function paint(now) {
var t = CW.simTime(CW.sim.hour);
clock.textContent = t; if (lensTime) lensTime.textContent = t;
val.textContent = f.toFixed(2);
lastText = now;
}
function modeFor(h) { h = h % 24; return (h >= 8 && h < 16.5) ? 'surplus' : 'demand'; }
setMode(modeFor(CW.sim.hour));
paint(prev);
if (CW.static) return;
function tick(now) {
var dt = Math.min(0.1, (now - prev) / 1000); prev = now;
CW.sim.hour = (CW.sim.hour + dt / 3) % 24;
setMode(modeFor(CW.sim.hour));
if (now > nextT) {
var bias = mode === 'surplus' ? 0.012 : -0.014;
target = bias + (Math.random() * 2 - 1) * 0.022;
nextT = now + 250;
}
var k = 1 - Math.exp(-dt / 0.45);
f += (50 + target - f) * k;
f = Math.max(50 - HZ_RANGE, Math.min(50 + HZ_RANGE, f));
var jitter = CW.reduced ? 0 : (Math.random() - 0.5) * 0.0015;
var deg = ((f + jitter - 50) / HZ_RANGE) * NEEDLE_DEG;
needle.style.transform = 'rotate(' + deg.toFixed(2) + 'deg)';
if (now - lastText > (CW.reduced ? 1000 : 120)) paint(now);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
};
})();
```
## `js/sees.js`
```js
/* 2 · What the grid sees: pinned line list + depth fly-through.
Values derived from the measured 300 px-step sample at 1440 × 900 (scaled by viewport width):
- cards sit at translate3d(±475px, 0, z) → ±32.99vw; card width at z = 0 is 485 px → 33.68vw
- perspective: fitted from 20 projected widths, w = W·P / (P − z) → P = 250 px (17.36vw), W = 485 px
(the brief's 1000 px estimate would only grow a card 1.25× at z +200; the sample grows it 5×)
- z runs −500 → +200 at 0.69 px of depth per px of scroll (+207 per 300 px) → 1014 px per card
- consecutive cards are 233 depth units apart → a new card every 338 px of scroll
- opacity 0 → 1 over z −500…−325, holds, 1 → 0 over z +25…+200 (fit: 0.440 @ −423, 0.893 @ +44, 0.448 @ +122)
- lines: inactive 0.5, active 1; the first switch lands 840 px in (card 1 at z ≈ +80), then every 338 px
- outro: list items go translateY(25px) / opacity 0 over the 300 px before the last card leaves;
outro lines rise in (y 12px, rotate 1deg → 0); the paragraph fades 1 → 0 over 1500 px, ease power2.in
- pin ≈ 3600 px (last card out at 2704 + 900 hold) */
(function () {
'use strict';
var CW = window.CW;
function splitLines(p) {
var text = p.textContent.trim(), words = text.split(/\s+/);
p.textContent = '';
var spans = words.map(function (w, i) { var s = document.createElement('span'); s.textContent = w + (i < words.length - 1 ? ' ' : ''); p.appendChild(s); return s; });
var lines = [], cur = null, top = null;
spans.forEach(function (s) { var t = s.offsetTop; if (t !== top) { cur = []; lines.push(cur); top = t; } cur.push(s.textContent); });
p.textContent = '';
var sr = document.createElement('span'); sr.className = 'sr'; sr.textContent = text; p.appendChild(sr);
return lines.map(function (l) { var ln = document.createElement('span'); ln.className = 'ln'; ln.setAttribute('aria-hidden', 'true'); ln.textContent = l.join('').trim(); p.appendChild(ln); return ln; });
}
CW.sees = function () {
var sec = document.querySelector('.sees'); if (!sec) return;
var stage = sec.querySelector('.sees__stage');
var cards = [].slice.call(sec.querySelectorAll('.sees__card'));
var items = [].slice.call(sec.querySelectorAll('.sees__item'));
var kicker = sec.querySelector('.sees__kicker');
var outro = sec.querySelector('.sees__outro');
if (CW.reduced) return;
var lines = [];
CW.seesSplit = function () { lines = splitLines(outro); render(lastS); };
var lastS = 0;
var mobile = CW.mobile();
var N = mobile ? 4 : 6;
var used = mobile ? [0, 2, 3, 5] : [0, 1, 2, 3, 4, 5];
cards.forEach(function (c, i) { if (used.indexOf(i) < 0) c.style.display = 'none'; });
var active = used.map(function (i) { return cards[i]; });
var SPAWN = 338, RATE = 0.69, Z0 = -500, Z1 = 200;
var TRAVEL = (Z1 - Z0) / RATE; // 1014.5 px
var cardsEnd = (N - 1) * SPAWN + TRAVEL; // 2704 px on desktop
var firstSwitch = 840;
var lineStep = mobile ? (cardsEnd - 300 - firstSwitch) / 5 : SPAWN;
var leaveStart = cardsEnd - 300;
var outroIn = cardsEnd - 100;
var pinLen = cardsEnd + 900; // ≈ 3604 px
var FADE = 1500, total = cardsEnd + FADE;
var X = 0, ZS = 1, lastK = -1;
/* floor lattice: 100-unit cells that advance at the cards' 0.69 depth rate, from z −800 to +200 */
var floor = sec.querySelector('.sees__floor');
function measure() {
var w = window.innerWidth; X = w * (mobile ? 0.30 : 0.3299); ZS = w / 1440;
if (floor) {
floor.style.width = (3600 * ZS) + 'px'; floor.style.height = (1000 * ZS) + 'px';
floor.style.backgroundSize = (100 * ZS) + 'px ' + (100 * ZS) + 'px';
floor.style.transform = 'translate3d(-50%,' + ((mobile ? 330 : 290) * ZS) + 'px,' + (-800 * ZS) + 'px) rotateX(90deg)';
}
}
measure();
function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function render(s) {
lastS = s;
if (floor) floor.style.backgroundPosition = '50% ' + ((RATE * s * ZS) % (100 * ZS)).toFixed(2) + 'px';
for (var i = 0; i < N; i++) {
var c = active[i], rel = s - i * SPAWN, z = Z0 + RATE * rel;
if (rel < 0 || z >= Z1) { if (c.__v !== 0) { c.style.visibility = 'hidden'; c.style.opacity = 0; c.__v = 0; } continue; }
var op = z < -325 ? (z + 500) / 175 : z > 25 ? (200 - z) / 175 : 1;
op = clamp(op);
var side = i % 2 === 0 ? -1 : 1;
c.style.visibility = 'visible'; c.__v = 1;
c.style.opacity = op.toFixed(3);
c.style.transform = 'translate3d(' + (side * X).toFixed(1) + 'px,0,' + (z * ZS).toFixed(2) + 'px)';
}
var k = s < firstSwitch ? 0 : Math.min(5, 1 + Math.floor((s - firstSwitch) / lineStep));
if (k !== lastK) { items.forEach(function (it, j) { it.classList.toggle('is-on', j === k); }); lastK = k; }
var p = clamp((s - leaveStart) / 300);
items.forEach(function (it, j) {
var pj = clamp(p * 1.4 - j * 0.08);
it.style.transform = pj ? 'translateY(' + (25 * pj).toFixed(1) + 'px)' : '';
it.style.opacity = (1 - pj).toFixed(3);
});
kicker.style.opacity = (1 - p).toFixed(3);
var q = clamp((s - outroIn) / 400);
lines.forEach(function (ln, m) {
var qm = clamp(q * 1.5 - m * 0.18), e = 1 - Math.pow(1 - qm, 3);
ln.style.opacity = e.toFixed(3);
ln.style.transform = 'translateY(' + (12 * (1 - e)).toFixed(1) + 'px) rotate(' + (1 * (1 - e)).toFixed(2) + 'deg)';
});
var f = clamp((s - cardsEnd) / FADE);
outro.style.opacity = (1 - f * f).toFixed(3);
}
ScrollTrigger.create({ trigger: sec, start: 'top top', end: '+=' + Math.round(pinLen), pin: stage, pinSpacing: true, anticipatePin: 1 });
var st = ScrollTrigger.create({
trigger: sec, start: 'top top', end: '+=' + Math.round(total),
onUpdate: function (self) { render(self.progress * total); },
onRefresh: function (self) { measure(); render(self.progress * total); }
});
render(0);
CW.seesST = st;
};
})();
```
## `js/stats.js`
```js
/* 5 · Stat band on volt. Numerals ≈ 9.3vw; the outline word behind (1 px stroke, black at 12%)
slides horizontally at 0.5× scroll; counters roll up on enter over 1.4 s with the brand ease.
(Numeral size, word height ≈ 17vw cap height and the 0.5× rate are estimated from frames.) */
(function () {
'use strict';
var CW = window.CW;
function fmt(v, kind) {
if (kind === 'dec1') return v.toFixed(1);
return Math.round(v).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
CW.stats = function () {
var sec = document.querySelector('.stats'); if (!sec) return;
var word = sec.querySelector('.stats__word');
var nums = sec.querySelectorAll('[data-to]');
if (CW.reduced) return;
ScrollTrigger.create({
trigger: sec, start: 'top bottom', end: 'bottom top',
onUpdate: function (self) { word.style.transform = 'translate3d(' + (window.innerWidth * 0.06 - (self.scroll() - self.start) * 0.5).toFixed(1) + 'px,0,0)'; }
});
word.style.transform = 'translate3d(' + (window.innerWidth * 0.06) + 'px,0,0)';
nums.forEach(function (el) {
var to = parseFloat(el.getAttribute('data-to')), kind = el.getAttribute('data-fmt');
var o = { v: 0 }; el.textContent = fmt(0, kind);
ScrollTrigger.create({ trigger: el, start: 'top 88%', once: true, onEnter: function () {
gsap.to(o, { v: to, duration: 1.4, ease: 'cw', onUpdate: function () { el.textContent = fmt(o.v, kind); } });
} });
});
};
})();
```
## `js/tour.js`
```js
/* 4 · Feature tour: white sheet (28 px top radius) rising over the pinned map; sticky title list
whose active item snaps as each panel crosses the viewport middle (0.3 s colour transition);
panel images parallax at 0.12× scroll (+36 px per 300 px); the street rail drifts left at
0.2533× scroll (−131 → −207 px over 300 px); chart bars grow on enter (0.9 s, stagger 0.05, cw). */
(function () {
'use strict';
var CW = window.CW;
CW.tour = function () {
var sec = document.querySelector('.tour'); if (!sec) return;
var titles = [].slice.call(sec.querySelectorAll('.tour__titles li'));
var panels = [].slice.call(sec.querySelectorAll('.tour__panel'));
var track = sec.querySelector('.tour__track');
function setActive(i) { titles.forEach(function (t, j) { t.classList.toggle('is-on', j === i); }); }
panels.forEach(function (p, i) {
ScrollTrigger.create({ trigger: p, start: 'top center', end: 'bottom center', onToggle: function (self) { if (self.isActive) setActive(i); } });
});
initPhone(sec);
if (CW.reduced) return;
/* rail */
ScrollTrigger.create({
trigger: sec, start: 'top bottom', end: 'bottom top',
onUpdate: function (self) { track.style.transform = 'translate3d(' + (-(self.scroll() - self.start) * 0.2533).toFixed(1) + 'px,0,0)'; }
});
/* parallax images */
sec.querySelectorAll('.tour__frame').forEach(function (fr) {
var img = fr.querySelector('.tour__img');
ScrollTrigger.create({
trigger: fr, start: 'top bottom', end: 'bottom top',
onUpdate: function (self) { var D = self.end - self.start; img.style.transform = 'translate3d(0,' + ((self.progress - 0.5) * D * 0.12).toFixed(1) + 'px,0)'; }
});
});
/* bars grow on enter */
function grow(sel, trig) {
var bars = sec.querySelectorAll(sel); if (!bars.length) return;
gsap.set(bars, { scaleY: 0 });
ScrollTrigger.create({ trigger: trig, start: 'top 78%', once: true, onEnter: function () { gsap.to(bars, { scaleY: 1, duration: 0.9, stagger: 0.05, ease: 'cw' }); } });
}
grow('.ui-health__bars i', '#f-health .tour__frame');
grow('.ui-earn__chart i', '#f-earn .tour__frame');
/* outage timeline: street drops out, the house lane stays lit */
var down = sec.querySelector('.ui-outage__down'), home = sec.querySelector('.ui-outage__home'), wins = sec.querySelectorAll('.ui-outage__house .win');
gsap.set(down, { scaleX: 0 }); gsap.set(home, { scaleX: 0 });
ScrollTrigger.create({ trigger: '#f-outage .tour__frame', start: 'top 78%', once: true, onEnter: function () {
var tl = gsap.timeline();
tl.to(home, { scaleX: 1, duration: 1.6, ease: 'cwInOut' }, 0)
.to(down, { scaleX: 1, duration: 0.9, ease: 'cw' }, 0.45)
.fromTo(wins, { opacity: 0.35 }, { opacity: 1, duration: 0.3, ease: 'none', stagger: 0.08 }, 0.5);
} });
};
function initPhone(sec) {
var ring = sec.querySelector('.phone__ringfg'), pct = sec.querySelector('.phone__pctv');
var earn = sec.querySelector('.phone__earn'), tog = sec.querySelector('.phone__toggle'), lbl = sec.querySelector('.phone__tlabel');
if (!ring) return;
var pence = 184, on = true;
tog.addEventListener('click', function () {
on = !on; tog.setAttribute('aria-checked', on ? 'true' : 'false');
lbl.textContent = on ? 'Sell at peak' : 'Selling paused';
});
if (CW.reduced) return;
var o = { v: 0 };
ring.style.strokeDasharray = '0 100';
ScrollTrigger.create({ trigger: '#f-app .tour__frame', start: 'top 75%', once: true, onEnter: function () {
gsap.to(o, { v: 78, duration: 1.4, ease: 'cw', onUpdate: function () { ring.style.strokeDasharray = o.v.toFixed(2) + ' 100'; pct.textContent = Math.round(o.v); } });
} });
var live = false;
CW.onScreen(sec.querySelector('#f-app'), function (v) { live = v; });
setInterval(function () {
if (!live || !on || CW.static) return;
pence += 1 + Math.floor(Math.random() * 3);
earn.textContent = '£' + (pence / 100).toFixed(2);
gsap.fromTo(earn, { color: CW.col.cobalt }, { color: CW.col.black, duration: 0.8, ease: 'none' });
}, 2400);
}
})();
```