Stillwarm
Mira por dónde se escapa tu calor
Qué incluye
- Prompts
- HTML · 472 líneas / React · 483 líneas
- Medios
- Imagen de fondo
- Alojamiento
- cdn.orbital.supply, ya incluido en el prompt
- Tipografías
- Big Shoulders + IBM Plex Mono · Google Fonts
- HTML
- Un archivo, sin librerías
- React
- React 19 · Vite · TypeScript · Tailwind 4 · lucide
- Movimiento
- lens-reveal, stepped-type, scan-reveal
Probada y documentada
05 de octubre de 2026
- Reproducida en Claude
- Reconstruida a ciegas desde el prompt: HTML y React
- Revisada a 375, 768, 1440, 1920 px
- Revisor de la spec: 0 errores, sin enlaces a generadores
Cómo usarla
Claude
Pega el prompt de HTML en un chat nuevo. Obtienes un index.html para servir por HTTP o publicar donde quieras.
Vite + React
Abre un proyecto vacío de Vite + React y pega el prompt de React. Indica los paquetes exactos; las URLs de los recursos ya están dentro.
El prompt
React · Vite483 líneas
# Build: "Stillwarm" — a thermal-camera lens that shows where a house loses heat (React)
## 0. Brief
A one-screen landing page for **Stillwarm**, infrared home audits: pale snow, slate ink, one orange accent on the CTA buttons. A framed photo of a white house alone in a snowy field is dressed as a thermal camera's viewfinder (corner marks, data plates, a −8 → 22 °C scale). The "wow": the pointer *is* the camera, a circular lens with a spot-meter reticle that reveals the same photo in false-colour infrared, aligned pixel to pixel, with a live °C reading and report labels on five hot spots. "Thermal view" opens the infrared over the whole frame with all five findings. Short, fast-out motion; monospace data.
## 1. Output & hard constraints
- Stack (exact): React 19 + Vite + TypeScript + Tailwind CSS v4 (`@tailwindcss/vite`) + `lucide-react` (`ArrowRight` only). No `motion` (all animation is CSS or the engine), nothing else. StrictMode on; `npm run build` with zero TypeScript errors.
- `index.html`: `lang="en"`, viewport, title `Stillwarm — See where your heat escapes`, description "Thermal home audits that show exactly where your heat escapes.", theme-color `#EEF1F2`, font preconnects, `<link rel="icon" href="data:,">`, `<body style="background:#EEF1F2;color:#1D2428">`. Then `src/main.tsx`, `index.css`, `App.tsx`, `reveal.ts`, `data.ts` and `components/`: `Nav.tsx` (header + menu), `Copy.tsx`, `Frame.tsx` (engine root), `Scale.tsx`, `Findings.tsx` (screen-reader list).
- `src/index.css` order: font `@import url(…)`, `@import "tailwindcss";`, anti-flash `html,body{background:#EEF1F2!important;color:#1D2428}`, `@theme` + `:root` (sections 4 and 9), then the literal CSS of 6.5–7 **outside any `@layer`** (so it beats utilities), reduced motion last.
- Everything else: Tailwind utilities with arbitrary values reproducing sections 3–6 exactly (`h-[44px] px-[16px] rounded-[3px] bg-ink`, `top-(--sw-inset)`). Section 5 becomes JSX (camelCase SVG attributes, `data-rv-spot=""`), keeping every `sw-*` class (the engine and the literal CSS target them).
- Page type: from 1100px, one screen (the frame fills the viewport under the nav; it only scrolls if the window is shorter than nav + 480px + gutter). Below 1100px, a short scrolling page: copy, frame, colour scale.
- Build exactly what this prompt describes. Do NOT add:
- an automatic tour of the hot spots (anywhere) or a play/pause control;
- Tab stops on the hot spots (keyboard and touch access is "Thermal view" plus the screen-reader list);
- labels that move to avoid each other or the edges (each keeps its side, 6.6);
- more zones or spots than the data of section 8 (8 zones, 5 spots);
- a third layout: exactly two, stacked below 1100px and overlaid from 1100px;
- scrambled or animated digits in the reading, or an indicator that runs along the scale;
- other sections, a footer, pricing, testimonials, forms, tooltips, a cursor outside the frame, canvas, WebGL or pixel sampling (every temperature comes from the zone table), animation libraries, icon fonts.
- Keep the links `href="#audits"`, `"#method"`, `"#report"`, `"#book"`; do not build those sections.
- Do NOT substitute, rename or invent asset URLs: section 2, verbatim.
- Zero console errors.
## 2. Assets (exact URLs — the only images)
| ID | Type | URL | Size | Role |
|---|---|---|---|---|
| BASE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/base.webp` | 2400×1343 | Photo (≥768) |
| BASE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/base.jpg` | 2400×1343 | Fallback `<img src>` |
| THERMAL_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/thermal.webp` | 2400×1343 | Infrared, registered on BASE pixel to pixel |
| THERMAL_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/thermal.jpg` | 2400×1343 | Fallback |
| BASE_MOBILE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/base-mobile.webp` | 1080×1350 | Photo ≤767: 4:5 crop x 47.42 → 92.17% |
| BASE_MOBILE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/base-mobile.jpg` | 1080×1350 | Fallback |
| THERMAL_MOBILE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/thermal-mobile.webp` | 1080×1350 | Infrared ≤767, same crop |
| THERMAL_MOBILE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/thermal-mobile.jpg` | 1080×1350 | Fallback |
Rules:
- Each layer is one `<picture>`, in this order: `<source media="(max-width: 767px)" type="image/webp">` (mobile WebP), `<source media="(max-width: 767px)">` (mobile JPEG), `<source type="image/webp">` (desktop WebP), `<img src>` (desktop JPEG). The first match wins; `(max-width: 767px)` is also the engine's query.
- **The two layers must stay aligned:** same box (the whole frame), `object-fit:cover` and `object-position` at every width (6.4). Any difference on one layer only (object-position, max-width, border, padding, transform, filter) shifts the infrared image against the photo.
- Base `<img>`: `width="2400" height="1343" alt="A white two-storey house alone in a snowy field under an overcast sky" fetchpriority="high" decoding="async"`. Thermal: same size attributes, `alt="" aria-hidden="true"`.
- An image that fires `error` gets `visibility:hidden` (also checked on mount with `img.complete && img.naturalWidth === 0`, because it can fail before the handler exists): the frame keeps its size and its `#D9DDE1` background. Icons are inline SVG.
## 3. Typography
```
https://fonts.googleapis.com/css2?family=Big+Shoulders:opsz,wght@10..72,500..800&family=IBM+Plex+Mono:wght@400;500&display=swap
```
`index.html` preconnects to `https://fonts.googleapis.com` and `https://fonts.gstatic.com` (`crossorigin`); `src/index.css` starts with `@import url("…");` of this exact URL.
- The display family is **"Big Shoulders"** (variable: `opsz` 10–72, `wght` 500–800). Do NOT "correct" it to "Big Shoulders Display": that family left the catalogue, the API drops it silently and all display text falls back to Arial Narrow. Keep `font-optical-sizing:auto`; weights 760 and 720 are intentional.
- Neither family has Greek glyphs: never write "ε". Write `Emissivity` (short form `E`).
- Characters: U+2212 `−` in negative numbers (never a hyphen), `°`, `·`, and U+00A0 between a number and `°C` (` `).
- Body: mono 15px/1.5, antialiased.
| Role | Font · size / line-height · weight | Tracking · case · colour |
|---|---|---|
| Wordmark "Stillwarm" | Display 24px/1 760 | .04em · uppercase · ink |
| H1 | Display `--sw-h1`/.9 760 | ink |
| Eyebrow | Mono 11px (≤767) · 12px (≥768) /1.5 400 | .14em · uppercase · muted |
| Lede | Mono 13.5px/21px (≤767) · 15px/24px (≥768) 400 | muted |
| Nav links · nav CTA, Menu/Close | Mono 13px 400 · 500 | muted (ink on hover) · as 6.1 |
| Primary CTA · secondary link | Mono 14px 500 · 400 | ink |
| Chip · data plates | Mono 11px 500 | .1em · uppercase · ink |
| Label line 1 · kind | Mono 10px/13px 500 · 400 | .12em · uppercase · on-ink · on-ink-2 |
| Label value | Display 22px/1 700 (15px as a tag) | paper |
| Lens readout | Mono 12px/22px 400, value 500 | on-ink-2, value paper |
| Scale unit · ticks | Mono 11px 500 · 10px/12px 400 | ink · muted |
## 4. Design tokens (mobile first; section 9 overrides the layout ones)
```css
@theme{
--color-snow:#EEF1F2; --color-paper:#F4F6F7; --color-ink:#1D2428; --color-ink-2:#2B343A;
--color-muted:#4F5A61; --color-line:#C9D1D5; --color-on-ink:#C9D1D5; --color-on-ink-2:#AEB8BE;
--color-accent:#F2641D; --color-accent-hi:#F4733A; --color-plate:rgba(244,246,247,.9); --color-frame:#D9DDE1;
--font-display:"Big Shoulders","Arial Narrow",sans-serif;
--font-mono:"IBM Plex Mono",ui-monospace,monospace;
}
:root{
--sw-ink:#1D2428; --sw-plate:rgba(244,246,247,.9); --sw-frame:#D9DDE1;
/* thermal ramp, cold → hot; stop = (°C + 8) / 30 */
--sw-ramp:#325182 0%,#3F5BBE 2.3%,#706BCA 4%,#936DC2 6.7%,#B15DD0 16.7%,#BE01CD 30%,#D000B8 40%,#EC0377 51.7%,#DE0E44 60%,#F1440B 66.7%,#FA5701 70%,#FC9306 76.7%,#FEB703 83.3%,#FDD405 90%,#FDE212 95%,#FBFD83 100%;
--sw-ease:cubic-bezier(.24,.7,.3,1); --sw-sweep:cubic-bezier(.42,.12,.32,1);
/* layout, up to 767px */
--sw-gutter:16px; --sw-nav-h:56px; --sw-ui:34px; --sw-inset:10px; --sw-arm:18px; --sw-chip-h:28px;
--sw-lens:124px; --sw-h1:clamp(44px,14.6vw,64px);
}
```
Colour names in sections 3–6 (ink, paper, muted, plate…) are these theme colours (`bg-ink`, `text-muted`); Display and Mono are `font-display` and `font-mono`. `--sw-ui`: horizontal inset of chip, plates and overlaid copy · `--sw-inset`: corners and plates from the frame edge · `--sw-arm`: corner-mark arm · `--sw-lens`: lens diameter (the engine reads its radius). Besides preflight: `:focus-visible{outline:2px solid var(--sw-ink);outline-offset:3px}`, buttons `cursor:pointer`.
## 5. Layer stack & DOM
**Z-order in the frame** (`isolation:isolate`): photo 0 → thermal layer 1 → lens 2 → hot spots 3 → corners, chip, data plates 4. Copy and scale are siblings of the frame; overlaid (≥1100) they sit above it at z 5.
**Markup** (classes, ids and data attributes are load-bearing for the engine and for sections 6–7; `Nav` = header + menu panel after it, `App` = `<main>` with `Copy`, `Frame`, `Scale` in `.sw-view`, then `Findings`):
```html
<header class="sw-nav" id="top">
<a class="sw-logo" href="#top" aria-label="Stillwarm, home"><svg>…6.1…</svg><span>Stillwarm</span></a>
<nav class="sw-links" aria-label="Main"><a href="#audits">Audits</a><a href="#method">Method</a><a href="#report">Sample report</a></nav>
<a class="sw-navcta" href="#book">Book an audit <svg>…arrow…</svg></a>
<button class="sw-menubtn" id="sw-menu-btn" type="button" aria-expanded="false" aria-controls="sw-menu">Menu</button>
</header>
<main class="sw-stage" id="main">
<div class="sw-view">
<div class="sw-copy">
<p class="sw-eyebrow">Infrared home audits · Winter 2026</p>
<h1><span class="sw-l">See where your</span> <span class="sw-l">heat escapes.</span></h1>
<p class="sw-lede">We scan every wall, window and seam at dawn, then put a price on every leak.</p>
<div class="sw-ctas"><a class="sw-cta" href="#book">Book a thermal audit <svg>…arrow…</svg></a><a class="sw-more" href="#method">How a scan works</a></div>
</div>
<div class="sw-frame" id="sw-frame">
<picture>…BASE sources…<img class="sw-img" data-rv-base …></picture>
<picture>…THERMAL sources…<img class="sw-img" data-rv-layer …></picture>
<div class="sw-lens" data-rv-lens aria-hidden="true"><div class="sw-lens-in">
<svg class="sw-reticle">…6.5…</svg>
<p class="sw-readout"><span>Spot</span> <b data-rv-value>16.6 °C</b></p>
</div></div>
<!-- 5 spots in order; data-n="2"…"5" repeat this (texts in 6.6) -->
<div class="sw-spot" data-rv-spot data-n="1" aria-hidden="true">
<i class="sw-lead"></i><i class="sw-mark"></i>
<span class="sw-label"><span class="sw-lab-id">01 · Entrance door</span><span class="sw-lab-row"><b class="sw-lab-val">17.4 °C</b><span class="sw-lab-kind">Air leak</span></span></span>
</div>
<i class="sw-corner sw-tl"></i><i class="sw-corner sw-tr"></i><i class="sw-corner sw-bl"></i><i class="sw-corner sw-br"></i>
<button class="sw-chip" type="button" data-rv-toggle aria-pressed="false"><svg>…half disc…</svg>Thermal view</button>
<p class="sw-meta sw-meta-l" aria-hidden="true"><span class="sw-long">Emissivity</span><span class="sw-short">E</span> <b>0.93</b> · <b>07:42</b></p>
<p class="sw-meta sw-meta-r" aria-hidden="true"><span class="sw-long">Outside</span><span class="sw-short">Out</span> <b>−7.1 °C</b></p>
</div>
<div class="sw-scale" aria-hidden="true"><span class="sw-unit">°C</span><i class="sw-bar"></i><span class="sw-ticks"><span>22</span><span>16</span><span>10</span><span>4</span><span>−2</span><span>−8</span></span></div>
</div>
<h2 class="sw-sr">Hot spots found in this scan</h2>
<ol class="sw-sr">…section 10…</ol>
</main>
<div class="sw-menu" id="sw-menu" role="dialog" aria-modal="true" aria-label="Menu" hidden>…6.2…</div>
```
## 6. Components
### 6.1 Nav
- `.sw-nav`: flex, centred vertically, height `var(--sw-nav-h)`, padding `0 var(--sw-gutter)`; static.
- `.sw-logo`: inline-flex, centred, gap 12px, ink. Mark: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="7"/><path d="M12 2v5M12 17v5M2 12h5M17 12h5"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg>`, then the wordmark.
- `.sw-links` (≥768 only): flex, gap 28px, margin-left 40px.
- `.sw-navcta` (≥768 only): margin-left auto; inline-flex, centred, gap 10px; height 44px; padding `0 16px`; radius 3px; ink background, paper text. Hover: ink-2, arrow `translate:2px 0`.
- Arrow (also in the CTAs): lucide `ArrowRight`, `size={16} strokeWidth={1.75} aria-hidden`.
- `.sw-menubtn` (≤767 only): margin-left auto; height 40px; padding `0 14px`; border 1px solid ink; radius 4px; transparent background.
### 6.2 Mobile menu (a dialog, ≤767 only)
- `.sw-menu`: `position:fixed; inset:0; z-index:50`; snow; flex column; padding `0 16px 24px`; plus `.sw-menu[hidden]{display:none}` (an author `display:flex` beats the browser's `[hidden]` rule: without it the closed panel covers the page). It opens by removing `hidden`, never with a `visibility` transition (it would not be focusable yet).
- Top row, 56px, `space-between`: a logo copy (`<span class="sw-logo" aria-hidden="true">`) and `<button class="sw-menubtn" id="sw-menu-close" type="button">Close</button>`.
- `<ol>`, margin-top 20px, 1px line under each `<li>`; each link: flex, baseline, gap 16px, padding `14px 0 12px`, index `01`–`03` (mono 12px muted) + name (Audits, Method, Sample report; display 40px/1 720), nav hrefs.
- Bottom (`margin-top:auto`): the primary CTA, full width, 48px high.
### 6.3 Copy
- Keep the class `.sw-copy` (the engine switches the lens off over it). Each `.sw-l` is `display:block; width:max-content; max-width:100%` (the break is always after "your"; `max-content` makes the stepped wipe advance one step per character across the text, not across the column). `.sw-lede`: max-width 46ch from 768px. `.sw-ctas`: flex, `align-items:center` (vertical only: the CTAs stay left-aligned), gap 24px.
- `.sw-cta` (primary): inline-flex, centred, gap 12px; height 50px; padding `0 20px`; radius 3px; accent background, ink text. Hover: accent-hi, arrow `translate:2px 0`.
- `.sw-more`: ink; 1px underline, offset 6px, colour `rgba(29,36,40,.4)`, ink on hover.
- Rhythm ≤767: padding-top 10px, eyebrow margin-bottom 14px, lede margin-top 14px, `.sw-ctas` margin-top 18px, CTA full width and 48px high, `.sw-more` hidden. From 768: 12px, 18px, 18px, 26px.
- Overlaid (≥1100): `position:absolute; z-index:5; left:calc(var(--sw-ui) - 28px); top:calc(var(--sw-ui) + 6px); width:max-content; padding:24px 28px 28px; border-radius:4px`. The padding is the lens-off margin and the Thermal view plate (6.6).
### 6.4 Frame and image layers
- `.sw-view` (wraps copy, frame and scale): `position:relative`; overlaid (≥1100) `height:max(480px, min(calc(100svh - var(--sw-nav-h) - var(--sw-gutter)), 58vw))`.
- `.sw-frame` (the engine root): `position:relative; overflow:hidden; isolation:isolate; border-radius:6px; background:var(--sw-frame); touch-action:pan-y pinch-zoom; user-select:none; -webkit-tap-highlight-color:transparent`; `cursor:none` under `(hover:hover) and (pointer:fine)` (the lens is the cursor; the chip keeps `pointer`).
- ≤767: `width:100%; aspect-ratio:4/5; margin-top:20px`.
- 768–1099: `width:100%; aspect-ratio:4/3; max-height:600px; margin-top:24px`. Keep `width:100%`: with `aspect-ratio` plus `max-height`, Chrome otherwise shrinks the width too.
- ≥1100: `width:100%; height:100%`.
- `.sw-img` (the same classes on both layers): `absolute inset-0 h-full w-full max-w-none object-cover pointer-events-none` (preflight sets `img{height:auto;max-width:100%}`); `object-position` 50% 50% (≤767), 75% 50% (768–1099), 0% 40% (≥1100). Photo z 0, thermal z 1; nothing else may differ.
### 6.5 Lens
- `.sw-lens`: `position:absolute; left:0; top:0; z-index:2; width:var(--sw-lens); height:var(--sw-lens); pointer-events:none; opacity:var(--rv-p,0); will-change:transform,opacity`; no transition (the engine drives `transform` and `--rv-p`).
- The power-on `scale` lives on `.sw-lens-in` (6.6), never on `.sw-lens`: there it would apply after the engine's `transform` and drag the lens off the pointer.
- Reticle, exact (every stroke 1.5px at any lens size; in JSX `className`, `strokeWidth`):
```html
<svg class="sw-reticle" viewBox="0 0 100 100" aria-hidden="true"><g class="sw-ring"><circle cx="50" cy="50" r="49.5" stroke="rgba(29,36,40,.3)" stroke-width="3"/><circle cx="50" cy="50" r="49.5"/><path d="M50 .5V8M99.5 50H92M50 99.5V92M.5 50H8M74.75 7.13 73.25 9.73M92.87 25.25 90.27 26.75M92.87 74.75 90.27 73.25M74.75 92.87 73.25 90.27M25.25 92.87 26.75 90.27M7.13 74.75 9.73 73.25M7.13 25.25 9.73 26.75M25.25 7.13 26.75 9.73"/></g><path class="sw-brackets" d="M32 38V32H38M62 32H68V38M68 62V68H62M38 68H32V62"/><path d="M50 39V46M50 54V61M39 50H46M54 50H61"/><circle cx="50" cy="50" r="2.2" fill="#F4F6F7" stroke="rgba(29,36,40,.5)" stroke-width="1"/></svg>
```
Literal CSS: `.sw-reticle{position:absolute;inset:0;width:100%;height:100%;overflow:visible;fill:none;stroke:rgba(244,246,247,.92);stroke-width:1.5}` and `.sw-reticle *{vector-effect:non-scaling-stroke}`.
- `.sw-readout`: `position:absolute; top:calc(100% - 8px); left:50%; translate:-50% 0; width:calc(12ch + 16px); height:22px; line-height:22px; text-align:center; white-space:nowrap; border-radius:3px; background:rgba(29,36,40,.92)`; the fixed width keeps it still when digits change.
### 6.6 Hot spots and labels
- `.sw-spot`: `position:absolute; left:0; top:0; width:0; height:0; z-index:3; pointer-events:none`.
- `.sw-mark`: 14px ring centred on the point, `border:1.5px solid rgba(244,246,247,.95)`, `box-shadow:0 0 0 1px rgba(29,36,40,.35), inset 0 0 0 1px rgba(29,36,40,.35)`, 3px paper dot in the centre.
- `.sw-lead`: ink, `box-shadow:0 0 0 .5px rgba(244,246,247,.55)`.
- `.sw-label`: `background:rgba(29,36,40,.94); border-radius:3px; padding:7px 10px 8px; white-space:nowrap`; `.sw-lab-id` block; `.sw-lab-row` flex, baseline, gap 8px, margin-top 3px.
| n | Line 1 | Value | Kind |
|---|---|---|---|
| 1 | 01 · Entrance door | 17.4 °C | Air leak |
| 2 | 02 · Corner glazing | 16.6 °C | Heat loss |
| 3 | 03 · Flue penetration | 15.1 °C | Thermal bridge |
| 4 | 04 · Upper glazing | 12.8 °C | Heat loss |
| 5 | 05 · Terrace doors | 13.6 °C | Air leak |
**State block (exact; `src/index.css`, outside `@layer`).** The sides were checked so that no label overlaps another, the copy, the chip, the scale or the plates from 375 to 2560px. One formula serves every side; the leader ends 12px outside the lens ring.
```css
/* engine contract: the thermal layer only shows inside the lens mask */
[data-rv-layer]{--rv-edge:12px;-webkit-mask-image:radial-gradient(circle var(--rv-r,0px) at var(--rv-x,50%) var(--rv-y,50%),#000 calc(var(--rv-r,0px) - var(--rv-edge,12px)),transparent var(--rv-r,0px));mask-image:radial-gradient(circle var(--rv-r,0px) at var(--rv-x,50%) var(--rv-y,50%),#000 calc(var(--rv-r,0px) - var(--rv-edge,12px)),transparent var(--rv-r,0px))}
[data-rv-spot]{visibility:hidden}
[data-rv-ready] [data-rv-spot]{visibility:visible}
/* label geometry · side = (--sw-sx, --sw-sy): u (0,-1) · ur (1,-1) · ul (-1,-1) · dl (-1,1) */
.sw-spot{--sw-sx:0;--sw-sy:-1;--sw-k:1;--sw-L:calc(var(--sw-k) * (var(--sw-lens) / 2 + 12px))}
.sw-lead{position:absolute;left:0;top:-.75px;width:var(--sw-L);height:1.5px;transform-origin:0 50%;rotate:calc(var(--sw-sy) * (90deg - var(--sw-sx) * 45deg))}
.sw-label{position:absolute;left:calc(var(--sw-sx) * var(--sw-L) * .7071);top:calc(var(--sw-sy) * var(--sw-L) * (1 - .2929 * var(--sw-sx) * var(--sw-sx)));translate:calc((var(--sw-sx) - 1) * 50%) calc((var(--sw-sy) - 1) * 50%);transform-origin:calc(50% - var(--sw-sx) * 50%) calc(50% - var(--sw-sy) * 50%)}
.sw-spot[data-n="1"]{--sw-sx:1}
.sw-spot[data-n="5"]{--sw-sx:-1;--sw-sy:1}
@media (min-width:768px) and (max-width:1099px){.sw-spot[data-n="1"]{--sw-sx:-1;--sw-sy:1}.sw-spot[data-n="3"]{--sw-sx:-1}}
@media (min-width:1100px){[data-rv-full] .sw-spot{--sw-k:.75}[data-rv-full] .sw-spot[data-n="1"]{--sw-sx:-1;--sw-sy:1}}
@media (max-width:767px){[data-rv-full] .sw-spot{--sw-L:14px}[data-rv-full] :is(.sw-lab-id,.sw-lab-kind){display:none}[data-rv-full] .sw-label{padding:3px 6px}[data-rv-full] .sw-lab-row{margin:0}[data-rv-full] .sw-lab-val{font-size:15px}}
/* show and hide: the engine sets [data-rv-on] on the spot whose label must show (all five in Thermal view) */
.sw-lead,.sw-label{opacity:0;transition:opacity .12s var(--sw-ease),scale .12s var(--sw-ease)}
.sw-lead{scale:0 1}
.sw-label{scale:.96}
[data-rv-on] .sw-lead{opacity:1;scale:1;transition-duration:.14s}
[data-rv-on] .sw-label{opacity:1;scale:1;transition-duration:.16s;transition-delay:.06s}
/* lens power, lock and Thermal view */
.sw-lens-in{position:absolute;inset:0;scale:calc(.84 + .16 * var(--rv-p,0))}
.sw-ring{transition:opacity .2s var(--sw-ease)}
[data-rv-full] .sw-ring{opacity:0}
.sw-brackets{transform-box:view-box;transform-origin:50% 50%;transition:scale .16s var(--sw-ease)}
[data-rv-lock] .sw-brackets{scale:.84}
@media (min-width:1100px){.sw-copy{transition:background-color .2s var(--sw-ease)}.sw-view:has([data-rv-full]) .sw-copy{background:var(--sw-plate)}}
```
### 6.7 Frame furniture: corner marks, "Thermal view" chip, data plates
- `.sw-corner`: `position:absolute; z-index:4; width:var(--sw-arm); height:var(--sw-arm); pointer-events:none`; 2px `rgba(29,36,40,.85)` borders on the two outer sides, `var(--sw-inset)` from the nearest edges (`.sw-tl`, `.sw-tr`, `.sw-bl`, `.sw-br`).
- `.sw-chip`: `position:absolute; z-index:4; top:var(--sw-inset); right:var(--sw-ui)`; inline-flex, centred, gap 8px; height `var(--sw-chip-h)`; padding `0 10px`; radius 3px; plate background, ink text. Hover: paper. `[aria-pressed="true"]`: ink background, paper text (hover ink-2). `:focus-visible`: `outline:2px solid var(--sw-ink); outline-offset:2px; box-shadow:0 0 0 6px rgba(244,246,247,.9)`. Icon: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="9.5"/><path d="M12 5.5a6.5 6.5 0 0 0 0 13z" fill="currentColor" stroke="none"/></svg>`.
- `.sw-meta`: `position:absolute; z-index:4; bottom:var(--sw-inset)`, left plate at `left:var(--sw-ui)`, right plate at `right:var(--sw-ui)`; block (flex drops the spaces around `·`), `line-height:var(--sw-chip-h)`; padding `0 10px`; radius 3px; plate background; nowrap; words and `·` muted, `<b>` ink 500. `.sw-long` from 768px, `.sw-short` up to 767px.
### 6.8 Colour scale
- `.sw-scale` is a grid with named areas (never auto-placement): `.sw-unit{grid-area:u}`, `.sw-bar{grid-area:b}`, `.sw-ticks{grid-area:t}`. The bar: radius 2px, `linear-gradient(to right, var(--sw-ramp))` horizontal, `to top` vertical.
- Horizontal, below 1100px (after the frame; margin-top 14px ≤767, 16px at 768–1099): `grid-template:"u b" auto ". t" auto / auto 1fr; gap:4px 12px; align-items:center`; bar 8px high; ticks flex `row-reverse`, `space-between` (−8 left, 22 right).
- Vertical, from 1100px: `position:absolute; z-index:5; right:var(--sw-inset); top:calc(var(--sw-inset) + var(--sw-chip-h) + 8px); width:60px; height:min(40%, 340px); padding:10px 12px 12px; border-radius:3px; background:var(--sw-plate); grid-template:"u u" auto "t b" 1fr / 1fr 8px; gap:8px 6px`; unit centred; ticks flex column, `space-between`, right-aligned (22 top, −8 bottom). Reset what the horizontal layout set: `align-items:stretch; margin-top:0` and the bar `height:auto`.
## 7. Motion
MP-CRISP (short, fast-out, almost linear). Entrance only for the headline (stepped wipe, one step per character) and the frame (opens top to bottom); the engine powers the lens on at 640ms; the menu drops in on each opening and closes instantly. Exact (`src/index.css`, outside `@layer`):
```css
@keyframes sw-type{from{clip-path:inset(-20% 100% -30% 0)}to{clip-path:inset(-20% 0 -30% 0)}}
@keyframes sw-scan{from{clip-path:inset(0 0 100% 0 round 6px)}to{clip-path:inset(0 round 6px)}}
@keyframes sw-menu-in{from{opacity:0;translate:0 -8px}}
.sw-l:first-child{animation:sw-type .28s steps(14,end) .06s backwards}
.sw-l:last-child{animation:sw-type .26s steps(13,end) .34s backwards}
.sw-frame{animation:sw-scan .48s var(--sw-sweep) .12s backwards}
.sw-menu:not([hidden]){animation:sw-menu-in .18s var(--sw-ease) backwards}
```
`backwards`, never `both`: nothing stays applied afterwards to fight hovers or states. The `sw-` prefix avoids collisions with framework animation names.
Interaction: the lens moves only through the engine (follow `lag` 55ms, power 0.8 × lag, Thermal view 420ms cubic ease-out); no CSS transition on the lens, `transform` or `--rv-*`. Labels, brackets, ring and copy plate use the state block (6.6). Buttons and chip: background 160ms, arrows `translate` 160ms, `var(--sw-ease)`.
Reduced motion (last rule; the engine makes itself instant):
```css
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
```
## 8. Behaviour (TypeScript)
1. **`src/reveal.ts`: the engine core, verbatim.** Do not edit, reformat or "improve" it: its guards fix real bugs.
```ts
/* EXP-REVEAL core v1 · init(root, data) → destroy() */
export type Pt = [number, number]
export type RevealData = {
image: Pt; rest: Pt; base: number; zones: [number, number, number, number, number][]
spots?: Pt[]; mobile?: { query: string; crop: [number, number, number, number] }
range?: Pt; decimals?: number; suffix?: string; soft?: number; near?: number; lag?: number; fullMs?: number; delay?: number; offOver?: string
}
export function init(root: HTMLElement, data: RevealData): () => void {
const [W, H] = data.image, SLOP = 8
const soft = data.soft ?? 10, near = data.near ?? 0.55, lag = data.lag ?? 55, FULL = data.fullMs ?? 420
const dec = data.decimals ?? 1, suffix = data.suffix ?? '', [lo, hi] = data.range ?? [-1e9, 1e9]
const q = (s: string) => root.querySelector<HTMLElement>(s)
const img = q('[data-rv-base]')!, layer = q('[data-rv-layer]')!, lens = q('[data-rv-lens]')!
const valueEl = q('[data-rv-value]'), toggle = q('[data-rv-toggle]')
const pins = [...root.querySelectorAll<HTMLElement>('[data-rv-spot]')]
const px = ([x, y]: Pt): Pt => [(x * W) / 100, (y * H) / 100] // % of the desktop image → its px
const spots = (data.spots ?? []).map(px), home = px(data.rest)
const zones = data.zones.map(([x0, y0, x1, y1, v]) => [...px([x0, y0]), ...px([x1, y1]), v])
const offSel = '[data-rv-toggle]' + (data.offOver ? ',' + data.offOver : '')
const rq = matchMedia('(prefers-reduced-motion: reduce)')
const mq = data.mobile ? matchMedia(data.mobile.query) : null
let reduce = rq.matches, view = false, touch: { id: number; x: number; y: number; drag: boolean } | null = null
let cur: Pt = [...home], tgt: Pt = [...home], pow = 0, powT = 0, full = 0, from = 0, t0 = 0
let mode = 'rest', shown = '', raf = 0, last = 0
// cover: one scale; object-position from the CSS; mobile = data.mobile.crop
const G = { fw: 1, fh: 1, s: 1, ox: 0, oy: 0, r: 1 }
function measure() {
const c = mq?.matches ? data.mobile!.crop : [0, 0, 100, 100]
const [cx, cy] = px([c[0], c[1]]), [cw, ch] = px([c[2] - c[0], c[3] - c[1]])
const op = getComputedStyle(img).objectPosition.split(' ').map(parseFloat)
G.fw = root.clientWidth; G.fh = root.clientHeight; G.r = lens.offsetWidth / 2
G.s = Math.max(G.fw / cw, G.fh / ch)
G.ox = ((G.fw - cw * G.s) * (op[0] || 0)) / 100 - cx * G.s
G.oy = ((G.fh - ch * G.s) * (op[1] || 0)) / 100 - cy * G.s
}
const toFrame = ([X, Y]: Pt): Pt => [G.ox + X * G.s, G.oy + Y * G.s]
const t3 = (x: number, y: number) => `translate3d(${x.toFixed(1)}px,${y.toFixed(1)}px,0)`
const toImage = (x: number, y: number): Pt => [(x - G.ox) / G.s, (y - G.oy) / G.s]
// exact inside a zone, blended out over `soft` px; later zones win
function read([X, Y]: Pt) {
let v = data.base
for (const [x0, y0, x1, y1, t] of zones) {
const d = Math.hypot(Math.max(x0 - X, 0, X - x1), Math.max(y0 - Y, 0, Y - y1))
if (d < soft) { const k = d / soft; v += (t - v) * (1 - k * k * (3 - 2 * k)) }
}
return Math.min(hi, Math.max(lo, v))
}
const fmt = (v: number) => { const n = +v.toFixed(dec); return (n < 0 ? '−' : '') + Math.abs(n).toFixed(dec) + suffix }
const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick) } }
function tick(now: number) {
const dt = Math.max(0, Math.min(64, now - last)), goal = view ? 1 : 0
last = now
if (reduce) { cur = [...tgt]; pow = powT; full = goal }
else {
const a = 1 - Math.exp(-dt / lag), b = 1 - Math.exp(-dt / (lag * 0.8))
cur = [cur[0] + (tgt[0] - cur[0]) * a, cur[1] + (tgt[1] - cur[1]) * a]
pow += (powT - pow) * b
const k = Math.max(0, Math.min(1, (now - t0) / FULL)) // rAF time can precede the click's performance.now()
full = from + (goal - from) * (1 - (1 - k) ** 3)
}
const busy = Math.abs(tgt[0] - cur[0]) + Math.abs(tgt[1] - cur[1]) > 0.1 || Math.abs(powT - pow) > 0.002 || full !== goal
if (!busy) { cur = [...tgt]; pow = powT }
render()
raf = busy ? requestAnimationFrame(tick) : 0
}
function render() {
const [x, y] = toFrame(cur), r = G.r, base = r * pow
const far = Math.hypot(Math.max(x, G.fw - x), Math.max(y, G.fh - y)) + 24
lens.style.transform = t3(x - r, y - r)
lens.style.setProperty('--rv-p', pow.toFixed(3))
layer.style.setProperty('--rv-x', x.toFixed(1) + 'px')
layer.style.setProperty('--rv-y', y.toFixed(1) + 'px')
layer.style.setProperty('--rv-r', Math.max(0, base + (far - base) * full).toFixed(1) + 'px') // < 0 would break the mask
let best = -1, bd = r * near
if (pow > 0.5) spots.forEach((s, i) => { const [sx, sy] = toFrame(s), d = Math.hypot(sx - x, sy - y); if (d < bd) { bd = d; best = i } })
pins.forEach((el, i) => el.toggleAttribute('data-rv-on', view || i === best))
lens.toggleAttribute('data-rv-lock', best >= 0)
const txt = fmt(read(cur))
if (valueEl && txt !== shown) valueEl.textContent = shown = txt
}
function relayout() {
measure()
const [x, y] = toFrame(tgt)
if (x < 0 || y < 0 || x > G.fw || y > G.fh) { tgt = [...home]; cur = [...home] } // cropped away
pins.forEach((el, i) => { if (spots[i]) { const [sx, sy] = toFrame(spots[i]); el.style.transform = t3(sx, sy) } })
render()
}
const aim = (p: Pt) => { tgt = p; powT = 1; kick() }
const local = (e: PointerEvent) => { const b = root.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top, b.width, b.height] }
const isOff = (t: EventTarget | null) => t instanceof Element && !!t.closest(offSel)
function goHome() { // pointer left: nearest spot
let best = home, bd = Infinity
for (const s of spots) { const d = Math.hypot(s[0] - tgt[0], s[1] - tgt[1]); if (d < bd) { bd = d; best = s } }
mode = 'rest'; aim([...best])
}
function onMouse(e: PointerEvent) {
if (e.pointerType !== 'mouse') return
const [x, y, w, h] = local(e)
if (x < 0 || y < 0 || x > w || y > h) { if (mode === 'track' || mode === 'off') goHome() }
else if (isOff(e.target)) { mode = 'off'; powT = 0; kick() }
else { mode = 'track'; aim(toImage(x, y)) }
}
const onOut = (e: MouseEvent) => { if (!e.relatedTarget && (mode === 'track' || mode === 'off')) goHome() }
// touch/pen: a vertical swipe may become the browser's scroll
function onDown(e: PointerEvent) {
if (e.pointerType !== 'mouse' && !isOff(e.target) && !touch?.drag) touch = { id: e.pointerId, x: e.clientX, y: e.clientY, drag: false }
}
function onDrag(e: PointerEvent) {
if (!touch || e.pointerId !== touch.id) return
if (!touch.drag) {
const dx = Math.abs(e.clientX - touch.x), dy = Math.abs(e.clientY - touch.y)
if (dx < SLOP || dx <= dy) return
touch.drag = true; mode = 'drag'
try { root.setPointerCapture(e.pointerId) } catch { /* gone */ } // only now, so a tap still clicks
}
const [x, y, w, h] = local(e)
aim(toImage(Math.min(w, Math.max(0, x)), Math.min(h, Math.max(0, y))))
}
function onEnd(e: PointerEvent) {
if (!touch || e.pointerId !== touch.id) return
const t = touch; touch = null
if (t.drag) { mode = 'free'; if (e.type === 'pointercancel') { tgt = [...cur]; kick() } } // browser took it: stay put
else if (e.type === 'pointerup' && Math.hypot(e.clientX - t.x, e.clientY - t.y) < SLOP) { mode = 'free'; const [x, y] = local(e); aim(toImage(x, y)) }
}
function onToggle() {
view = !view
toggle!.setAttribute('aria-pressed', String(view))
root.toggleAttribute('data-rv-full', view)
from = full; t0 = performance.now(); kick()
}
const offs: (() => void)[] = []
const on = (t: EventTarget, ev: string, fn: (e: never) => void, h = fn as EventListener) => { t.addEventListener(ev, h); offs.push(() => t.removeEventListener(ev, h)) }
on(document, 'pointermove', onMouse); on(document, 'mouseout', onOut)
on(root, 'pointerdown', onDown); on(root, 'pointermove', onDrag); on(root, 'pointerup', onEnd); on(root, 'pointercancel', onEnd)
on(rq, 'change', (e: MediaQueryListEvent) => { reduce = e.matches; kick() })
if (toggle) on(toggle, 'click', onToggle)
if (mq) on(mq, 'change', relayout)
const ro = new ResizeObserver(relayout)
ro.observe(root)
toggle?.setAttribute('aria-pressed', 'false')
relayout()
root.setAttribute('data-rv-ready', '')
const timer = setTimeout(() => { if (mode === 'rest') aim(tgt) }, reduce ? 0 : data.delay ?? 0)
return () => {
offs.forEach((f) => f())
ro.disconnect(); clearTimeout(timer); cancelAnimationFrame(raf); raf = 0
root.removeAttribute('data-rv-ready'); root.removeAttribute('data-rv-full')
toggle?.setAttribute('aria-pressed', 'false')
lens.removeAttribute('data-rv-lock')
for (const p of ['transform', '--rv-p']) lens.style.removeProperty(p)
for (const p of ['--rv-x', '--rv-y', '--rv-r']) layer.style.removeProperty(p)
pins.forEach((el) => { el.style.removeProperty('transform'); el.removeAttribute('data-rv-on') })
}
}
```
2. **`src/data.ts`, verbatim values** (plus the label texts of 6.6). Coordinates are % of the desktop image; the space in `suffix` is U+00A0; `rest` is spot 02; `offOver` switches the lens off over the copy.
```ts
import type { RevealData } from './reveal'
export const DATA: RevealData = {
image: [2400, 1343],
mobile: { query: '(max-width: 767px)', crop: [47.42, 0, 92.17, 100] },
rest: [69.3, 63.0],
base: -6.4, // sky
zones: [
[0, 69.5, 100, 100, -7.2], // snow
[47.0, 45.0, 66.8, 70.4, 5.6], // left volume walls
[66.8, 40.8, 91.7, 70.4, -7.2], // right volume walls (insulated)
[52.1, 61.4, 59.6, 70.3, 17.4], // entrance: porch, door, heat pump
[63.6, 58.8, 72.75, 68.4, 16.6], // corner glazing
[60.9, 43.8, 61.6, 55.2, 15.1], // flue collar
[67.8, 46.3, 82.4, 54.8, 12.8], // upper glazing
[77.25, 59.4, 82.75, 67.7, 13.6], // terrace doors
],
spots: [[55.55, 65.4], [69.3, 63.0], [61.25, 50.5], [79.0, 51.8], [80.0, 63.6]],
range: [-8, 22], decimals: 1, suffix: ' °C', delay: 640, offOver: '.sw-copy',
}
```
3. **`Frame.tsx`** renders the frame of section 5, the chip with `aria-pressed={false}` fixed, and starts the engine once:
```tsx
const frame = useRef<HTMLDivElement>(null)
useEffect(() => init(frame.current!, DATA), []) // init returns destroy: StrictMode's second mount starts clean
```
The engine writes attributes React does not track: export `Frame` in `memo`, without props or state, and keep `App` stateless, so it never re-renders.
4. **`Nav.tsx`, menu (≤767), `useState`:** opening removes `hidden`, sets `aria-expanded="true"`, `inert` on `<header>` and `#main`, `overflow:hidden` on `<html>`, and focuses the first panel link. Close button, Escape, a panel link or `matchMedia('(min-width: 768px)')` matching undo it all and refocus the Menu button (not after the width change).
5. **Images:** `onError` sets `visibility:hidden` on that `<img>`.
Only the engine writes `--rv-*`, `transform` on lens and spots, `data-rv-*` attributes, `aria-pressed` and `[data-rv-value]`. It also handles touch (tap or horizontal drag moves the lens, a vertical swipe scrolls), reduced motion, size changes and the mobile crop.
## 9. Responsive
| Range | Token overrides | Layout |
|---|---|---|
| ≤767 | section 4 | Stacked; 4:5 images; Menu button only; no secondary link; short plate texts; value tags in Thermal view |
| 768–1099 | `--sw-gutter:24px; --sw-nav-h:64px; --sw-ui:42px; --sw-inset:12px; --sw-arm:20px; --sw-chip-h:30px; --sw-lens:168px; --sw-h1:clamp(64px,8.1vw,88px)` | Stacked; desktop images; 4:3 frame ≤600px high; nav links + CTA |
| ≥1100 | `--sw-gutter:20px; --sw-ui:48px; --sw-inset:14px; --sw-arm:24px; --sw-lens:clamp(200px,16vw,296px); --sw-h1:clamp(64px,5.5vw,120px)` | Overlaid |
| ≥1800 | `--sw-gutter:24px; --sw-nav-h:72px; --sw-ui:56px; --sw-inset:16px; --sw-arm:26px` | Overlaid |
- Token overrides: `:root` inside `@media (min-width: 768px)`, `(min-width: 1100px)`, `(min-width: 1800px)` in `src/index.css`. The rows are cumulative (`min-width` cascade): a token a later row does not override keeps the previous row's value.
- In JSX only Tailwind v4 arbitrary variants: `max-[768px]:` for ≤767 (`max-[N]` excludes N: `max-[767px]:` would miss 767px, where images and engine already switch), `min-[768px]:`, `max-[1100px]:` for below 1100, `min-[1100px]:`, `min-[1800px]:`; never named breakpoints (`sm:`, `md:`). To toggle display, both variants on the element (`max-[768px]:hidden min-[768px]:flex`): a plain display class beside `hidden` beats it.
- Stacked (<1100): `.sw-stage` padding `0 var(--sw-gutter) 24px`; copy, frame and horizontal scale in flow.
- Overlaid (≥1100): `.sw-stage` `min-height:calc(100svh - var(--sw-nav-h))`, padding `0 var(--sw-gutter) var(--sw-gutter)`, `display:grid; align-content:center`; the frame fills `.sw-view` (6.4); copy and vertical scale are absolute inside it.
- Touch: nothing depends on hover (v4 `hover:` only fires on hover devices); the frame gets `[@media(hover:hover)_and_(pointer:fine)]:cursor-none`.
## 10. Accessibility & performance
- One `<h1>`; landmarks `header`, `nav aria-label="Main"`, `main`. `:focus-visible` everywhere; all text ≥ 4.5:1 (muted on snow 6.4:1, ink on accent 5.3:1).
- `.sw-sr` is visually hidden (`position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap`); one `<li>` per spot of 6.6, e.g. "01 Entrance door: 17.4 °C, air leak."
- Lens, spots, corners, plates and scale are `aria-hidden`; the chip is a real `<button>` (Enter, Space) with `aria-pressed`; the menu is a modal dialog.
## 11. Acceptance checklist
- [ ] **First paint:** snow background, nav, eyebrow, lede, CTAs; no flash. **Entrance:** the two headline lines type in (0.06–0.60s), the frame opens top to bottom (0.12–0.60s), at 0.64s the lens powers on over the corner glazing: "Spot 16.6 °C" and label 02.
- [ ] **Lens:** follows the mouse with a short lag; infrared aligned with the photo in a soft circle; the reading changes by zone (sky −6.4 °C, snow −7.2 °C, entrance 17.4 °C); off over the copy; back to the nearest hot spot when the pointer leaves; labels on their fixed sides, leader first.
- [ ] **Thermal view:** click, Enter, Space or tap → the infrared grows from the lens to the whole frame in 0.42s; the ring fades (reticle and reading stay); five labels without overlaps (≤767: value tags); dark chip, `aria-pressed="true"`; from 1100px the copy gets a pale plate. Again → back into the lens.
- [ ] **Touch:** tap or horizontal drag moves the lens; a vertical swipe scrolls and the lens stays.
- [ ] **Responsive:** ≤767 stacked, 4:5 images, menu dialog; 768–1099 stacked, 4:3 frame; ≥1100 copy over the sky, vertical scale on the right; no horizontal scroll at 375, 768, 1440, 1920.
- [ ] **Reduced motion:** no entrance, no smoothing, instant Thermal view, everything visible.
- [ ] **Robustness:** images blocked → the frame keeps its size and `#D9DDE1`; zero console errors; only the URLs of sections 2–3; `npm run build` clean.HTML · un archivo472 líneas
# Build: "Stillwarm" — a thermal-camera lens that shows where a house loses heat
## 0. Brief
A one-screen landing page for **Stillwarm**, infrared home audits: pale snow, slate ink, one orange accent on the CTA buttons. A framed photo of a white house alone in a snowy field is dressed as a thermal camera's viewfinder (corner marks, data plates, a −8 → 22 °C scale). The "wow": the pointer *is* the camera, a circular lens with a spot-meter reticle that reveals the same photo in false-colour infrared, aligned pixel to pixel, with a live °C reading and report labels on five hot spots. "Thermal view" opens the infrared over the whole frame with all five findings. Short, fast-out motion; monospace data.
## 1. Output & hard constraints
- ONE self-contained `index.html`: one inline `<style>`, one inline `<script>` at the end of `<body>`, no build step. External libraries: none. The only external files are the fonts (section 3) and the 8 images (section 2).
- `<html lang="en">`. `<head>`: charset utf-8, `viewport` `width=device-width, initial-scale=1`, title `Stillwarm — See where your heat escapes`, description "Thermal home audits that show exactly where your heat escapes.", `theme-color` `#EEF1F2`, the font links of section 3 and `<link rel="icon" href="data:,">` (no favicon request).
- Page type: from 1100px, one screen (the frame fills the viewport under the nav; it only scrolls if the window is shorter than nav + 480px + gutter). Below 1100px, a short scrolling page: copy, frame, colour scale.
- Build exactly what this prompt describes. Do NOT add:
- an automatic tour of the hot spots (anywhere) or a play/pause control;
- Tab stops on the hot spots (keyboard and touch access is "Thermal view" plus the screen-reader list);
- labels that move to avoid each other or the edges (each keeps its side, 6.6);
- more zones or spots than the data of section 8 (8 zones, 5 spots);
- a third layout: exactly two, stacked below 1100px and overlaid from 1100px;
- scrambled or animated digits in the reading, or an indicator that runs along the scale;
- other sections, a footer, pricing, testimonials, forms, tooltips, a cursor outside the frame, canvas, WebGL or pixel sampling (every temperature comes from the zone table), animation libraries, icon fonts.
- Keep the links `href="#audits"`, `"#method"`, `"#report"`, `"#book"`; do not build those sections.
- Do NOT substitute, rename or invent asset URLs: section 2, verbatim.
- Anti-flash: the FIRST CSS rule is `html,body{background:#EEF1F2!important;color:#1D2428}`, and `<body style="background:#EEF1F2;color:#1D2428">`.
- Stylesheet order: section 4 → section 9 token overrides → section 6 components → the state block (6.6) → section 7, with the reduced-motion block last.
- Zero console errors; works from `file://` and any host.
## 2. Assets (exact URLs — the only images)
| ID | Type | URL | Size | Role |
|---|---|---|---|---|
| BASE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/base.webp` | 2400×1343 | Photo (≥768) |
| BASE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/base.jpg` | 2400×1343 | Fallback `<img src>` |
| THERMAL_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/thermal.webp` | 2400×1343 | Infrared, registered on BASE pixel to pixel |
| THERMAL_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/thermal.jpg` | 2400×1343 | Fallback |
| BASE_MOBILE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/base-mobile.webp` | 1080×1350 | Photo ≤767: 4:5 crop x 47.42 → 92.17% |
| BASE_MOBILE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/base-mobile.jpg` | 1080×1350 | Fallback |
| THERMAL_MOBILE_WEBP | image/webp | `https://cdn.orbital.supply/exp-0001/thermal-mobile.webp` | 1080×1350 | Infrared ≤767, same crop |
| THERMAL_MOBILE_JPG | image/jpeg | `https://cdn.orbital.supply/exp-0001/thermal-mobile.jpg` | 1080×1350 | Fallback |
Rules:
- Each layer is one `<picture>`, in this order: `<source media="(max-width: 767px)" type="image/webp">` (mobile WebP), `<source media="(max-width: 767px)">` (mobile JPEG), `<source type="image/webp">` (desktop WebP), `<img src>` (desktop JPEG). The first match wins; `(max-width: 767px)` is also the engine's query.
- **The two layers must stay aligned:** same box (the whole frame), `object-fit:cover` and `object-position` at every width (6.4). Any difference on one layer only (object-position, max-width, border, padding, transform, filter) shifts the infrared image against the photo.
- Base `<img>`: `width="2400" height="1343" alt="A white two-storey house alone in a snowy field under an overcast sky" fetchpriority="high" decoding="async"`. Thermal: same size attributes, `alt="" aria-hidden="true"`.
- An image that fires `error` gets `visibility:hidden`: the frame keeps its size and its `#D9DDE1` background. Icons are inline SVG.
## 3. Typography
```
https://fonts.googleapis.com/css2?family=Big+Shoulders:opsz,wght@10..72,500..800&family=IBM+Plex+Mono:wght@400;500&display=swap
```
Preconnect to `https://fonts.googleapis.com` and `https://fonts.gstatic.com` (`crossorigin`), then the stylesheet with this exact URL.
- The display family is **"Big Shoulders"** (variable: `opsz` 10–72, `wght` 500–800). Do NOT "correct" it to "Big Shoulders Display": that family left the catalogue, the API drops it silently and all display text falls back to Arial Narrow. Keep `font-optical-sizing:auto`; weights 760 and 720 are intentional.
- Neither family has Greek glyphs: never write "ε". Write `Emissivity` (short form `E`).
- Characters: U+2212 `−` in negative numbers (never a hyphen), `°`, `·`, and U+00A0 between a number and `°C` (` `).
- Body: mono 15px/1.5, antialiased.
| Role | Font · size / line-height · weight | Tracking · case · colour |
|---|---|---|
| Wordmark "Stillwarm" | Display 24px/1 760 | .04em · uppercase · ink |
| H1 | Display `--sw-h1`/.9 760 | ink |
| Eyebrow | Mono 11px (≤767) · 12px (≥768) /1.5 400 | .14em · uppercase · muted |
| Lede | Mono 13.5px/21px (≤767) · 15px/24px (≥768) 400 | muted |
| Nav links · nav CTA, Menu/Close | Mono 13px 400 · 500 | muted (ink on hover) · as 6.1 |
| Primary CTA · secondary link | Mono 14px 500 · 400 | ink |
| Chip · data plates | Mono 11px 500 | .1em · uppercase · ink |
| Label line 1 · kind | Mono 10px/13px 500 · 400 | .12em · uppercase · on-ink · on-ink-2 |
| Label value | Display 22px/1 700 (15px as a tag) | paper |
| Lens readout | Mono 12px/22px 400, value 500 | on-ink-2, value paper |
| Scale unit · ticks | Mono 11px 500 · 10px/12px 400 | ink · muted |
## 4. Design tokens (mobile first; section 9 overrides the layout ones)
```css
:root{
/* palette: snow page, slate ink, one accent (CTA buttons only) */
--sw-snow:#EEF1F2; --sw-paper:#F4F6F7; --sw-ink:#1D2428; --sw-ink-2:#2B343A;
--sw-muted:#4F5A61; --sw-line:#C9D1D5; --sw-on-ink:#C9D1D5; --sw-on-ink-2:#AEB8BE;
--sw-accent:#F2641D; --sw-accent-hi:#F4733A; --sw-plate:rgba(244,246,247,.9); --sw-frame:#D9DDE1;
/* thermal ramp, cold → hot, sampled from the thermal image; stop position = (°C + 8) / 30 */
--sw-ramp:#325182 0%,#3F5BBE 2.3%,#706BCA 4%,#936DC2 6.7%,#B15DD0 16.7%,#BE01CD 30%,#D000B8 40%,#EC0377 51.7%,#DE0E44 60%,#F1440B 66.7%,#FA5701 70%,#FC9306 76.7%,#FEB703 83.3%,#FDD405 90%,#FDE212 95%,#FBFD83 100%;
--sw-font-d:"Big Shoulders","Arial Narrow",sans-serif;
--sw-font-m:"IBM Plex Mono",ui-monospace,monospace;
--sw-ease:cubic-bezier(.24,.7,.3,1); /* fast out, almost linear */
--sw-sweep:cubic-bezier(.42,.12,.32,1);
/* layout, up to 767px */
--sw-gutter:16px; --sw-nav-h:56px; --sw-ui:34px; --sw-inset:10px; --sw-arm:18px; --sw-chip-h:28px;
--sw-lens:124px; --sw-h1:clamp(44px,14.6vw,64px);
}
```
`--sw-ui`: horizontal inset of chip, plates and overlaid copy · `--sw-inset`: distance of corners and plates from the frame edge · `--sw-arm`: corner-mark arm · `--sw-lens`: lens diameter (the engine reads the radius from it). Base reset: border-box, no margins, links inherit colour with no underline, buttons reset, `img,svg,picture{display:block}`, `:focus-visible{outline:2px solid var(--sw-ink);outline-offset:3px}`.
## 5. Layer stack & DOM
**Z-order in the frame** (`isolation:isolate`): photo 0 → thermal layer 1 → lens 2 → hot spots 3 → corners, chip, data plates 4. Copy and scale are siblings of the frame; overlaid (≥1100) they sit above it at z 5.
**Markup** (classes, ids and data attributes are load-bearing for the engine and for sections 6–7):
```html
<body style="background:#EEF1F2;color:#1D2428">
<header class="sw-nav" id="top">
<a class="sw-logo" href="#top" aria-label="Stillwarm, home"><svg>…6.1…</svg><span>Stillwarm</span></a>
<nav class="sw-links" aria-label="Main"><a href="#audits">Audits</a><a href="#method">Method</a><a href="#report">Sample report</a></nav>
<a class="sw-navcta" href="#book">Book an audit <svg>…arrow…</svg></a>
<button class="sw-menubtn" id="sw-menu-btn" type="button" aria-expanded="false" aria-controls="sw-menu">Menu</button>
</header>
<main class="sw-stage" id="main">
<div class="sw-view">
<div class="sw-copy">
<p class="sw-eyebrow">Infrared home audits · Winter 2026</p>
<h1><span class="sw-l">See where your</span> <span class="sw-l">heat escapes.</span></h1>
<p class="sw-lede">We scan every wall, window and seam at dawn, then put a price on every leak.</p>
<div class="sw-ctas"><a class="sw-cta" href="#book">Book a thermal audit <svg>…arrow…</svg></a><a class="sw-more" href="#method">How a scan works</a></div>
</div>
<div class="sw-frame" id="sw-frame">
<picture>…BASE sources…<img class="sw-img" data-rv-base …></picture>
<picture>…THERMAL sources…<img class="sw-img" data-rv-layer …></picture>
<div class="sw-lens" data-rv-lens aria-hidden="true"><div class="sw-lens-in">
<svg class="sw-reticle">…6.5…</svg>
<p class="sw-readout"><span>Spot</span> <b data-rv-value>16.6 °C</b></p>
</div></div>
<!-- 5 spots in order; data-n="2"…"5" repeat this (texts in 6.6) -->
<div class="sw-spot" data-rv-spot data-n="1" aria-hidden="true">
<i class="sw-lead"></i><i class="sw-mark"></i>
<span class="sw-label"><span class="sw-lab-id">01 · Entrance door</span><span class="sw-lab-row"><b class="sw-lab-val">17.4 °C</b><span class="sw-lab-kind">Air leak</span></span></span>
</div>
<i class="sw-corner sw-tl"></i><i class="sw-corner sw-tr"></i><i class="sw-corner sw-bl"></i><i class="sw-corner sw-br"></i>
<button class="sw-chip" type="button" data-rv-toggle aria-pressed="false"><svg>…half disc…</svg>Thermal view</button>
<p class="sw-meta sw-meta-l" aria-hidden="true"><span class="sw-long">Emissivity</span><span class="sw-short">E</span> <b>0.93</b> · <b>07:42</b></p>
<p class="sw-meta sw-meta-r" aria-hidden="true"><span class="sw-long">Outside</span><span class="sw-short">Out</span> <b>−7.1 °C</b></p>
</div>
<div class="sw-scale" aria-hidden="true"><span class="sw-unit">°C</span><i class="sw-bar"></i><span class="sw-ticks"><span>22</span><span>16</span><span>10</span><span>4</span><span>−2</span><span>−8</span></span></div>
</div>
<h2 class="sw-sr">Hot spots found in this scan</h2>
<ol class="sw-sr">…section 10…</ol>
</main>
<div class="sw-menu" id="sw-menu" role="dialog" aria-modal="true" aria-label="Menu" hidden>…6.2…</div>
<script>…section 8…</script>
</body>
```
## 6. Components
### 6.1 Nav
- `.sw-nav`: flex, centred vertically, height `var(--sw-nav-h)`, padding `0 var(--sw-gutter)`; static.
- `.sw-logo`: inline-flex, centred, gap 12px, ink. Mark: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="7"/><path d="M12 2v5M12 17v5M2 12h5M17 12h5"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg>`, then the wordmark.
- `.sw-links` (≥768 only): flex, gap 28px, margin-left 40px.
- `.sw-navcta` (≥768 only): margin-left auto; inline-flex, centred, gap 10px; height 44px; padding `0 16px`; radius 3px; ink background, paper text. Hover: ink-2, arrow `translate:2px 0`.
- Arrow (16×16, also used by the CTAs): `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M12 5l7 7-7 7"/></svg>`.
- `.sw-menubtn` (≤767 only): margin-left auto; height 40px; padding `0 14px`; border 1px solid ink; radius 4px; transparent background.
### 6.2 Mobile menu (a dialog, ≤767 only)
- `.sw-menu`: `position:fixed; inset:0; z-index:50`; snow; flex column; padding `0 16px 24px`; plus `.sw-menu[hidden]{display:none}` (an author `display:flex` beats the browser's `[hidden]` rule: without it the closed panel covers the page). It opens by removing `hidden`, never with a `visibility` transition (it would not be focusable yet).
- Top row, 56px, `space-between`: a logo copy (`<span class="sw-logo" aria-hidden="true">`) and `<button class="sw-menubtn" id="sw-menu-close" type="button">Close</button>`.
- `<ol>`, margin-top 20px, 1px line under each `<li>`; each link: flex, baseline, gap 16px, padding `14px 0 12px`, index `01`–`03` (mono 12px muted) + name (Audits, Method, Sample report; display 40px/1 720), nav hrefs.
- Bottom (`margin-top:auto`): the primary CTA, full width, 48px high.
### 6.3 Copy
- Keep the class `.sw-copy` (the engine switches the lens off over it). Each `.sw-l` is `display:block; width:max-content; max-width:100%` (the break is always after "your"; `max-content` makes the stepped wipe advance one step per character across the text, not across the column). `.sw-lede`: max-width 46ch from 768px. `.sw-ctas`: flex, `align-items:center` (vertical only: the CTAs stay left-aligned), gap 24px.
- `.sw-cta` (primary): inline-flex, centred, gap 12px; height 50px; padding `0 20px`; radius 3px; accent background, ink text. Hover: accent-hi, arrow `translate:2px 0`.
- `.sw-more`: ink; 1px underline, offset 6px, colour `rgba(29,36,40,.4)`, ink on hover.
- Rhythm ≤767: padding-top 10px, eyebrow margin-bottom 14px, lede margin-top 14px, `.sw-ctas` margin-top 18px, CTA full width and 48px high, `.sw-more` hidden. From 768: 12px, 18px, 18px, 26px.
- Overlaid (≥1100): `position:absolute; z-index:5; left:calc(var(--sw-ui) - 28px); top:calc(var(--sw-ui) + 6px); width:max-content; padding:24px 28px 28px; border-radius:4px`. The padding is the lens-off margin and the Thermal view plate (6.6).
### 6.4 Frame and image layers
- `.sw-view` (wraps copy, frame and scale): `position:relative`; overlaid (≥1100) `height:max(480px, min(calc(100svh - var(--sw-nav-h) - var(--sw-gutter)), 58vw))`.
- `.sw-frame` (the engine root): `position:relative; overflow:hidden; isolation:isolate; border-radius:6px; background:var(--sw-frame); touch-action:pan-y pinch-zoom; user-select:none; -webkit-tap-highlight-color:transparent`; `cursor:none` under `(hover:hover) and (pointer:fine)` (the lens is the cursor; the chip keeps `pointer`).
- ≤767: `width:100%; aspect-ratio:4/5; margin-top:20px`.
- 768–1099: `width:100%; aspect-ratio:4/3; max-height:600px; margin-top:24px`. Keep `width:100%`: with `aspect-ratio` plus `max-height`, Chrome otherwise shrinks the width too.
- ≥1100: `width:100%; height:100%`.
- `.sw-img` (one rule for both layers): `position:absolute; inset:0; width:100%; height:100%; max-width:none; object-fit:cover; pointer-events:none`; `object-position` 50% 50% (≤767), 75% 50% (768–1099), 0% 40% (≥1100). Photo z 0, thermal z 1; nothing else may differ.
### 6.5 Lens
- `.sw-lens`: `position:absolute; left:0; top:0; z-index:2; width:var(--sw-lens); height:var(--sw-lens); pointer-events:none; opacity:var(--rv-p,0); will-change:transform,opacity`; no transition (the engine drives `transform` and `--rv-p`).
- The power-on `scale` lives on `.sw-lens-in` (6.6), never on `.sw-lens`: there it would apply after the engine's `transform` and drag the lens off the pointer.
- Reticle, exact (every stroke 1.5px at any lens size):
```html
<svg class="sw-reticle" viewBox="0 0 100 100" aria-hidden="true"><g class="sw-ring"><circle cx="50" cy="50" r="49.5" stroke="rgba(29,36,40,.3)" stroke-width="3"/><circle cx="50" cy="50" r="49.5"/><path d="M50 .5V8M99.5 50H92M50 99.5V92M.5 50H8M74.75 7.13 73.25 9.73M92.87 25.25 90.27 26.75M92.87 74.75 90.27 73.25M74.75 92.87 73.25 90.27M25.25 92.87 26.75 90.27M7.13 74.75 9.73 73.25M7.13 25.25 9.73 26.75M25.25 7.13 26.75 9.73"/></g><path class="sw-brackets" d="M32 38V32H38M62 32H68V38M68 62V68H62M38 68H32V62"/><path d="M50 39V46M50 54V61M39 50H46M54 50H61"/><circle cx="50" cy="50" r="2.2" fill="#F4F6F7" stroke="rgba(29,36,40,.5)" stroke-width="1"/></svg>
```
`.sw-reticle{position:absolute;inset:0;width:100%;height:100%;overflow:visible;fill:none;stroke:rgba(244,246,247,.92);stroke-width:1.5}` and `.sw-reticle *{vector-effect:non-scaling-stroke}`.
- `.sw-readout`: `position:absolute; top:calc(100% - 8px); left:50%; translate:-50% 0; width:calc(12ch + 16px); height:22px; line-height:22px; text-align:center; white-space:nowrap; border-radius:3px; background:rgba(29,36,40,.92)`; the fixed width keeps it still when digits change.
### 6.6 Hot spots and labels
- `.sw-spot`: `position:absolute; left:0; top:0; width:0; height:0; z-index:3; pointer-events:none`.
- `.sw-mark`: 14px ring centred on the point, `border:1.5px solid rgba(244,246,247,.95)`, `box-shadow:0 0 0 1px rgba(29,36,40,.35), inset 0 0 0 1px rgba(29,36,40,.35)`, 3px paper dot in the centre.
- `.sw-lead`: ink, `box-shadow:0 0 0 .5px rgba(244,246,247,.55)`.
- `.sw-label`: `background:rgba(29,36,40,.94); border-radius:3px; padding:7px 10px 8px; white-space:nowrap`; `.sw-lab-id` block; `.sw-lab-row` flex, baseline, gap 8px, margin-top 3px.
| n | Line 1 | Value | Kind |
|---|---|---|---|
| 1 | 01 · Entrance door | 17.4 °C | Air leak |
| 2 | 02 · Corner glazing | 16.6 °C | Heat loss |
| 3 | 03 · Flue penetration | 15.1 °C | Thermal bridge |
| 4 | 04 · Upper glazing | 12.8 °C | Heat loss |
| 5 | 05 · Terrace doors | 13.6 °C | Air leak |
**State block (exact, after the component rules).** The sides were checked so that no label overlaps another, the copy, the chip, the scale or the plates from 375 to 2560px. One formula serves every side; the leader ends 12px outside the lens ring.
```css
/* engine contract: the thermal layer only shows inside the lens mask */
[data-rv-layer]{--rv-edge:12px;-webkit-mask-image:radial-gradient(circle var(--rv-r,0px) at var(--rv-x,50%) var(--rv-y,50%),#000 calc(var(--rv-r,0px) - var(--rv-edge,12px)),transparent var(--rv-r,0px));mask-image:radial-gradient(circle var(--rv-r,0px) at var(--rv-x,50%) var(--rv-y,50%),#000 calc(var(--rv-r,0px) - var(--rv-edge,12px)),transparent var(--rv-r,0px))}
[data-rv-spot]{visibility:hidden}
[data-rv-ready] [data-rv-spot]{visibility:visible}
/* label geometry · side = (--sw-sx, --sw-sy): u (0,-1) · ur (1,-1) · ul (-1,-1) · dl (-1,1) */
.sw-spot{--sw-sx:0;--sw-sy:-1;--sw-k:1;--sw-L:calc(var(--sw-k) * (var(--sw-lens) / 2 + 12px))}
.sw-lead{position:absolute;left:0;top:-.75px;width:var(--sw-L);height:1.5px;transform-origin:0 50%;rotate:calc(var(--sw-sy) * (90deg - var(--sw-sx) * 45deg))}
.sw-label{position:absolute;left:calc(var(--sw-sx) * var(--sw-L) * .7071);top:calc(var(--sw-sy) * var(--sw-L) * (1 - .2929 * var(--sw-sx) * var(--sw-sx)));translate:calc((var(--sw-sx) - 1) * 50%) calc((var(--sw-sy) - 1) * 50%);transform-origin:calc(50% - var(--sw-sx) * 50%) calc(50% - var(--sw-sy) * 50%)}
.sw-spot[data-n="1"]{--sw-sx:1}
.sw-spot[data-n="5"]{--sw-sx:-1;--sw-sy:1}
@media (min-width:768px) and (max-width:1099px){.sw-spot[data-n="1"]{--sw-sx:-1;--sw-sy:1}.sw-spot[data-n="3"]{--sw-sx:-1}}
@media (min-width:1100px){[data-rv-full] .sw-spot{--sw-k:.75}[data-rv-full] .sw-spot[data-n="1"]{--sw-sx:-1;--sw-sy:1}}
@media (max-width:767px){[data-rv-full] .sw-spot{--sw-L:14px}[data-rv-full] :is(.sw-lab-id,.sw-lab-kind){display:none}[data-rv-full] .sw-label{padding:3px 6px}[data-rv-full] .sw-lab-row{margin:0}[data-rv-full] .sw-lab-val{font-size:15px}}
/* show and hide: the engine sets [data-rv-on] on the spot whose label must show (all five in Thermal view) */
.sw-lead,.sw-label{opacity:0;transition:opacity .12s var(--sw-ease),scale .12s var(--sw-ease)}
.sw-lead{scale:0 1}
.sw-label{scale:.96}
[data-rv-on] .sw-lead{opacity:1;scale:1;transition-duration:.14s}
[data-rv-on] .sw-label{opacity:1;scale:1;transition-duration:.16s;transition-delay:.06s}
/* lens power, lock and Thermal view */
.sw-lens-in{position:absolute;inset:0;scale:calc(.84 + .16 * var(--rv-p,0))}
.sw-ring{transition:opacity .2s var(--sw-ease)}
[data-rv-full] .sw-ring{opacity:0}
.sw-brackets{transform-box:view-box;transform-origin:50% 50%;transition:scale .16s var(--sw-ease)}
[data-rv-lock] .sw-brackets{scale:.84}
@media (min-width:1100px){.sw-copy{transition:background-color .2s var(--sw-ease)}.sw-view:has([data-rv-full]) .sw-copy{background:var(--sw-plate)}}
```
### 6.7 Frame furniture: corner marks, "Thermal view" chip, data plates
- `.sw-corner`: `position:absolute; z-index:4; width:var(--sw-arm); height:var(--sw-arm); pointer-events:none`; 2px `rgba(29,36,40,.85)` borders on the two outer sides, `var(--sw-inset)` from the nearest edges (`.sw-tl`, `.sw-tr`, `.sw-bl`, `.sw-br`).
- `.sw-chip`: `position:absolute; z-index:4; top:var(--sw-inset); right:var(--sw-ui)`; inline-flex, centred, gap 8px; height `var(--sw-chip-h)`; padding `0 10px`; radius 3px; plate background, ink text. Hover: paper. `[aria-pressed="true"]`: ink background, paper text (hover ink-2). `:focus-visible`: `outline:2px solid var(--sw-ink); outline-offset:2px; box-shadow:0 0 0 6px rgba(244,246,247,.9)`. Icon: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="9.5"/><path d="M12 5.5a6.5 6.5 0 0 0 0 13z" fill="currentColor" stroke="none"/></svg>`.
- `.sw-meta`: `position:absolute; z-index:4; bottom:var(--sw-inset)`, left plate at `left:var(--sw-ui)`, right plate at `right:var(--sw-ui)`; block (flex drops the spaces around `·`), `line-height:var(--sw-chip-h)`; padding `0 10px`; radius 3px; plate background; nowrap; words and `·` muted, `<b>` ink 500. `.sw-long` from 768px, `.sw-short` up to 767px.
### 6.8 Colour scale
- `.sw-scale` is a grid with named areas (never auto-placement): `.sw-unit{grid-area:u}`, `.sw-bar{grid-area:b}`, `.sw-ticks{grid-area:t}`. The bar: radius 2px, `linear-gradient(to right, var(--sw-ramp))` horizontal, `to top` vertical.
- Horizontal, below 1100px (after the frame; margin-top 14px ≤767, 16px at 768–1099): `grid-template:"u b" auto ". t" auto / auto 1fr; gap:4px 12px; align-items:center`; bar 8px high; ticks flex `row-reverse`, `space-between` (−8 left, 22 right).
- Vertical, from 1100px: `position:absolute; z-index:5; right:var(--sw-inset); top:calc(var(--sw-inset) + var(--sw-chip-h) + 8px); width:60px; height:min(40%, 340px); padding:10px 12px 12px; border-radius:3px; background:var(--sw-plate); grid-template:"u u" auto "t b" 1fr / 1fr 8px; gap:8px 6px`; unit centred; ticks flex column, `space-between`, right-aligned (22 top, −8 bottom). Reset what the horizontal layout set: `align-items:stretch; margin-top:0` and the bar `height:auto`.
## 7. Motion
MP-CRISP (short, fast-out, almost linear). Entrance only for the headline (stepped wipe, one step per character) and the frame (opens top to bottom); the engine powers the lens on at 640ms; the menu drops in on each opening and closes instantly. Exact:
```css
@keyframes sw-type{from{clip-path:inset(-20% 100% -30% 0)}to{clip-path:inset(-20% 0 -30% 0)}}
@keyframes sw-scan{from{clip-path:inset(0 0 100% 0 round 6px)}to{clip-path:inset(0 round 6px)}}
@keyframes sw-menu-in{from{opacity:0;translate:0 -8px}}
.sw-l:first-child{animation:sw-type .28s steps(14,end) .06s backwards}
.sw-l:last-child{animation:sw-type .26s steps(13,end) .34s backwards}
.sw-frame{animation:sw-scan .48s var(--sw-sweep) .12s backwards}
.sw-menu:not([hidden]){animation:sw-menu-in .18s var(--sw-ease) backwards}
```
`backwards`, never `both`: nothing stays applied afterwards to fight hovers or states. The `sw-` prefix avoids collisions with framework animation names.
Interaction: the lens moves only through the engine (follow `lag` 55ms, power 0.8 × lag, Thermal view 420ms cubic ease-out); no CSS transition on the lens, `transform` or `--rv-*`. Labels, brackets, ring and copy plate use the state block (6.6). Buttons and chip: background 160ms, arrows `translate` 160ms, `var(--sw-ease)`.
Reduced motion (last rule; the engine makes itself instant):
```css
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
```
## 8. Behaviour (JS)
One inline `<script>` at the end of `<body>`, in this order:
1. **Engine core, verbatim.** Do not edit, reformat or "improve" it: its guards (progress floored at 0, touch slop, sleeping frame loop) fix real bugs.
```js
/* EXP-REVEAL core v1 · initReveal(root, data) → destroy() */
function initReveal(root, data) {
const [W, H] = data.image, SLOP = 8;
const soft = data.soft ?? 10, near = data.near ?? 0.55, lag = data.lag ?? 55, FULL = data.fullMs ?? 420;
const dec = data.decimals ?? 1, suffix = data.suffix ?? '', [lo, hi] = data.range ?? [-1e9, 1e9];
const q = (s) => root.querySelector(s);
const img = q('[data-rv-base]'), layer = q('[data-rv-layer]'), lens = q('[data-rv-lens]');
const valueEl = q('[data-rv-value]'), toggle = q('[data-rv-toggle]');
const pins = [...root.querySelectorAll('[data-rv-spot]')];
const px = ([x, y]) => [(x * W) / 100, (y * H) / 100]; // % of the desktop image → its px
const spots = (data.spots ?? []).map(px), home = px(data.rest);
const zones = data.zones.map(([x0, y0, x1, y1, v]) => [...px([x0, y0]), ...px([x1, y1]), v]);
const offSel = '[data-rv-toggle]' + (data.offOver ? ',' + data.offOver : '');
const rq = matchMedia('(prefers-reduced-motion: reduce)');
const mq = data.mobile ? matchMedia(data.mobile.query) : null;
let reduce = rq.matches, view = false, touch = null;
let cur = [...home], tgt = [...home], pow = 0, powT = 0, full = 0, from = 0, t0 = 0;
let mode = 'rest', shown = '', raf = 0, last = 0;
// cover: one scale; object-position from the CSS; mobile = data.mobile.crop
const G = { fw: 1, fh: 1, s: 1, ox: 0, oy: 0, r: 1 };
function measure() {
const c = mq?.matches ? data.mobile.crop : [0, 0, 100, 100];
const [cx, cy] = px([c[0], c[1]]), [cw, ch] = px([c[2] - c[0], c[3] - c[1]]);
const op = getComputedStyle(img).objectPosition.split(' ').map(parseFloat);
G.fw = root.clientWidth; G.fh = root.clientHeight; G.r = lens.offsetWidth / 2;
G.s = Math.max(G.fw / cw, G.fh / ch);
G.ox = ((G.fw - cw * G.s) * (op[0] || 0)) / 100 - cx * G.s;
G.oy = ((G.fh - ch * G.s) * (op[1] || 0)) / 100 - cy * G.s;
}
const toFrame = ([X, Y]) => [G.ox + X * G.s, G.oy + Y * G.s];
const t3 = (x, y) => `translate3d(${x.toFixed(1)}px,${y.toFixed(1)}px,0)`;
const toImage = (x, y) => [(x - G.ox) / G.s, (y - G.oy) / G.s];
// exact inside a zone, blended out over `soft` px; later zones win
function read([X, Y]) {
let v = data.base;
for (const [x0, y0, x1, y1, t] of zones) {
const d = Math.hypot(Math.max(x0 - X, 0, X - x1), Math.max(y0 - Y, 0, Y - y1));
if (d < soft) { const k = d / soft; v += (t - v) * (1 - k * k * (3 - 2 * k)); }
}
return Math.min(hi, Math.max(lo, v));
}
const fmt = (v) => { const n = +v.toFixed(dec); return (n < 0 ? '−' : '') + Math.abs(n).toFixed(dec) + suffix; };
const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };
function tick(now) {
const dt = Math.max(0, Math.min(64, now - last)), goal = view ? 1 : 0;
last = now;
if (reduce) { cur = [...tgt]; pow = powT; full = goal; }
else {
const a = 1 - Math.exp(-dt / lag), b = 1 - Math.exp(-dt / (lag * 0.8));
cur = [cur[0] + (tgt[0] - cur[0]) * a, cur[1] + (tgt[1] - cur[1]) * a];
pow += (powT - pow) * b;
const k = Math.max(0, Math.min(1, (now - t0) / FULL)); // rAF time can precede the click's performance.now()
full = from + (goal - from) * (1 - (1 - k) ** 3);
}
const busy = Math.abs(tgt[0] - cur[0]) + Math.abs(tgt[1] - cur[1]) > 0.1 || Math.abs(powT - pow) > 0.002 || full !== goal;
if (!busy) { cur = [...tgt]; pow = powT; }
render();
raf = busy ? requestAnimationFrame(tick) : 0;
}
function render() {
const [x, y] = toFrame(cur), r = G.r, base = r * pow;
const far = Math.hypot(Math.max(x, G.fw - x), Math.max(y, G.fh - y)) + 24;
lens.style.transform = t3(x - r, y - r);
lens.style.setProperty('--rv-p', pow.toFixed(3));
layer.style.setProperty('--rv-x', x.toFixed(1) + 'px');
layer.style.setProperty('--rv-y', y.toFixed(1) + 'px');
layer.style.setProperty('--rv-r', Math.max(0, base + (far - base) * full).toFixed(1) + 'px'); // < 0 would break the mask
let best = -1, bd = r * near;
if (pow > 0.5) spots.forEach((s, i) => { const [sx, sy] = toFrame(s), d = Math.hypot(sx - x, sy - y); if (d < bd) { bd = d; best = i; } });
pins.forEach((el, i) => el.toggleAttribute('data-rv-on', view || i === best));
lens.toggleAttribute('data-rv-lock', best >= 0);
const txt = fmt(read(cur));
if (valueEl && txt !== shown) valueEl.textContent = shown = txt;
}
function relayout() {
measure();
const [x, y] = toFrame(tgt);
if (x < 0 || y < 0 || x > G.fw || y > G.fh) { tgt = [...home]; cur = [...home]; } // cropped away
pins.forEach((el, i) => { if (spots[i]) { const [sx, sy] = toFrame(spots[i]); el.style.transform = t3(sx, sy); } });
render();
}
const aim = (p) => { tgt = p; powT = 1; kick(); };
const local = (e) => { const b = root.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top, b.width, b.height]; };
const isOff = (t) => t instanceof Element && !!t.closest(offSel);
function goHome() { // pointer left: nearest spot
let best = home, bd = Infinity;
for (const s of spots) { const d = Math.hypot(s[0] - tgt[0], s[1] - tgt[1]); if (d < bd) { bd = d; best = s; } }
mode = 'rest'; aim([...best]);
}
function onMouse(e) {
if (e.pointerType !== 'mouse') return;
const [x, y, w, h] = local(e);
if (x < 0 || y < 0 || x > w || y > h) { if (mode === 'track' || mode === 'off') goHome(); }
else if (isOff(e.target)) { mode = 'off'; powT = 0; kick(); }
else { mode = 'track'; aim(toImage(x, y)); }
}
const onOut = (e) => { if (!e.relatedTarget && (mode === 'track' || mode === 'off')) goHome(); };
// touch/pen: a vertical swipe may become the browser's scroll
function onDown(e) {
if (e.pointerType !== 'mouse' && !isOff(e.target) && !touch?.drag) touch = { id: e.pointerId, x: e.clientX, y: e.clientY, drag: false };
}
function onDrag(e) {
if (!touch || e.pointerId !== touch.id) return;
if (!touch.drag) {
const dx = Math.abs(e.clientX - touch.x), dy = Math.abs(e.clientY - touch.y);
if (dx < SLOP || dx <= dy) return;
touch.drag = true; mode = 'drag';
try { root.setPointerCapture(e.pointerId); } catch {} // only now, so a tap still clicks
}
const [x, y, w, h] = local(e);
aim(toImage(Math.min(w, Math.max(0, x)), Math.min(h, Math.max(0, y))));
}
function onEnd(e) {
if (!touch || e.pointerId !== touch.id) return;
const t = touch; touch = null;
if (t.drag) { mode = 'free'; if (e.type === 'pointercancel') { tgt = [...cur]; kick(); } } // browser took it: stay put
else if (e.type === 'pointerup' && Math.hypot(e.clientX - t.x, e.clientY - t.y) < SLOP) { mode = 'free'; const [x, y] = local(e); aim(toImage(x, y)); }
}
function onToggle() {
view = !view;
toggle.setAttribute('aria-pressed', String(view));
root.toggleAttribute('data-rv-full', view);
from = full; t0 = performance.now(); kick();
}
const offs = [];
const on = (t, ev, fn) => { t.addEventListener(ev, fn); offs.push(() => t.removeEventListener(ev, fn)); };
on(document, 'pointermove', onMouse); on(document, 'mouseout', onOut);
on(root, 'pointerdown', onDown); on(root, 'pointermove', onDrag); on(root, 'pointerup', onEnd); on(root, 'pointercancel', onEnd);
on(rq, 'change', (e) => { reduce = e.matches; kick(); });
if (toggle) on(toggle, 'click', onToggle);
if (mq) on(mq, 'change', relayout);
const ro = new ResizeObserver(relayout);
ro.observe(root);
toggle?.setAttribute('aria-pressed', 'false');
relayout();
root.setAttribute('data-rv-ready', '');
const timer = setTimeout(() => { if (mode === 'rest') aim(tgt); }, reduce ? 0 : data.delay ?? 0);
return function destroy() {
offs.forEach((f) => f());
ro.disconnect(); clearTimeout(timer); cancelAnimationFrame(raf); raf = 0;
root.removeAttribute('data-rv-ready'); root.removeAttribute('data-rv-full');
toggle?.setAttribute('aria-pressed', 'false');
lens.removeAttribute('data-rv-lock');
for (const p of ['transform', '--rv-p']) lens.style.removeProperty(p);
for (const p of ['--rv-x', '--rv-y', '--rv-r']) layer.style.removeProperty(p);
pins.forEach((el) => { el.style.removeProperty('transform'); el.removeAttribute('data-rv-on'); });
};
}
```
2. **Data, verbatim.** Coordinates are % of the desktop image; the space in `suffix` is U+00A0; `rest` is spot 02; `offOver` switches the lens off over the copy.
```js
const DATA = {
image: [2400, 1343],
mobile: { query: '(max-width: 767px)', crop: [47.42, 0, 92.17, 100] },
rest: [69.3, 63.0],
base: -6.4, // sky
zones: [
[0, 69.5, 100, 100, -7.2], // snow
[47.0, 45.0, 66.8, 70.4, 5.6], // left volume walls
[66.8, 40.8, 91.7, 70.4, -7.2], // right volume walls (insulated)
[52.1, 61.4, 59.6, 70.3, 17.4], // entrance: porch, door, heat pump
[63.6, 58.8, 72.75, 68.4, 16.6], // corner glazing
[60.9, 43.8, 61.6, 55.2, 15.1], // flue collar
[67.8, 46.3, 82.4, 54.8, 12.8], // upper glazing
[77.25, 59.4, 82.75, 67.7, 13.6], // terrace doors
],
spots: [[55.55, 65.4], [69.3, 63.0], [61.25, 50.5], [79.0, 51.8], [80.0, 63.6]],
range: [-8, 22], decimals: 1, suffix: ' °C', delay: 640, offOver: '.sw-copy',
}
```
3. **Menu (≤767).** `#sw-menu-btn` opens it: remove `hidden` from `#sw-menu`, `aria-expanded="true"`, `inert` on `<header>` and `<main>`, `overflow:hidden` on `<html>`, focus the first panel link. It closes on `#sw-menu-close`, Escape, a click on a panel link (navigation continues) or when `matchMedia('(min-width: 768px)')` starts matching: add `hidden`, remove `inert` and the lock, `aria-expanded="false"`, focus back to `#sw-menu-btn` (except after the width change).
4. **Images.** Each `.sw-img` gets `visibility:hidden` on `error`, and also at start-up if `img.complete && img.naturalWidth === 0` (an image can fail before the listener exists).
5. **Start.** `initReveal(document.getElementById('sw-frame'), DATA);`
Only the engine writes `--rv-*`, `transform` on lens and spots, `data-rv-*` attributes, `aria-pressed` and `[data-rv-value]`. It also handles touch (tap or horizontal drag moves the lens, a vertical swipe scrolls), reduced motion, size changes and the mobile crop.
## 9. Responsive
| Range | Token overrides | Layout |
|---|---|---|
| ≤767 | section 4 | Stacked; 4:5 images; Menu button only; no secondary link; short plate texts; value tags in Thermal view |
| 768–1099 | `--sw-gutter:24px; --sw-nav-h:64px; --sw-ui:42px; --sw-inset:12px; --sw-arm:20px; --sw-chip-h:30px; --sw-lens:168px; --sw-h1:clamp(64px,8.1vw,88px)` | Stacked; desktop images; 4:3 frame ≤600px high; nav links + CTA |
| ≥1100 | `--sw-gutter:20px; --sw-ui:48px; --sw-inset:14px; --sw-arm:24px; --sw-lens:clamp(200px,16vw,296px); --sw-h1:clamp(64px,5.5vw,120px)` | Overlaid |
| ≥1800 | `--sw-gutter:24px; --sw-nav-h:72px; --sw-ui:56px; --sw-inset:16px; --sw-arm:26px` | Overlaid |
- Queries: `(max-width: 767px)`, `(min-width: 768px)`, `(min-width: 1100px)`, `(min-width: 1800px)`. The token rows are cumulative (`min-width` cascade): a token a later row does not override keeps the previous row's value; 767/768 is also the boundary of the `<picture>` sources and of `mobile.query`.
- Stacked (<1100): `.sw-stage` padding `0 var(--sw-gutter) 24px`; copy, frame and horizontal scale in flow.
- Overlaid (≥1100): `.sw-stage` `min-height:calc(100svh - var(--sw-nav-h))`, padding `0 var(--sw-gutter) var(--sw-gutter)`, `display:grid; align-content:center`; the frame fills `.sw-view` (6.4); copy and vertical scale are absolute inside it.
- Touch: nothing depends on hover; hover styles and `cursor:none` only under `(hover:hover) and (pointer:fine)`.
## 10. Accessibility & performance
- One `<h1>`; landmarks `header`, `nav aria-label="Main"`, `main`. `:focus-visible` everywhere; all text ≥ 4.5:1 (muted on snow 6.4:1, ink on accent 5.3:1).
- `.sw-sr` is visually hidden (`position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap`); one `<li>` per spot of 6.6, e.g. "01 Entrance door: 17.4 °C, air leak."
- Lens, spots, corners, plates and scale are `aria-hidden`; the chip is a real `<button>` (Enter, Space) with `aria-pressed`; the menu is a modal dialog.
## 11. Acceptance checklist
- [ ] **First paint:** snow background, nav, eyebrow, lede, CTAs; no flash. **Entrance:** the two headline lines type in (0.06–0.60s), the frame opens top to bottom (0.12–0.60s), at 0.64s the lens powers on over the corner glazing: "Spot 16.6 °C" and label 02.
- [ ] **Lens:** follows the mouse with a short lag; infrared aligned with the photo in a soft circle; the reading changes by zone (sky −6.4 °C, snow −7.2 °C, entrance 17.4 °C); off over the copy; back to the nearest hot spot when the pointer leaves; labels on their fixed sides, leader first.
- [ ] **Thermal view:** click, Enter, Space or tap → the infrared grows from the lens to the whole frame in 0.42s; the ring fades (reticle and reading stay); five labels without overlaps (≤767: value tags); dark chip, `aria-pressed="true"`; from 1100px the copy gets a pale plate. Again → back into the lens.
- [ ] **Touch:** tap or horizontal drag moves the lens; a vertical swipe scrolls and the lens stays.
- [ ] **Responsive:** ≤767 stacked, 4:5 images, menu dialog; 768–1099 stacked, 4:3 frame; ≥1100 copy over the sky, vertical scale on the right; no horizontal scroll at 375, 768, 1440, 1920.
- [ ] **Reduced motion:** no entrance, no smoothing, instant Thermal view, everything visible.
- [ ] **Robustness:** images blocked → the frame keeps its size and `#D9DDE1`; zero console errors; only the URLs of sections 2–3.
















































