088

Waitlist Page Template

Free

A one-screen pre-launch waitlist page built round one product photograph: visitors drag the radio's tuning knob, static gives way to stations that tune in the price, materials and batch, and launch day holds the email sign-up. Worked example: a pocket radio from a Glasgow workshop.

intermediate
7 more details
draggablesnapmomentumkeyboard-navigationcanvasprogressive-enhancementcontainer-queries

Released

About this template

A complete pre-launch page that fits on one screen: a wordmark, one sentence, a large product photograph on a flat grey ground and a panel of launch details. There is no scroll story and no countdown. The product itself is the interface, so the page reads as a product shot first and a form second.

Read the full template overview

The signature is the tuning dial. Visitors turn the photographed knob, slide the needle along the printed scale in the dial window, or use the arrow keys, and between stations the panel dissolves into static. Each station tunes in one part of the story: what it is, the price, what it is made of, the batch, and finally launch day, where the cream preset key on the radio doubles as the sign-up button. Releasing the knob settles on the nearest station with a small mechanical overshoot, and the signal lamp glows as it locks on.

The worked example is Fennick, a two-person workshop in Glasgow launching a pocket FM radio in a numbered batch of 300. To make it yours, replace the photograph, re-measure five overlay positions, and rewrite the stations: each one is a list item with a frequency and a name, and the scale, markers and station strip are drawn from that list.

Waitlist Page Template - website template preview

What's included

13 items
  • Complete one-screen launch page: wordmark, headline, a large product photograph, a launch details panel, a station strip and a footer
  • A tuning dial you operate on the photographed product: turn the knob, slide the needle in the dial window, or use the mouse wheel
  • Static between stations: the panel blurs and fills with grey noise, then resolves as the needle reaches a station and the signal lamp glows
  • Release settles on the nearest station with a gentle detent and a slight overshoot, and a fast flick carries further along the band
  • Five stations by default (what it is, price, materials, batch, launch day), written as plain list items with a frequency and a name
  • The printed scale, station markers and station strip are generated from that list, so adding or removing a station needs no script edits
  • The product's own preset key is the sign-up button: it tunes to launch day first, then submits the email once one is entered
  • Front-end email form with inline validation, an honest success message and a polite screen reader announcement
  • Native range input under the knob: arrow keys move station to station, Shift+arrows fine-tune, Home and End jump to the ends
  • Reduced motion keeps the dial fully working with instant settling and no static or blur
  • Without JavaScript every station is listed in order beside the product and the form is a plain form
  • Rethink Sans with Spline Sans Mono, a five-colour token block, and a ground colour matched to the photograph's backdrop
  • GSAP core only: no plugins, no scroll library, no build step

Perfect for

5 use cases
  • Hardware and gadget makers collecting emails before a first production run
  • App and software launches, with features or pricing tiers as the stations
  • Drinks, food and beauty brands opening pre-orders for a new product
  • Authors and publishers building a list before publication day
  • Crowdfunding campaigns that want a notify-me page before going live

How it works

5 sections

Structure

The page is one grid: copy and the details panel on the left, the product photograph and station strip on the right. Every overlay on the photograph (the dial window, knob, lamp and preset key) is positioned in percentages of the image, so the whole object scales as one at any size. Below 900px the order becomes headline, product, strip, then panel.

The tuning dial

Turning the knob, sliding along the dial window, the wheel and the keyboard all drive one frequency value, and the needle, knob angle, readout, signal bars, lamp and panel are all drawn from it. Signal strength rises as the needle nears a station, which is what fades the static out and the copy in. On release the dial settles on the nearest station, taking the speed of the throw into account.

Stations

Each station is a list item carrying a frequency and a short name. The scale's station markers, the strip of station links and the readout labels are all generated from those items, and the last one is treated as launch day. Only one station shows at a time, stacked in the same space so the panel never changes height while tuning.

Sign-up

The launch station holds an email form. Submitting it, or pressing the preset key on the photograph, checks the address, swaps the form for a confirmation and announces it to screen readers. Nothing is sent anywhere until you connect it to your own list provider.

Fallbacks

Keyboard users tune with a native range input that reports the frequency and station name to assistive technology. Reduced motion keeps every control working with instant settling and no static. With JavaScript off or the CDN blocked, all stations are listed in order and the form is an ordinary form.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this template's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Questions about this template

Which kinds of launch does this page suit?

Anything with one hero object and a handful of facts to tell before a date: a gadget, a kettle, a pair of headphones, a bottled drink, a book, an app shown as a phone. The stations become whatever your launch needs, such as features, price, ingredients or chapters, and the last station is always the sign-up.

How do I put my own product photo on the radio's place?

Use a front-on product shot on a flat neutral backdrop, set --ground to that backdrop colour, and measure where your own controls sit. Five pairs of percentages in the .radio rule place the dial window, knob, lamp and key. If your product has no knob, keep the dial window and station strip as the controls and remove the knob element.

Does the email form actually collect addresses?

No. It is a static front end that validates the address and shows a success message, but it sends nothing. Point the form's action at Buttondown, Mailchimp, ConvertKit or Formspree, or post it to your own endpoint from the submit handler, and delete the demo line in the success message.

Can I have more or fewer than five stations?

Yes. Add or remove list items in the stations list, each with a data-freq between 87.5 and 108 and a data-name. The printed scale, its markers and the strip under the product are rebuilt from the list on load. Keep stations at least two or three MHz apart so the labels on the scale stay readable.

Will visitors understand they are meant to turn the knob?

On load the needle sweeps across the band and lands on the first station, and a short line under the product invites a drag until the first interaction. The station strip under the product works as ordinary links too, so anyone who never touches the knob can still read every station with a click.

Is there sound when tuning?

No. The static is purely visual, which keeps the page quiet in an office and avoids autoplay rules. If you want audio, the frequency value updates on every frame, so a short noise loop whose volume follows the gap between stations can be added behind an explicit sound-on button.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Fennick R1: a pocket FM radio, first batch ships 4 March 2027</title>
    <meta name="description" content="The Fennick R1 is a pocket FM radio made by two people in Glasgow. Batch 01 is 300 numbered radios at £145, shipping 4 March 2027. Join the list.">
    <script data-cfasync="false">(function(d){d.classList.add('has-js');setTimeout(function(){if(!d.classList.contains('js-ready'))d.classList.remove('has-js')},4000)})(document.documentElement)</script>
    <link rel="preload" as="image" href="assets/img/radio-front.webp" fetchpriority="high">
    <!-- Fonts are self-hosted in assets/fonts/ (@font-face at the top of style.css) -->
    <link rel="preload" href="assets/fonts/rethink-sans-latin.woff2" as="font" type="font/woff2" crossorigin>
    <link rel="preload" href="assets/fonts/spline-sans-mono-latin.woff2" as="font" type="font/woff2" crossorigin>
    <link rel="stylesheet" href="assets/style.css">
</head>
<body>
<div class="page">

    <header class="site-header">
        <a class="wordmark" href="./" aria-label="Fennick Radio, home">
            <!-- Wordmark: a dial mark plus the name. Swap the <text> for your
                 brand, or replace the whole <svg> with your own logo. -->
            <svg viewBox="0 0 168 36" width="168" height="36" aria-hidden="true" focusable="false">
                <circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="3"/>
                <path d="M18 18 L18 6" stroke="var(--signal)" stroke-width="3.5" stroke-linecap="round"/>
                <circle cx="18" cy="18" r="3.5" fill="currentColor"/>
                <text x="42" y="27" font-family="Rethink Sans, system-ui, sans-serif" font-weight="800" font-size="26" letter-spacing="-0.6" fill="currentColor">Fennick</text>
            </svg>
        </a>
        <p class="site-header__batch"><span>Batch 01</span> <span aria-hidden="true">·</span> <span>300 radios</span></p>
        <a class="pill" href="#launch" data-tune-link="launch">Join the list</a>
    </header>

    <main class="hero" id="main">
        <div class="hero__copy">
            <p class="eyebrow">Fennick R1 &middot; pocket FM radio</p>
            <h1 class="hero__title">A small orange radio, ready in&nbsp;March.</h1>

            <!-- THE PANEL. Each <li data-station> is one station on the dial:
                 data-freq is where it sits (87.5 to 108), data-name is the short
                 label for the readout and the station strip. Add, remove or
                 reorder them freely; the scale, markers and strip are built
                 from this list. The LAST station is launch day and holds the form. -->
            <section class="panel" aria-label="Launch details" data-panel>
                <div class="readout" aria-hidden="true" data-readout>
                    <span class="readout__band">FM</span>
                    <span class="readout__freq" data-readout-freq>88.6</span>
                    <span class="readout__unit">MHz</span>
                    <span class="readout__name" data-readout-name>Hello</span>
                    <span class="readout__bars" data-readout-bars><i></i><i></i><i></i><i></i><i></i></span>
                </div>

                <ol class="stations" data-stations>
                    <li class="station" id="hello" data-station data-freq="88.6" data-name="Hello">
                        <h2 class="station__title">A radio for your shirt&nbsp;pocket.</h2>
                        <p>The R1 does one thing: FM, loud enough for a kitchen and small enough to forget it&rsquo;s on you. One knob, one key, and an aerial that pulls out to 42&nbsp;cm.</p>
                        <ul class="facts">
                            <li>FM 87.5&ndash;108</li><li>One knob</li><li>No app</li>
                        </ul>
                    </li>
                    <li class="station" id="price" data-station data-freq="92.4" data-name="Price">
                        <h2 class="station__title">&pound;145, posted anywhere in the&nbsp;UK.</h2>
                        <p>That includes the USB-C cable, a waxed-cotton sleeve and a two-year guarantee we honour from our own bench. EU and US postage is quoted at checkout.</p>
                        <ul class="facts">
                            <li>&pound;145</li><li>2-year guarantee</li><li>UK post included</li>
                        </ul>
                    </li>
                    <li class="station" id="build" data-station data-freq="96.8" data-name="Build">
                        <h2 class="station__title">One block of aluminium, anodised&nbsp;orange.</h2>
                        <p>The shell is machined from solid 6061 and anodised in Sheffield. The knob is knurled graphite, the key is cream acetal, and the speaker sits behind 380 drilled holes.</p>
                        <ul class="facts">
                            <li>190 g</li><li>11 &times; 6.5 &times; 2.4 cm</li><li>30 h a charge</li>
                        </ul>
                    </li>
                    <li class="station" id="batch" data-station data-freq="101.2" data-name="Batch 01">
                        <h2 class="station__title">300 radios, numbered on the&nbsp;back.</h2>
                        <p>The two of us assemble and test every R1 in our workshop in Kinning Park, Glasgow. When Batch 01 is gone, Batch 02 follows in the summer.</p>
                        <ul class="facts">
                            <li>No. 001&ndash;300</li><li>Made in Glasgow</li><li>Batch 02: summer</li>
                        </ul>
                    </li>
                    <li class="station station--launch" id="launch" data-station data-freq="105.6" data-name="Launch day">
                        <h2 class="station__title">Ships 4 March&nbsp;2027.</h2>
                        <p>Leave your email and we&rsquo;ll write once, on launch morning, with first claim on Batch&nbsp;01. Nothing to pay&nbsp;now.</p>
                        <!-- Front end only: this form sends nothing. Point action at
                             your list provider (Buttondown, Mailchimp, Formspree...)
                             and remove the demo line in the success message. -->
                        <form class="signup" id="waitlist" action="#" method="post" novalidate data-signup>
                            <label class="signup__label" for="email">Email address</label>
                            <div class="signup__row">
                                <input class="signup__input" id="email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required aria-describedby="email-error">
                                <button class="pill pill--big" type="submit">Join the list</button>
                            </div>
                            <p class="signup__error" id="email-error" data-signup-error></p>
                        </form>
                        <p class="signup__done" data-signup-done hidden tabindex="-1">
                            <strong>You&rsquo;re on the list for Batch 01.</strong>
                            We&rsquo;ll write on 4 March. <span class="signup__demo">Demo form: nothing was sent.</span>
                        </p>
                    </li>
                </ol>
                <canvas class="panel__static" width="160" height="120" aria-hidden="true" data-static></canvas>
                <p class="visually-hidden" aria-live="polite" data-live></p>
            </section>
        </div>

        <div class="stage">
            <!-- THE OBJECT. Overlay positions are percentages of this photo,
                 set as custom properties in style.css (.radio). Swap the photo
                 and re-measure those values for your own product. -->
            <figure class="radio" data-radio>
                <img class="radio__photo" src="assets/img/radio-front.webp" width="1536" height="1024" fetchpriority="high"
                     alt="The Fennick R1, a signal-orange aluminium pocket radio with a dial window, speaker grille, graphite tuning knob and cream preset key"
                     draggable="false">

                <div class="dial" data-dial>
                    <svg class="dial__scale" viewBox="0 0 1000 256" preserveAspectRatio="none" aria-hidden="true" focusable="false" data-scale></svg>
                    <div class="dial__needle" data-needle></div>
                </div>

                <span class="lamp" aria-hidden="true" data-lamp></span>

                <div class="knob" data-knob>
                    <img src="assets/img/knob.webp" width="480" height="480" alt="" draggable="false">
                </div>
                <label class="visually-hidden" for="tuner">Tuning dial</label>
                <input class="tuner" id="tuner" type="range" min="875" max="1080" step="1" value="886" data-tuner>


                <button class="key" type="button" data-key aria-label="Preset key: join the list for launch day"></button>
            </figure>

            <nav class="strip" aria-label="Stations">
                <ol data-strip>
                    <li><a href="#hello"><span>88.6</span>Hello</a></li>
                    <li><a href="#price"><span>92.4</span>Price</a></li>
                    <li><a href="#build"><span>96.8</span>Build</a></li>
                    <li><a href="#batch"><span>101.2</span>Batch 01</a></li>
                    <li><a href="#launch"><span>105.6</span>Launch day</a></li>
                </ol>
            </nav>
            <p class="hint" aria-hidden="true" data-hint>Drag the knob or the dial to tune</p>
        </div>
    </main>

    <footer class="site-footer">
        <p>Fennick Radio &middot; Kinning Park, Glasgow</p>
        <p><a href="mailto:hello@fennickradio.co.uk">hello@fennickradio.co.uk</a> &middot; <a href="#" aria-label="Fennick Radio on Instagram">Instagram</a></p>
    </footer>
</div>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js" defer></script>
<script src="assets/script.js" defer></script>
</body>
</html>
/**
 * Waitlist Page Template
 *
 * One script, one gsap.context. The page is a radio: the knob, the dial
 * window, the keyboard (a native range input) and the station strip all
 * drive one frequency value, and everything else is drawn from it.
 *
 * Content lives in the markup: each <li data-station data-freq data-name>
 * is a station. The printed scale, its markers and the station strip are
 * built from that list, so adding or removing a station needs no JS edits.
 * The last station is launch day and holds the sign-up form.
 *
 * @plugins none (GSAP core only)
 */

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initTemplate() {

    /* If the CDN is blocked, leave the plain page: every station listed and
       an ordinary form. */
    if (typeof gsap === 'undefined') {
        document.documentElement.classList.remove('has-js');
        return;
    }
    document.documentElement.classList.add('js-ready');

    /* ------------------------------------------------------------------
       SETTINGS: the band and the feel of the knob
       ------------------------------------------------------------------ */
    const BAND_MIN = 87.5;          // MHz at the left end of the scale
    const BAND_MAX = 108;           // MHz at the right end
    const DEG_PER_MHZ = 44;         // knob rotation per MHz (about 2.5 turns end to end)
    const CAPTURE = 0.55;           // MHz either side of a station where the signal starts
    const DETENT = 0.28;            // MHz either side where the knob is gently pulled in
    const SVG_NS = 'http://www.w3.org/2000/svg';
    const SCALE_W = 1000;           // the scale's viewBox width
    const SCALE_PAD = 60;           // viewBox units left free at each end

    const root = document.querySelector('[data-radio]');
    const panel = document.querySelector('[data-panel]');
    if (!root || !panel) return;

    const stationEls = Array.from(panel.querySelectorAll('[data-station]'));
    if (!stationEls.length) return;
    const stations = stationEls.map(function (el) {
        return { el: el, id: el.id, freq: parseFloat(el.dataset.freq), name: el.dataset.name || el.id };
    }).sort(function (a, b) { return a.freq - b.freq; });
    const launch = stations[stations.length - 1];

    const dial = root.querySelector('[data-dial]');
    const scaleSvg = root.querySelector('[data-scale]');
    const needle = root.querySelector('[data-needle]');
    const knob = root.querySelector('[data-knob]');
    const knobImg = knob ? knob.querySelector('img') : null;
    const lamp = root.querySelector('[data-lamp]');
    const key = root.querySelector('[data-key]');
    const tuner = root.querySelector('[data-tuner]');
    const strip = document.querySelector('[data-strip]');
    const hint = document.querySelector('[data-hint]');
    const readFreq = panel.querySelector('[data-readout-freq]');
    const readName = panel.querySelector('[data-readout-name]');
    const bars = Array.from(panel.querySelectorAll('[data-readout-bars] i'));
    const staticCanvas = panel.querySelector('[data-static]');
    const live = panel.querySelector('[data-live]');
    const form = panel.querySelector('[data-signup]');

    const clamp = function (v) { return Math.min(BAND_MAX, Math.max(BAND_MIN, v)); };
    const toX = function (f) { return SCALE_PAD + (f - BAND_MIN) / (BAND_MAX - BAND_MIN) * (SCALE_W - SCALE_PAD * 2); };
    const fmt = function (f) { return (Math.round(f * 10) / 10).toFixed(1); };

    function nearest(f) {
        let best = stations[0];
        stations.forEach(function (s) { if (Math.abs(s.freq - f) < Math.abs(best.freq - f)) best = s; });
        return best;
    }

    /* ------------------------------------------------------------------
       PRINTED SCALE, built from the station list
       ------------------------------------------------------------------ */
    function svgEl(name, attrs, text) {
        const n = document.createElementNS(SVG_NS, name);
        Object.keys(attrs).forEach(function (k) { n.setAttribute(k, attrs[k]); });
        if (text) n.textContent = text;
        return n;
    }

    /* Compact scale for small dials: numerals every 4 MHz, set larger, and no
       station names (the strip under the radio carries them). */
    let scaleCompact = null;
    function buildScale() {
        if (!scaleSvg || !dial) return;
        const compact = dial.clientWidth > 0 && dial.clientWidth < 300;
        if (compact === scaleCompact) return;
        scaleCompact = compact;
        scaleSvg.textContent = '';
        scaleSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
        const ink = 'var(--print)';
        const mono = 'Spline Sans Mono, monospace';
        const g = svgEl('g', { fill: ink, stroke: ink });
        const numEvery = compact ? 4 : 2;
        g.appendChild(svgEl('line', { x1: toX(BAND_MIN), x2: toX(BAND_MAX), y1: 150, y2: 150, 'stroke-width': 2.5 }));
        for (let t = Math.ceil(BAND_MIN * 5); t <= BAND_MAX * 5; t++) {
            const f = t / 5;
            const major = Math.abs(f - Math.round(f)) < 0.001;
            if (compact && !major) continue;
            const x = toX(f);
            g.appendChild(svgEl('line', { x1: x, x2: x, y1: 150, y2: major ? 110 : 132, 'stroke-width': major ? (compact ? 4 : 2.5) : 1.4 }));
            if (major && (Math.round(f) - 88) % numEvery === 0) {
                g.appendChild(svgEl('text', { x: x, y: compact ? 96 : 92, 'text-anchor': 'middle', 'font-size': compact ? 62 : 34, 'font-family': mono, stroke: 'none' }, String(Math.round(f))));
            }
        }
        if (!compact) {
            g.appendChild(svgEl('text', { x: 10, y: 222, 'font-size': 22, 'font-family': mono, stroke: 'none' }, 'FM'));
            g.appendChild(svgEl('text', { x: SCALE_W - 10, y: 222, 'text-anchor': 'end', 'font-size': 20, 'font-family': mono, stroke: 'none' }, 'MHz'));
        }
        stations.forEach(function (s) {
            const x = toX(s.freq);
            const m = compact ? 16 : 9;
            g.appendChild(svgEl('path', { d: 'M' + (x - m) + ' 180 L' + (x + m) + ' 180 L' + x + ' ' + (180 - m * 1.8) + ' Z', fill: 'var(--signal)', stroke: 'none' }));
            s.label = null;
            if (!compact) {
                s.label = svgEl('text', { x: x, y: 222, 'text-anchor': 'middle', 'font-size': 27, 'font-family': 'Rethink Sans, sans-serif', 'font-weight': 700, stroke: 'none', class: 'dial__label' }, s.name);
                g.appendChild(s.label);
            }
        });
        scaleSvg.appendChild(g);
    }

    /* Station strip under the radio, rebuilt so it always matches the list */
    function buildStrip() {
        if (!strip) return;
        strip.textContent = '';
        stations.forEach(function (s) {
            const li = document.createElement('li');
            const a = document.createElement('a');
            a.href = '#' + s.id;
            a.dataset.tuneLink = s.id;
            const num = document.createElement('span');
            num.textContent = fmt(s.freq);
            a.appendChild(num);
            a.appendChild(document.createTextNode(s.name));
            s.link = a;
            li.appendChild(a);
            strip.appendChild(li);
        });
    }

    buildScale();
    buildStrip();

    const previousContext = window.gsapContext;
    const ctx = gsap.context(function (rootContext) {
        const mm = gsap.matchMedia();

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            const isMotion = context.conditions.isMotion;
            const handlers = [];
            const on = function (el, type, fn, opts) {
                if (!el) return;
                const listener = context.add(null, fn);
                el.addEventListener(type, listener, opts);
                handlers.push(function () { el.removeEventListener(type, listener, opts); });
            };

            const state = { f: stations[0].freq };
            let current = null;        // the station whose panel is showing
            let lockedId = null;       // the station last announced
            let signal = 1;
            let tween = null;
            let staticOn = false;
            let touched = false;

            const setNeedle = gsap.quickSetter(needle, 'x', 'px');
            const setKnob = knobImg ? gsap.quickSetter(knobImg, 'rotation', 'deg') : null;

            /* ---- render: everything is drawn from state.f ---- */
            function render() {
                const f = state.f;
                if (dial && needle) setNeedle(toX(f) / SCALE_W * dial.clientWidth);
                if (setKnob) setKnob((f - BAND_MIN) * DEG_PER_MHZ);

                const st = nearest(f);
                const d = Math.abs(st.freq - f);
                signal = Math.max(0, 1 - d / CAPTURE);
                signal = signal * signal * (3 - 2 * signal);

                if (st !== current) {
                    if (current) {
                        unscramble(current);
                        current.el.classList.remove('is-active');
                        current.el.style.transform = '';
                        current.el.inert = true;
                        current.el.setAttribute('aria-hidden', 'true');
                        if (current.link) current.link.removeAttribute('aria-current');
                    }
                    current = st;
                    current.el.classList.add('is-active');
                    current.el.inert = false;
                    current.el.removeAttribute('aria-hidden');
                    if (current.link) current.link.setAttribute('aria-current', 'true');
                }
                stations.forEach(function (s) {
                    if (s.label) s.label.classList.toggle('is-tuned', s === current && signal > 0.5);
                });

                const content = current.el;
                if (isMotion) {
                    content.style.opacity = (0.3 + 0.7 * signal).toFixed(3);
                    if (signal >= 0.98) { unscramble(current); content.style.transform = ''; }
                } else {
                    content.style.opacity = signal > 0.4 ? '1' : '0.25';
                }
                if (lamp) lamp.style.opacity = (signal * 0.95).toFixed(3);

                if (readFreq) readFreq.textContent = fmt(f);
                if (readName) readName.textContent = signal > 0.35 ? st.name : 'Between stations';
                const lit = Math.round(signal * bars.length);
                bars.forEach(function (b, i) { b.classList.toggle('is-on', i < lit); });

                if (tuner) {
                    tuner.value = String(Math.round(f * 10));
                    tuner.setAttribute('aria-valuetext', fmt(f) + ' FM, ' + (signal > 0.35 ? st.name : 'between stations'));
                }

                staticOn = isMotion && signal < 0.98;
                if (staticCanvas) staticCanvas.style.opacity = isMotion ? ((1 - signal) * 0.6).toFixed(3) : '0';
            }

            /* ---- static, between stations only ----
               The station's own text breaks up: characters swap for noise glyphs
               in proportion to how far off-station the needle is, the block
               tears sideways, and scanline noise runs over the station box. */
            const GLYPHS = '#%&*+=/\\|~<>-_:;░▒';
            function textNodes(s) {
                if (s.texts) return s.texts;
                const out = [];
                const walker = document.createTreeWalker(s.el, NodeFilter.SHOW_TEXT, {
                    acceptNode: function (n) {
                        if (!n.nodeValue.trim() || n.parentElement.closest('form, [data-signup-done]')) return NodeFilter.FILTER_REJECT;
                        return NodeFilter.FILTER_ACCEPT;
                    }
                });
                while (walker.nextNode()) out.push({ node: walker.currentNode, text: walker.currentNode.nodeValue });
                s.texts = out;
                return out;
            }
            function scramble(s, amount) {
                s.scrambled = true;
                textNodes(s).forEach(function (t) {
                    let str = '';
                    for (let i = 0; i < t.text.length; i++) {
                        const ch = t.text[i];
                        str += (ch === ' ' || Math.random() > amount) ? ch : GLYPHS[(Math.random() * GLYPHS.length) | 0];
                    }
                    t.node.nodeValue = str;
                });
            }
            function unscramble(s) {
                if (!s || !s.scrambled) return;
                s.scrambled = false;
                textNodes(s).forEach(function (t) { t.node.nodeValue = t.text; });
            }

            const sctx = staticCanvas ? staticCanvas.getContext('2d') : null;
            const stationList = panel.querySelector('[data-stations]');
            let staticImg = null;
            let frame = 0;
            const inkRGB = (getComputedStyle(document.documentElement).getPropertyValue('--ink-rgb') || '22, 25, 27')
                .split(',').map(function (v) { return parseInt(v, 10) || 0; });
            function fitStatic() {
                if (!staticCanvas || !stationList) return;
                staticCanvas.style.top = stationList.offsetTop + 'px';
                staticCanvas.style.height = stationList.offsetHeight + 'px';
            }
            function drawStatic() {
                if (!staticOn) return;
                frame++;
                if (frame % 2) return; /* about 30 fps is plenty for static */
                const off = 1 - signal;
                if (current) {
                    scramble(current, Math.min(0.85, off * 1.1));
                    const tear = Math.random() < off ? (Math.random() - 0.5) * off * 14 : 0;
                    current.el.style.transform = tear ? 'translateX(' + tear.toFixed(1) + 'px)' : '';
                }
                if (!sctx) return;
                const w = staticCanvas.width, h = staticCanvas.height;
                if (!staticImg) staticImg = sctx.createImageData(w, h);
                const px = staticImg.data;
                /* Ink-coloured scanlines on a transparent canvas: most lines are
                   faint speckle, a few become darker tearing bands. */
                for (let y = 0; y < h; y++) {
                    const band = Math.random() < 0.07 ? 0.35 : (y % 2 ? 0 : 0.1);
                    for (let x = 0; x < w; x++) {
                        const i = (y * w + x) * 4;
                        px[i] = inkRGB[0]; px[i + 1] = inkRGB[1]; px[i + 2] = inkRGB[2];
                        px[i + 3] = band ? (band * (0.4 + Math.random() * 0.6)) * 255 : 0;
                    }
                }
                sctx.putImageData(staticImg, 0, 0);
            }
            if (isMotion) {
                gsap.ticker.add(drawStatic);
                handlers.push(function () { gsap.ticker.remove(drawStatic); });
            }

            /* ---- announce a station once the dial settles on it ---- */
            function settled() {
                const st = nearest(state.f);
                if (Math.abs(st.freq - state.f) < 0.05 && lockedId !== st.id) {
                    lockedId = st.id;
                    if (live && touched) live.textContent = 'Tuned to ' + fmt(st.freq) + ', ' + st.name + '.';
                }
            }

            function kill() { if (tween) { tween.kill(); tween = null; } }

            /* Glide to a frequency; reduced motion jumps there */
            function tuneTo(f, opts) {
                kill();
                f = clamp(f);
                const o = opts || {};
                if (!isMotion) { state.f = f; render(); settled(); if (o.onComplete) o.onComplete(); return; }
                const dist = Math.abs(f - state.f);
                tween = gsap.to(state, {
                    f: f,
                    duration: o.duration || Math.min(1.1, 0.35 + dist * 0.06),
                    ease: o.ease || 'back.out(1.5)',
                    onUpdate: render,
                    onComplete: function () { tween = null; settled(); if (o.onComplete) o.onComplete(); }
                });
            }

            /* Release: project the throw a little, then settle on the nearest station */
            function snap(velocity) {
                const projected = state.f + gsap.utils.clamp(-3, 3, (velocity || 0) * 0.12);
                tuneTo(nearest(projected).freq);
            }

            /* While dragging, a station gently pulls the needle in (a detent) */
            function setFromDrag(raw) {
                raw = clamp(raw);
                const st = nearest(raw);
                const d = st.freq - raw;
                const w = Math.max(0, 1 - Math.abs(d) / DETENT);
                state.f = raw + d * w * w * 0.7;
                render();
            }

            function firstTouch() {
                if (touched) return;
                touched = true;
                if (hint) gsap.to(hint, { autoAlpha: 0, duration: isMotion ? 0.4 : 0 });
            }

            /* Velocity tracker in MHz per second */
            const samples = [];
            function track(f) {
                const t = performance.now();
                samples.push({ f: f, t: t });
                while (samples.length && t - samples[0].t > 120) samples.shift();
            }
            function velocity() {
                if (samples.length < 2) return 0;
                const a = samples[0], b = samples[samples.length - 1];
                const dt = (b.t - a.t) / 1000;
                return dt > 0 ? (b.f - a.f) / dt : 0;
            }

            /* ---- knob: turn it by the pointer's angle round its centre ---- */
            let raw = state.f;
            if (knob) {
                let knobDrag = null;
                const angleOf = function (e) {
                    const r = knob.getBoundingClientRect();
                    const dx = e.clientX - (r.left + r.width / 2);
                    const dy = e.clientY - (r.top + r.height / 2);
                    return { a: Math.atan2(dy, dx) * 180 / Math.PI, near: Math.hypot(dx, dy) < r.width * 0.12 };
                };
                on(knob, 'pointerdown', function (e) {
                    if (e.button !== 0) return;
                    e.preventDefault();
                    kill();
                    firstTouch();
                    knob.setPointerCapture(e.pointerId);
                    knob.classList.add('is-dragging');
                    if (tuner) tuner.focus({ preventScroll: true });
                    raw = state.f;
                    root.classList.add('is-pointer');
                    knobDrag = { id: e.pointerId, last: angleOf(e).a };
                    samples.length = 0;
                    track(raw);
                });
                on(knob, 'pointermove', function (e) {
                    if (!knobDrag || e.pointerId !== knobDrag.id) return;
                    const p = angleOf(e);
                    if (p.near) { knobDrag.last = p.a; return; }
                    let da = p.a - knobDrag.last;
                    if (da > 180) da -= 360;
                    if (da < -180) da += 360;
                    knobDrag.last = p.a;
                    raw = clamp(raw + da / DEG_PER_MHZ);
                    track(raw);
                    setFromDrag(raw);
                });
                const endKnob = function (e) {
                    if (!knobDrag || e.pointerId !== knobDrag.id) return;
                    knobDrag = null;
                    knob.classList.remove('is-dragging');
                    snap(isMotion ? velocity() : 0);
                };
                on(knob, 'pointerup', endKnob);
                on(knob, 'pointercancel', endKnob);
            }

            /* ---- dial window: grab the needle and slide it ---- */
            if (dial) {
                let dialDrag = null;
                const freqAt = function (clientX) {
                    const r = dial.getBoundingClientRect();
                    const x = (clientX - r.left) / r.width * SCALE_W;
                    return clamp(BAND_MIN + (x - SCALE_PAD) / (SCALE_W - SCALE_PAD * 2) * (BAND_MAX - BAND_MIN));
                };
                on(dial, 'pointerdown', function (e) {
                    if (e.button !== 0) return;
                    kill();
                    firstTouch();
                    dialDrag = { id: e.pointerId, x: e.clientX, y: e.clientY, active: e.pointerType === 'mouse' };
                    samples.length = 0;
                    if (dialDrag.active) {
                        e.preventDefault();
                        root.classList.add('is-pointer');
                        dial.setPointerCapture(e.pointerId);
                        if (tuner) tuner.focus({ preventScroll: true });
                        /* first press glides the needle under the pointer */
                        const target = freqAt(e.clientX);
                        if (isMotion) {
                            tween = gsap.to(state, { f: target, duration: 0.22, ease: 'power2.out', onUpdate: render });
                        } else { state.f = target; render(); }
                        raw = target;
                        track(raw);
                    }
                });
                on(dial, 'pointermove', function (e) {
                    if (!dialDrag || e.pointerId !== dialDrag.id) return;
                    if (!dialDrag.active) {
                        /* touch: only take the gesture once it is clearly sideways */
                        const dx = Math.abs(e.clientX - dialDrag.x), dy = Math.abs(e.clientY - dialDrag.y);
                        if (dy > 10 && dy > dx) { dialDrag = null; return; }
                        if (dx < 6) return;
                        dialDrag.active = true;
                        dial.setPointerCapture(e.pointerId);
                    }
                    if (tween) tween.kill();
                    raw = freqAt(e.clientX);
                    track(raw);
                    setFromDrag(raw);
                });
                const endDial = function (e) {
                    if (!dialDrag || e.pointerId !== dialDrag.id) return;
                    const wasActive = dialDrag.active;
                    dialDrag = null;
                    if (wasActive) snap(isMotion ? velocity() : 0);
                    else if (e.type === 'pointerup') tuneTo(nearest(freqAt(e.clientX)).freq);
                };
                on(dial, 'pointerup', endDial);
                on(dial, 'pointercancel', endDial);
            }

            /* ---- wheel over the knob or dial: fine tuning, settles after a pause ---- */
            let wheelTimer = null;
            const onWheel = function (e) {
                e.preventDefault();
                kill();
                firstTouch();
                const dy = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
                raw = clamp((wheelTimer ? raw : state.f) + dy * 0.006);
                setFromDrag(raw);
                clearTimeout(wheelTimer);
                wheelTimer = setTimeout(function () { wheelTimer = null; snap(0); }, 160);
            };
            on(knob, 'wheel', onWheel, { passive: false });
            on(dial, 'wheel', onWheel, { passive: false });
            handlers.push(function () { clearTimeout(wheelTimer); });

            /* ---- keyboard: arrows move station to station, Shift+arrows fine-tune ---- */
            if (tuner) {
                on(tuner, 'keydown', function (e) {
                    const idx = stations.indexOf(nearest(state.f));
                    const onStation = Math.abs(stations[idx].freq - state.f) < 0.05;
                    let target = null;
                    if (e.shiftKey && /^Arrow/.test(e.key)) return; /* native 0.1 MHz steps */
                    if (e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'PageUp') {
                        target = onStation || stations[idx].freq <= state.f ? stations[Math.min(idx + 1, stations.length - 1)] : stations[idx];
                    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown' || e.key === 'PageDown') {
                        target = onStation || stations[idx].freq >= state.f ? stations[Math.max(idx - 1, 0)] : stations[idx];
                    } else if (e.key === 'Home') { target = stations[0]; }
                    else if (e.key === 'End') { target = launch; }
                    if (!target) return;
                    e.preventDefault();
                    firstTouch();
                    tuneTo(target.freq, { duration: 0.6, ease: 'power3.inOut' });
                });
                on(tuner, 'input', function () {
                    kill();
                    firstTouch();
                    state.f = clamp(parseInt(tuner.value, 10) / 10);
                    render();
                    settled();
                });
            }

            /* ---- station links (strip and header): tune instead of jumping ---- */
            document.querySelectorAll('a[data-tune-link]').forEach(function (a) {
                on(a, 'click', function (e) {
                    const s = stations.filter(function (x) { return x.id === a.dataset.tuneLink; })[0];
                    if (!s) return;
                    e.preventDefault();
                    firstTouch();
                    const toForm = s === launch && a.closest('.site-header');
                    tuneTo(s.freq, {
                        duration: 0.75,
                        ease: 'power3.inOut',
                        onComplete: function () {
                            const field = toForm ? launch.el.querySelector('input') : null;
                            if (field) field.focus({ preventScroll: true });
                        }
                    });
                });
            });

            /* ---- preset key: tunes to launch day, then submits ---- */
            if (key) {
                const pressKey = function () {
                    key.classList.add('is-pressed');
                    setTimeout(function () { key.classList.remove('is-pressed'); }, 140);
                };
                on(key, 'click', function () {
                    pressKey();
                    firstTouch();
                    const field = launch.el.querySelector('input');
                    const atLaunch = Math.abs(state.f - launch.freq) < 0.05;
                    if (atLaunch && form && !form.hidden && field && field.value.trim()) {
                        form.requestSubmit();
                    } else {
                        tuneTo(launch.freq, {
                            duration: 0.8,
                            ease: 'power3.inOut',
                            onComplete: function () { if (field && !form.hidden) field.focus({ preventScroll: true }); }
                        });
                    }
                });
            }

            /* ---- sign-up: front end only, see README to connect a list ---- */
            if (form) {
                const field = form.querySelector('input[type="email"]');
                const error = form.querySelector('[data-signup-error]');
                const done = panel.querySelector('[data-signup-done]');
                on(form, 'submit', function (e) {
                    e.preventDefault();
                    const value = field.value.trim();
                    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
                        field.setAttribute('aria-invalid', 'true');
                        error.textContent = 'Enter an email like name@example.com.';
                        field.focus();
                        return;
                    }
                    field.removeAttribute('aria-invalid');
                    error.textContent = '';
                    form.hidden = true;
                    if (done) {
                        done.hidden = false;
                        done.focus({ preventScroll: true });
                        if (isMotion) gsap.fromTo(done, { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.45, ease: 'power2.out' });
                    }
                    if (live) live.textContent = 'You are on the list for Batch 01. We will write on 4 March.';
                });
                on(field, 'input', function () {
                    if (field.getAttribute('aria-invalid')) { field.removeAttribute('aria-invalid'); error.textContent = ''; }
                });
            }

            on(document, 'keydown', function () { root.classList.remove('is-pointer'); });
            on(window, 'resize', function () { buildScale(); fitStatic(); render(); });
            fitStatic();

            /* ---- start: the needle sweeps in from the top of the band ---- */
            stations.forEach(function (s) { s.el.inert = true; s.el.setAttribute('aria-hidden', 'true'); });
            if (isMotion && !touched) {
                state.f = BAND_MAX;
                render();
                tween = gsap.to(state, {
                    f: stations[0].freq,
                    duration: 1.6,
                    delay: 0.25,
                    ease: 'power3.inOut',
                    onUpdate: render,
                    onComplete: function () { tween = null; settled(); }
                });
            } else {
                render();
                settled();
            }
            /* The scale's text needs the mono face before the first measure */
            if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { fitStatic(); render(); });

            return function cleanup() {
                kill();
                handlers.forEach(function (remove) { remove(); });
                handlers.length = 0;
                stations.forEach(unscramble);
                stations.forEach(function (s) {
                    s.el.classList.remove('is-active');
                    s.el.style.transform = '';
                    s.el.inert = false;
                    s.el.removeAttribute('aria-hidden');
                    s.el.style.opacity = '';
                    s.el.style.filter = '';
                });
            };
        });

        const handleUnload = function () { rootContext.revert(); };
        window.addEventListener('beforeunload', handleUnload);
        return function cleanupPage() {
            window.removeEventListener('beforeunload', handleUnload);
            if (window.gsapContext === rootContext) {
                if (previousContext === undefined) delete window.gsapContext;
                else window.gsapContext = previousContext;
            }
        };
    });
    window.gsapContext = ctx;
});
!function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){if("undefined"==typeof gsap)return void document.documentElement.classList.remove("has-js");document.documentElement.classList.add("js-ready");const t=87.5,e=108,n=1e3,o=document.querySelector("[data-radio]"),r=document.querySelector("[data-panel]");if(!o||!r)return;const i=Array.from(r.querySelectorAll("[data-station]"));if(!i.length)return;const a=i.map(function(t){return{el:t,id:t.id,freq:parseFloat(t.dataset.freq),name:t.dataset.name||t.id}}).sort(function(t,e){return t.freq-e.freq}),l=a[a.length-1],c=o.querySelector("[data-dial]"),u=o.querySelector("[data-scale]"),s=o.querySelector("[data-needle]"),d=o.querySelector("[data-knob]"),f=d?d.querySelector("img"):null,p=o.querySelector("[data-lamp]"),h=o.querySelector("[data-key]"),m=o.querySelector("[data-tuner]"),y=document.querySelector("[data-strip]"),g=document.querySelector("[data-hint]"),v=r.querySelector("[data-readout-freq]"),x=r.querySelector("[data-readout-name]"),q=Array.from(r.querySelectorAll("[data-readout-bars] i")),w=r.querySelector("[data-static]"),M=r.querySelector("[data-live]"),b=r.querySelector("[data-signup]"),C=function(n){return Math.min(e,Math.max(t,n))},k=function(e){return 60+(e-t)/20.5*880},S=function(t){return(Math.round(10*t)/10).toFixed(1)};function E(t){let e=a[0];return a.forEach(function(n){Math.abs(n.freq-t)<Math.abs(e.freq-t)&&(e=n)}),e}function L(t,e,n){const o=document.createElementNS("http://www.w3.org/2000/svg",t);return Object.keys(e).forEach(function(t){o.setAttribute(t,e[t])}),n&&(o.textContent=n),o}let A=null;function I(){if(!u||!c)return;const n=c.clientWidth>0&&c.clientWidth<300;if(n===A)return;A=n,u.textContent="",u.setAttribute("preserveAspectRatio","xMidYMid meet");const o="var(--print)",r="Spline Sans Mono, monospace",i=L("g",{fill:o,stroke:o}),l=n?4:2;i.appendChild(L("line",{x1:k(t),x2:k(e),y1:150,y2:150,"stroke-width":2.5}));for(let t=Math.ceil(437.5);t<=540;t++){const e=t/5,o=Math.abs(e-Math.round(e))<.001;if(n&&!o)continue;const a=k(e);i.appendChild(L("line",{x1:a,x2:a,y1:150,y2:o?110:132,"stroke-width":o?n?4:2.5:1.4})),o&&(Math.round(e)-88)%l==0&&i.appendChild(L("text",{x:a,y:n?96:92,"text-anchor":"middle","font-size":n?62:34,"font-family":r,stroke:"none"},String(Math.round(e))))}n||(i.appendChild(L("text",{x:10,y:222,"font-size":22,"font-family":r,stroke:"none"},"FM")),i.appendChild(L("text",{x:990,y:222,"text-anchor":"end","font-size":20,"font-family":r,stroke:"none"},"MHz"))),a.forEach(function(t){const e=k(t.freq),o=n?16:9;i.appendChild(L("path",{d:"M"+(e-o)+" 180 L"+(e+o)+" 180 L"+e+" "+(180-1.8*o)+" Z",fill:"var(--signal)",stroke:"none"})),t.label=null,n||(t.label=L("text",{x:e,y:222,"text-anchor":"middle","font-size":27,"font-family":"Rethink Sans, sans-serif","font-weight":700,stroke:"none",class:"dial__label"},t.name),i.appendChild(t.label))}),u.appendChild(i)}I(),y&&(y.textContent="",a.forEach(function(t){const e=document.createElement("li"),n=document.createElement("a");n.href="#"+t.id,n.dataset.tuneLink=t.id;const o=document.createElement("span");o.textContent=S(t.freq),n.appendChild(o),n.appendChild(document.createTextNode(t.name)),t.link=n,e.appendChild(n),y.appendChild(e)}));const T=window.gsapContext,F=gsap.context(function(i){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(i){const u=i.conditions.isMotion,y=[],L=function(t,e,n,o){if(!t)return;const r=i.add(null,n);t.addEventListener(e,r,o),y.push(function(){t.removeEventListener(e,r,o)})},A={f:a[0].freq};let T=null,F=null,D=1,X=null,N=!1,R=!1;const O=gsap.quickSetter(s,"x","px"),P=f?gsap.quickSetter(f,"rotation","deg"):null;function Y(){const e=A.f;c&&s&&O(k(e)/n*c.clientWidth),P&&P(44*(e-t));const o=E(e),r=Math.abs(o.freq-e);D=Math.max(0,1-r/.55),D=D*D*(3-2*D),o!==T&&(T&&(_(T),T.el.classList.remove("is-active"),T.el.style.transform="",T.el.inert=!0,T.el.setAttribute("aria-hidden","true"),T.link&&T.link.removeAttribute("aria-current")),T=o,T.el.classList.add("is-active"),T.el.inert=!1,T.el.removeAttribute("aria-hidden"),T.link&&T.link.setAttribute("aria-current","true")),a.forEach(function(t){t.label&&t.label.classList.toggle("is-tuned",t===T&&D>.5)});const i=T.el;u?(i.style.opacity=(.3+.7*D).toFixed(3),D>=.98&&(_(T),i.style.transform="")):i.style.opacity=D>.4?"1":"0.25",p&&(p.style.opacity=(.95*D).toFixed(3)),v&&(v.textContent=S(e)),x&&(x.textContent=D>.35?o.name:"Between stations");const l=Math.round(D*q.length);q.forEach(function(t,e){t.classList.toggle("is-on",e<l)}),m&&(m.value=String(Math.round(10*e)),m.setAttribute("aria-valuetext",S(e)+" FM, "+(D>.35?o.name:"between stations"))),N=u&&D<.98,w&&(w.style.opacity=u?(.6*(1-D)).toFixed(3):"0")}const z="#%&*+=/\\|~<>-_:;░▒";function W(t){if(t.texts)return t.texts;const e=[],n=document.createTreeWalker(t.el,NodeFilter.SHOW_TEXT,{acceptNode:function(t){return!t.nodeValue.trim()||t.parentElement.closest("form, [data-signup-done]")?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}});for(;n.nextNode();)e.push({node:n.currentNode,text:n.currentNode.nodeValue});return t.texts=e,e}function _(t){t&&t.scrambled&&(t.scrambled=!1,W(t).forEach(function(t){t.node.nodeValue=t.text}))}const U=w?w.getContext("2d"):null,V=r.querySelector("[data-stations]");let B=null,H=0;const j=(getComputedStyle(document.documentElement).getPropertyValue("--ink-rgb")||"22, 25, 27").split(",").map(function(t){return parseInt(t,10)||0});function J(){w&&V&&(w.style.top=V.offsetTop+"px",w.style.height=V.offsetHeight+"px")}function K(){if(!N)return;if(H++,H%2)return;const t=1-D;if(T){e=T,n=Math.min(.85,1.1*t),e.scrambled=!0,W(e).forEach(function(t){let e="";for(let o=0;o<t.text.length;o++){const r=t.text[o];e+=" "===r||Math.random()>n?r:z[18*Math.random()|0]}t.node.nodeValue=e});const o=Math.random()<t?(Math.random()-.5)*t*14:0;T.el.style.transform=o?"translateX("+o.toFixed(1)+"px)":""}var e,n;if(!U)return;const o=w.width,r=w.height;B||(B=U.createImageData(o,r));const i=B.data;for(let t=0;t<r;t++){const e=Math.random()<.07?.35:t%2?0:.1;for(let n=0;n<o;n++){const r=4*(t*o+n);i[r]=j[0],i[r+1]=j[1],i[r+2]=j[2],i[r+3]=e?e*(.4+.6*Math.random())*255:0}}U.putImageData(B,0,0)}function Z(){const t=E(A.f);Math.abs(t.freq-A.f)<.05&&F!==t.id&&(F=t.id,M&&R&&(M.textContent="Tuned to "+S(t.freq)+", "+t.name+"."))}function $(){X&&(X.kill(),X=null)}function G(t,e){$(),t=C(t);const n=e||{};if(!u)return A.f=t,Y(),Z(),void(n.onComplete&&n.onComplete());const o=Math.abs(t-A.f);X=gsap.to(A,{f:t,duration:n.duration||Math.min(1.1,.35+.06*o),ease:n.ease||"back.out(1.5)",onUpdate:Y,onComplete:function(){X=null,Z(),n.onComplete&&n.onComplete()}})}function Q(t){G(E(A.f+gsap.utils.clamp(-3,3,.12*(t||0))).freq)}function tt(t){const e=E(t=C(t)).freq-t,n=Math.max(0,1-Math.abs(e)/.28);A.f=t+e*n*n*.7,Y()}function et(){R||(R=!0,g&&gsap.to(g,{autoAlpha:0,duration:u?.4:0}))}u&&(gsap.ticker.add(K),y.push(function(){gsap.ticker.remove(K)}));const nt=[];function ot(t){const e=performance.now();for(nt.push({f:t,t:e});nt.length&&e-nt[0].t>120;)nt.shift()}function rt(){if(nt.length<2)return 0;const t=nt[0],e=nt[nt.length-1],n=(e.t-t.t)/1e3;return n>0?(e.f-t.f)/n:0}let it=A.f;if(d){let t=null;const e=function(t){const e=d.getBoundingClientRect(),n=t.clientX-(e.left+e.width/2),o=t.clientY-(e.top+e.height/2);return{a:180*Math.atan2(o,n)/Math.PI,near:Math.hypot(n,o)<.12*e.width}};L(d,"pointerdown",function(n){0===n.button&&(n.preventDefault(),$(),et(),d.setPointerCapture(n.pointerId),d.classList.add("is-dragging"),m&&m.focus({preventScroll:!0}),it=A.f,o.classList.add("is-pointer"),t={id:n.pointerId,last:e(n).a},nt.length=0,ot(it))}),L(d,"pointermove",function(n){if(!t||n.pointerId!==t.id)return;const o=e(n);if(o.near)return void(t.last=o.a);let r=o.a-t.last;r>180&&(r-=360),r<-180&&(r+=360),t.last=o.a,it=C(it+r/44),ot(it),tt(it)});const n=function(e){t&&e.pointerId===t.id&&(t=null,d.classList.remove("is-dragging"),Q(u?rt():0))};L(d,"pointerup",n),L(d,"pointercancel",n)}if(c){let e=null;const r=function(e){const o=c.getBoundingClientRect(),r=(e-o.left)/o.width*n;return C(t+(r-60)/880*20.5)};L(c,"pointerdown",function(t){if(0===t.button&&($(),et(),e={id:t.pointerId,x:t.clientX,y:t.clientY,active:"mouse"===t.pointerType},nt.length=0,e.active)){t.preventDefault(),o.classList.add("is-pointer"),c.setPointerCapture(t.pointerId),m&&m.focus({preventScroll:!0});const e=r(t.clientX);u?X=gsap.to(A,{f:e,duration:.22,ease:"power2.out",onUpdate:Y}):(A.f=e,Y()),it=e,ot(it)}}),L(c,"pointermove",function(t){if(e&&t.pointerId===e.id){if(!e.active){const n=Math.abs(t.clientX-e.x),o=Math.abs(t.clientY-e.y);if(o>10&&o>n)return void(e=null);if(n<6)return;e.active=!0,c.setPointerCapture(t.pointerId)}X&&X.kill(),it=r(t.clientX),ot(it),tt(it)}});const i=function(t){if(!e||t.pointerId!==e.id)return;const n=e.active;e=null,n?Q(u?rt():0):"pointerup"===t.type&&G(E(r(t.clientX)).freq)};L(c,"pointerup",i),L(c,"pointercancel",i)}let at=null;const lt=function(t){t.preventDefault(),$(),et();const e=Math.abs(t.deltaY)>Math.abs(t.deltaX)?t.deltaY:t.deltaX;it=C((at?it:A.f)+.006*e),tt(it),clearTimeout(at),at=setTimeout(function(){at=null,Q(0)},160)};if(L(d,"wheel",lt,{passive:!1}),L(c,"wheel",lt,{passive:!1}),y.push(function(){clearTimeout(at)}),m&&(L(m,"keydown",function(t){const e=a.indexOf(E(A.f)),n=Math.abs(a[e].freq-A.f)<.05;let o=null;t.shiftKey&&/^Arrow/.test(t.key)||("ArrowRight"===t.key||"ArrowUp"===t.key||"PageUp"===t.key?o=n||a[e].freq<=A.f?a[Math.min(e+1,a.length-1)]:a[e]:"ArrowLeft"===t.key||"ArrowDown"===t.key||"PageDown"===t.key?o=n||a[e].freq>=A.f?a[Math.max(e-1,0)]:a[e]:"Home"===t.key?o=a[0]:"End"===t.key&&(o=l),o&&(t.preventDefault(),et(),G(o.freq,{duration:.6,ease:"power3.inOut"})))}),L(m,"input",function(){$(),et(),A.f=C(parseInt(m.value,10)/10),Y(),Z()})),document.querySelectorAll("a[data-tune-link]").forEach(function(t){L(t,"click",function(e){const n=a.filter(function(e){return e.id===t.dataset.tuneLink})[0];if(!n)return;e.preventDefault(),et();const o=n===l&&t.closest(".site-header");G(n.freq,{duration:.75,ease:"power3.inOut",onComplete:function(){const t=o?l.el.querySelector("input"):null;t&&t.focus({preventScroll:!0})}})})}),h){const t=function(){h.classList.add("is-pressed"),setTimeout(function(){h.classList.remove("is-pressed")},140)};L(h,"click",function(){t(),et();const e=l.el.querySelector("input");Math.abs(A.f-l.freq)<.05&&b&&!b.hidden&&e&&e.value.trim()?b.requestSubmit():G(l.freq,{duration:.8,ease:"power3.inOut",onComplete:function(){e&&!b.hidden&&e.focus({preventScroll:!0})}})})}if(b){const t=b.querySelector('input[type="email"]'),e=b.querySelector("[data-signup-error]"),n=r.querySelector("[data-signup-done]");L(b,"submit",function(o){o.preventDefault();const r=t.value.trim();if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r))return t.setAttribute("aria-invalid","true"),e.textContent="Enter an email like name@example.com.",void t.focus();t.removeAttribute("aria-invalid"),e.textContent="",b.hidden=!0,n&&(n.hidden=!1,n.focus({preventScroll:!0}),u&&gsap.fromTo(n,{opacity:0,y:8},{opacity:1,y:0,duration:.45,ease:"power2.out"})),M&&(M.textContent="You are on the list for Batch 01. We will write on 4 March.")}),L(t,"input",function(){t.getAttribute("aria-invalid")&&(t.removeAttribute("aria-invalid"),e.textContent="")})}return L(document,"keydown",function(){o.classList.remove("is-pointer")}),L(window,"resize",function(){I(),J(),Y()}),J(),a.forEach(function(t){t.el.inert=!0,t.el.setAttribute("aria-hidden","true")}),u&&!R?(A.f=e,Y(),X=gsap.to(A,{f:a[0].freq,duration:1.6,delay:.25,ease:"power3.inOut",onUpdate:Y,onComplete:function(){X=null,Z()}})):(Y(),Z()),document.fonts&&document.fonts.ready&&document.fonts.ready.then(function(){J(),Y()}),function(){$(),y.forEach(function(t){t()}),y.length=0,a.forEach(_),a.forEach(function(t){t.el.classList.remove("is-active"),t.el.style.transform="",t.el.inert=!1,t.el.removeAttribute("aria-hidden"),t.el.style.opacity="",t.el.style.filter=""})}});const u=function(){i.revert()};return window.addEventListener("beforeunload",u),function(){window.removeEventListener("beforeunload",u),window.gsapContext===i&&(void 0===T?delete window.gsapContext:window.gsapContext=T)}});window.gsapContext=F});
/* ==========================================================================
   Waitlist Page Template: a one-screen pre-launch page you tune like a radio

   One photographed object on a flat grey ground, and the object runs the
   page: dragging its knob sweeps a needle across a printed scale, and each
   "station" tunes in one part of the launch story, ending on the sign-up.
   Make it yours by swapping the photo and the <li data-station> copy first.

   Reskin: repaint the five colour tokens below, set --ground to the colour
   of your photo's backdrop, and re-measure the overlay positions in .radio.
   ========================================================================== */

/* Self-hosted fonts (latin), licence in fonts/OFL.txt. Rethink Sans is one
   variable file declared at the three weights in use. */
@font-face {
    font-family: 'Rethink Sans';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url("fonts/rethink-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: 'Rethink Sans';
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("fonts/rethink-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: 'Rethink Sans';
    font-style: normal;
    font-weight: 800;
    font-display: swap;
    src: url("fonts/rethink-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: 'Spline Sans Mono';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url("fonts/spline-sans-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;
}

:root {
    /* Measured pairs (WCAG, as rendered):
       --ink #16191B on --ground #ADB1B5        8.3:1  (all body copy)
       --ink-soft #33383C on --ground           5.6:1  (secondary copy, facts)
       #FFFFFF on --signal #C4421B              5.2:1  (pill buttons)
       --print #2A2C2E on dial paper #DEDDD8   10.3:1  (scale numerals)
       --signal-print #9E3210 on dial paper     5.3:1  (tuned station name)
       --signal on --ground is 2.3:1: it is never used for text on the grey,
       only for fills, the needle and small markers.
       Rationale: every bit of colour on the page belongs to the product. The
       ground is sampled from the photo's own backdrop so the object sits on
       the page with no visible frame. Display stays conservative (about 2.6x
       body); the photograph is the loud thing. */
    --ground: #ADB1B5;
    --ground-rgb: 173, 177, 181;

    --ink: #16191B;
    --ink-rgb: 22, 25, 27;
    --ink-soft: #33383C;

    --signal: #C4421B;
    --signal-rgb: 196, 66, 27;
    --on-signal: #FFFFFF;
    --signal-print: #9E3210;   /* tuned station name on the dial paper */

    /* Dial print: ink for the scale printed on the radio's dial paper */
    --print: #2A2C2E;
    --print-rgb: 42, 44, 46;
    --lamp-rgb: 255, 176, 64;

    --font-display: 'Rethink Sans', system-ui, sans-serif;
    --font-body: 'Rethink Sans', system-ui, sans-serif;
    --font-mono: 'Spline Sans Mono', ui-monospace, monospace;

    --step-display: clamp(2rem, 1.2rem + 2.4vw, 2.85rem);
    --step-h2: clamp(1.4rem, 1.1rem + 0.8vw, 1.75rem);
    --step-body: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem);
    --step-fine: 0.8125rem;

    --gutter: clamp(1rem, 3.4vw, 3rem);
}

/* --------------------------------------------------------------------------
   BASE
   -------------------------------------------------------------------------- */

*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: auto; -webkit-text-size-adjust: 100%; }

body {
    margin: 0;
    background: var(--ground);
    color: var(--ink);
    font-family: var(--font-body);
    font-weight: 500;
    font-size: var(--step-body);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

a { color: inherit; }
img { display: block; max-width: 100%; }
:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; }

.visually-hidden {
    position: absolute !important;
    width: 1px; height: 1px;
    margin: -1px; padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
}

.page {
    min-height: 100vh;
    min-height: 100svh;
    display: grid;
    grid-template-rows: auto 1fr auto;
    overflow-x: clip;
}

/* --------------------------------------------------------------------------
   HEADER AND FOOTER
   -------------------------------------------------------------------------- */

.site-header {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    padding: 1.25rem var(--gutter);
}

.wordmark { display: inline-flex; color: var(--ink); text-decoration: none; border-radius: 6px; }
.wordmark svg { width: 9.5rem; height: auto; }

.site-header__batch {
    margin: 0 0 0 auto;
    font-family: var(--font-mono);
    font-size: var(--step-fine);
    letter-spacing: 0.02em;
    color: var(--ink-soft);
}

.pill {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.5rem;
    padding: 0.5rem 1.15rem;
    border: 0;
    border-radius: 999px;
    background: var(--signal);
    color: var(--on-signal);
    font: 700 0.9375rem/1 var(--font-body);
    text-decoration: none;
    cursor: pointer;
    white-space: nowrap;
    transition: transform 0.15s ease, background-color 0.15s ease;
}
.pill:hover { background: color-mix(in srgb, var(--signal) 88%, black); }
.pill:active { transform: translateY(1px) scale(0.98); }
.pill--big { min-height: 3rem; padding-inline: 1.4rem; font-size: 1rem; }

.site-footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 0.25rem 1.5rem;
    padding: 1rem var(--gutter) 1.25rem;
    font-size: var(--step-fine);
    color: var(--ink-soft);
}
.site-footer p { margin: 0; }

/* --------------------------------------------------------------------------
   HERO: copy and panel on the left, the object on the right
   -------------------------------------------------------------------------- */

.hero {
    display: grid;
    grid-template-columns: minmax(19rem, 25rem) minmax(0, 1fr);
    align-items: center;
    column-gap: clamp(1rem, 2vw, 2.5rem);
    padding: 0 var(--gutter);
}

.hero__copy { position: relative; z-index: 2; padding-bottom: 2.5rem; }

.eyebrow {
    margin: 0 0 0.75rem;
    font-family: var(--font-mono);
    font-size: var(--step-fine);
    color: var(--ink-soft);
}

.hero__title {
    margin: 0 0 2rem;
    font-family: var(--font-display);
    font-weight: 800;
    font-size: var(--step-display);
    line-height: 1.04;
    letter-spacing: -0.025em;
    text-wrap: balance;
}

/* The panel: readout strip, then the station that is tuned in */
.panel { position: relative; }

.readout {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
    padding: 0.55rem 0;
    margin-bottom: 1.1rem;
    border-block: 2px solid var(--ink);
    font-family: var(--font-mono);
    font-size: var(--step-fine);
    color: var(--ink);
}
.readout__freq { font-size: 1.6rem; line-height: 1; letter-spacing: -0.02em; min-width: 4.6ch; text-align: right; font-variant-numeric: tabular-nums; }
.readout__name { margin-left: 0.6rem; flex: 1; white-space: nowrap; overflow: hidden; }
.readout__bars { display: inline-flex; align-items: flex-end; gap: 3px; height: 1rem; align-self: center; }
.readout__bars i { width: 4px; background: var(--ink); opacity: 0.22; }
.readout__bars i:nth-child(1) { height: 20%; }
.readout__bars i:nth-child(2) { height: 40%; }
.readout__bars i:nth-child(3) { height: 60%; }
.readout__bars i:nth-child(4) { height: 80%; }
.readout__bars i:nth-child(5) { height: 100%; }
.readout__bars i.is-on { opacity: 1; }

.stations { list-style: none; margin: 0; padding: 0; }
.station + .station { margin-top: 2rem; }

.station__title {
    margin: 0 0 0.6rem;
    font-family: var(--font-display);
    font-weight: 700;
    font-size: var(--step-h2);
    line-height: 1.12;
    letter-spacing: -0.015em;
    text-wrap: balance;
}
.station p { margin: 0; max-width: 34ch; }

.facts {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    list-style: none;
    margin: 1rem 0 0;
    padding: 0;
}
.facts li {
    padding: 0.2rem 0.55rem;
    border: 1.5px solid rgba(var(--ink-rgb), 0.55);
    border-radius: 999px;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--ink);
}

/* Sign-up form on the launch station */
.signup { margin-top: 1.2rem; }
.signup__label { display: block; margin-bottom: 0.35rem; font-size: var(--step-fine); font-weight: 700; }
.signup__row { display: flex; gap: 0.5rem; }
.signup__input {
    flex: 1;
    min-width: 0;
    min-height: 3rem;
    padding: 0 1rem;
    border: 2px solid var(--ink);
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.55);
    color: var(--ink);
    font: 500 1rem var(--font-body);
}
.signup__input::placeholder { color: rgba(var(--ink-rgb), 0.6); }
.signup__input:focus-visible { outline-offset: 2px; background: #fff; }
.signup__input[aria-invalid="true"] { border-color: var(--signal); }
.signup__error { min-height: 1.4em; margin: 0.4rem 0 0; font-size: var(--step-fine); font-weight: 700; }
.signup__done { margin-top: 1.2rem; padding: 0.9rem 1.1rem; border-left: 4px solid var(--signal); background: rgba(255, 255, 255, 0.4); }
.signup__done strong { display: block; }
.signup__done:focus { outline: none; }
.signup__demo { display: block; margin-top: 0.3rem; font-size: var(--step-fine); color: var(--ink-soft); }

.panel__static { display: none; }

/* --------------------------------------------------------------------------
   THE OBJECT. Every overlay is positioned in percent of the photo, so the
   whole radio scales as one. Re-measure these for your own photo.
   -------------------------------------------------------------------------- */

.stage { position: relative; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-bottom: 1rem; }

.radio {
    /* Measured on radio-front (1536 x 1024) */
    --dial-x: 16.4%;  --dial-y: 26.4%;  --dial-w: 44.5%; --dial-h: 17%;
    --knob-x: 76.17%; --knob-y: 36.13%; --knob-d: 15.2%;   /* centre + diameter (of width) */
    --lamp-x: 65.63%; --lamp-y: 26.22%;
    --key-x: 76.27%;  --key-y: 68%;     --key-d: 7.5%;

    position: relative;
    container-type: inline-size;
    width: min(100%, 76rem, calc((100svh - 13rem) * 1.5));
    margin: 0 auto;
    aspect-ratio: 1536 / 1024;
    -webkit-user-select: none;
    user-select: none;
}

.radio__photo {
    width: 100%;
    height: auto;
    -webkit-user-drag: none;
    /* Feather the photo's edges into the ground so no frame shows */
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent), linear-gradient(180deg, transparent, #000 8%, #000 92%, transparent);
    -webkit-mask-composite: source-in;
    mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent), linear-gradient(180deg, transparent, #000 8%, #000 92%, transparent);
    mask-composite: intersect;
}

/* Dial window: the printed scale and the needle sit over the blank paper */
.dial {
    position: absolute;
    left: var(--dial-x); top: var(--dial-y);
    width: var(--dial-w); height: var(--dial-h);
    border-radius: 3px;
    overflow: hidden;
    touch-action: pan-y;
}
.has-js .dial { cursor: ew-resize; }
.dial__scale { position: absolute; inset: 0; width: 100%; height: 100%; mix-blend-mode: multiply; }
.dial__label { transition: fill 0.2s ease; }
.dial__label.is-tuned { fill: var(--signal-print); }
.dial__needle {
    position: absolute;
    top: 6%; bottom: 25%;   /* stops above the station names */
    left: 0;
    width: 3px;
    margin-left: -1.5px;
    border-radius: 2px;
    background: var(--signal);
    box-shadow: 2px 1px 3px rgba(var(--print-rgb), 0.35);
    will-change: transform;
}
html:not(.has-js) .dial__needle { display: none; }
html:not(.has-js) .hero { align-items: start; }
@media (min-width: 901px) and (min-aspect-ratio: 13/10) { html:not(.has-js) .stage { position: sticky; top: 0; align-self: start; min-height: 100vh; } }

.lamp {
    position: absolute;
    left: var(--lamp-x); top: var(--lamp-y);
    width: 6%; aspect-ratio: 1;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    background: radial-gradient(closest-side, rgba(var(--lamp-rgb), 1) 0 18%, rgba(var(--lamp-rgb), 0.55) 32%, rgba(var(--lamp-rgb), 0) 100%);
    mix-blend-mode: screen;
    opacity: 0;
    pointer-events: none;
}

.knob {
    position: absolute;
    left: var(--knob-x); top: var(--knob-y);
    width: var(--knob-d); aspect-ratio: 1;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    box-shadow: 0 0.5cqw 0.9cqw rgba(40, 14, 4, 0.45), 0 0.1cqw 0.15cqw rgba(40, 14, 4, 0.5);
    touch-action: none;
}
.has-js .knob { cursor: grab; }
.knob.is-dragging { cursor: grabbing; }
.knob img {
    width: 100%; height: 100%;
    border-radius: 50%;
    clip-path: circle(49.4%);
    -webkit-user-drag: none;
    will-change: transform;
}
/* Static key light from the upper left, so the highlight does not turn with the cap */
.knob::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 55%);
    pointer-events: none;
}
.radio:not(.is-pointer):has(.tuner:focus-visible) .knob { outline: 3px solid #FFFFFF; outline-offset: 1.2cqw; }

/* The preset key is drawn from the photo itself, so it can be pressed */
.key {
    position: absolute;
    left: var(--key-x); top: var(--key-y);
    width: var(--key-d); aspect-ratio: 1;
    transform: translate(-50%, -50%);
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: url("img/radio-front.webp") no-repeat;
    /* size = 100 / key-d; position = centre-of-key maths, see README */
    background-size: 1333.33% auto;
    background-position: 78.40% 70.28%;
    cursor: pointer;
    transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
}
.key:hover { filter: brightness(1.04); }
.key.is-pressed, .key:active { transform: translate(-50%, -50%) scale(0.94); filter: brightness(0.9); box-shadow: inset 0 0.2cqw 0.5cqw rgba(60, 40, 20, 0.35); }
.key:focus-visible { outline-offset: 0.35cqw; }

/* The real control for keyboard and assistive tech. It stays in the page
   and takes focus; the knob shows the focus ring. */
.tuner { position: absolute; left: var(--knob-x); top: var(--knob-y); width: 1px; height: 1px; opacity: 0; pointer-events: none; }
html:not(.has-js) .tuner, html:not(.has-js) .key, html:not(.has-js) .lamp { display: none; }

/* Station strip under the object */
/* Above the photo: the tall layout pulls the radio down over the strip */
.strip { position: relative; z-index: 2; }
.strip ol {
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    gap: 0.25rem 0.4rem;
    list-style: none;
    margin: 0.4rem 0 0;
    padding: 0;
}
.strip a {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.1rem;
    min-width: 5.5rem;
    padding: 0.4rem 0.6rem 0.45rem;
    border-radius: 0.6rem;
    font-size: var(--step-fine);
    font-weight: 700;
    text-decoration: none;
    color: var(--ink);
}
.strip a span { font-family: var(--font-mono); font-weight: 500; color: var(--ink-soft); }
.strip a:hover { background: rgba(255, 255, 255, 0.28); }
.strip a[aria-current="true"] { background: rgba(255, 255, 255, 0.42); }
.strip a[aria-current="true"] span { color: var(--ink); }

.hint {
    margin: 0.5rem 0 0;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--ink-soft);
}
html:not(.has-js) .hint { display: none; }

/* --------------------------------------------------------------------------
   TUNER MODE (JS). One station shows at a time, stacked in the same cell so
   the panel never changes height. Without JS every station stays listed.
   -------------------------------------------------------------------------- */

.has-js .stations { display: grid; }
.has-js .station { grid-area: 1 / 1; margin: 0; visibility: hidden; }
.has-js .station.is-active { visibility: visible; }
.has-js .panel__static {
    /* Scanline static, clipped to the station box (top and height are set
       by the script to match the station list). */
    display: block;
    position: absolute;
    left: 0;
    top: 4rem;
    width: 100%;
    height: 12rem;
    image-rendering: pixelated;
    opacity: 0;
    pointer-events: none;
}
html:not(.has-js) .readout { display: none; }

/* --------------------------------------------------------------------------
   TALL WINDOWS (squarer than 13:10): the object takes the full width on top,
   headline and panel sit side by side beneath it.
   -------------------------------------------------------------------------- */

@media (min-width: 901px) and (max-aspect-ratio: 13/10) {
    .hero {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        grid-template-rows: auto auto 1fr;
        grid-template-areas: "stage stage" "eyebrow panel" "title panel";
        align-items: start;
        align-content: center;
        column-gap: clamp(2rem, 5vw, 5rem);
    }
    .hero__copy { display: contents; }
    .stage { grid-area: stage; padding-bottom: 1.75rem; }
    .eyebrow { grid-area: eyebrow; }
    .hero__title { grid-area: title; }
    .panel { grid-area: panel; }
    .radio { width: min(100%, 80rem, calc((100svh - 31rem) * 1.5)); margin-block: -3% -4%; }
}

/* --------------------------------------------------------------------------
   SMALLER SCREENS: title, object, strip, then the panel
   -------------------------------------------------------------------------- */

@media (max-width: 900px) {
    .hero { grid-template-columns: minmax(0, 1fr); padding-bottom: 1rem; }
    .hero__copy { display: contents; }
    .eyebrow { order: 1; }
    .hero__title { order: 2; margin-bottom: 0.5rem; }
    .stage { order: 3; margin: 0 calc(-1 * var(--gutter)); }
    .panel { order: 4; margin-top: 0.75rem; }
    .radio { width: min(100%, 40rem); }
    .site-header__batch { display: none; }
    .site-header .pill { margin-left: auto; }
}

@media (max-width: 520px) {
    .radio { width: 112%; margin-left: -6%; }
    .strip ol { gap: 0.15rem; flex-wrap: nowrap; justify-content: space-between; padding: 0 var(--gutter); }
    .strip a { min-width: 0; padding: 0.35rem 0.5rem; font-size: 0.6875rem; }
    .wordmark svg { width: 8rem; }
    .signup__row { flex-direction: column; }
}

@media (prefers-reduced-motion: reduce) {
    .pill, .key { transition: none; }
}

A one-screen pre-launch page built round one product photograph. The product is the interface: visitors turn its tuning knob (or slide the needle, or use the arrow keys), static gives way to "stations" that tune in each part of the launch story, and the last station, launch day, holds the email sign-up. The product's own preset key doubles as the submit button.

The demo dresses it as Fennick, a two-person workshop in Glasgow launching a pocket FM radio in a numbered batch of 300. Every word, photo and colour is yours to change. This template is free.

Quick Start

  1. Copy index.html and the assets/ folder to your server. There is no build step; it deploys as static files.
  2. Rewrite the headline, the header batch line and the footer.
  3. Rewrite the stations (see Stations below). Keep the last one as your launch day with the form.
  4. Connect the form to your list provider (see The sign-up form).
  5. Swap the photograph for your own product (see Using It With Your Own Design).

Using It With Your Own Design

The page is built on three things: a flat ground colour, one front-on product photo on that same colour, and a set of overlays positioned in percentages of the photo.

  1. Shoot or choose the photo. Front-on, the product large in frame, on a flat neutral backdrop with no vignette. Export it at about 1500px wide.
  2. Match the ground. Sample the backdrop near the photo's edges and set --ground (and --ground-rgb) in assets/style.css to it. The photo's edges are feathered into the page so no frame shows.
  3. Place the overlays. In the .radio rule, five custom properties set where things sit on the photo, as percentages of its width and height:
    --dial-x: 16.4%;  --dial-y: 26.4%;  --dial-w: 44.5%; --dial-h: 17%;
    --knob-x: 76.17%; --knob-y: 36.13%; --knob-d: 15.2%;  /* centre, diameter */
    --lamp-x: 65.63%; --lamp-y: 26.22%;
    --key-x: 76.27%;  --key-y: 68%;     --key-d: 7.5%;
    
    Measure them in any image editor: pixel position divided by image size. Also update aspect-ratio on .radio and the image's width/height.
  4. The preset key is drawn from the photo itself so it can be pressed. If you move it, update .key's background-size (100 / key diameter, as a percentage) and background-position (left / (1 - d) horizontally and top / (1 - d * aspect) vertically, where left/top are the key's top-left corner as fractions of the photo).
  5. No knob on your product? Delete the .knob element. The dial window, wheel, keyboard and station strip still tune the page. No dial window either? Place [data-dial] over any blank strip of your product, or over the ground beneath it.

Stations

Each station is a list item in [data-stations]:

<li class="station" id="price" data-station data-freq="92.4" data-name="Price">
    <h2 class="station__title">£145, posted anywhere in the UK.</h2>
    <p>One or two sentences.</p>
    <ul class="facts"><li>£145</li><li>2-year guarantee</li></ul>
</li>
  • data-freq places it on the band (87.5 to 108). Keep stations two or three MHz apart so their scale labels stay readable.
  • data-name is the short label on the scale, the readout and the strip.
  • The id is the link target for the strip and for no-JS readers.
  • The printed scale, its markers and the station strip are rebuilt from this list on load. The static strip in index.html is only the no-JS fallback, so keep its links in step if you rename stations.
  • The last station (highest frequency) is launch day: the preset key and the header's "Join the list" link tune to it.

The sign-up form

The form is a front end only: it validates the address, swaps in a success message and announces it to screen readers, but sends nothing. To connect it:

  • Hosted form endpoint (Formspree, Buttondown, Mailchimp embedded form): set the form's action to the provider's URL and remove the e.preventDefault() in the submit handler, or fetch() the endpoint there before showing the success message.
  • Delete the .signup__demo line ("Demo form: nothing was sent.").

Settings

At the top of assets/script.js:

Constant Default What it does
BAND_MIN, BAND_MAX 87.5, 108 Ends of the scale
DEG_PER_MHZ 44 Knob rotation per MHz (lower = faster tuning)
CAPTURE 0.55 How far from a station the signal starts
DETENT 0.28 How far from a station the knob is gently pulled in

Palette

Five colour tokens in :root:

Token Value Role
--ground #ADB1B5 Page ground, sampled from the photo's backdrop
--ink #16191B All text (8.3:1 on the ground)
--ink-soft #33383C Secondary text (5.6:1)
--signal #C4421B The product's colour: pills, needle, station markers
--print #2A2C2E Ink for the scale printed on the dial

--signal is never used for text on the grey (2.3:1); white text on it measures 5.2:1.

Images

assets/img/ holds two WebP derivatives baked from the masters in assets/img-src/ by assets/img-manifest.json:

  • radio-front.webp: the product, front-on, on the grey backdrop
  • knob.webp: the tuning knob cap, shot from above, rotated by the script

Both are AI-generated images of a fictional product, made for this template. Replace the files (same names) or edit the manifest and re-bake with your own image tools.

Accessibility

  • The tuning dial is a native <input type="range"> under the knob. It takes focus (the knob shows the focus ring) and reports aria-valuetext such as "96.8 FM, Build". Arrow keys and Page Up/Down move station to station; Shift+arrows fine-tune by 0.1 MHz; Home and End jump to the ends.
  • Stations that are not tuned in are inert, so only the visible station's content is reachable.
  • The station strip is a list of real links; the preset key is a labelled button.
  • Sign-up errors are tied to the field with aria-describedby; success moves focus to the confirmation and is announced in a polite live region.
  • prefers-reduced-motion: the dial works the same, settles instantly, and shows no static, blur or needle sweep.
  • Without JavaScript (or if the GSAP CDN is blocked) every station is listed in order beside the product and the form is a plain form.

Dependencies

  • GSAP core 3.12+ from jsDelivr (the demo is built against 3.15.0). No plugins.
  • Rethink Sans (500, 700, 800) and Spline Sans Mono (500), self-hosted in assets/fonts/ (see Fonts).

Fonts

Rethink Sans and Spline Sans Mono are self-hosted in assets/fonts/ as the latin subsets Google Fonts serves, declared by the @font-face rules at the top of assets/style.css. Rethink Sans is a single variable file declared at the three weights the page uses. Both files are preloaded in the <head> because both appear on the first screen. Licence: SIL Open Font License 1.1, full text in assets/fonts/OFL.txt (Rethink Sans: Copyright 2022 The Rethink Sans Project Authors; Spline Sans Mono: Copyright 2022 The Spline Sans Mono Project Authors).

To swap a face, put your .woff2 in assets/fonts/, point its @font-face rule and the --font-* token at it, and change the preloads in the <head>. To use Google Fonts instead, delete the @font-face rules and the preloads and add the Google Fonts stylesheet link.

Teardown

Everything runs inside one gsap.context stored on window.gsapContext. window.gsapContext.revert() kills tweens, removes every listener and the static ticker, and restores the station list to its plain state.