# Effort slider

> A stepped slider for how hard a model thinks, whose last step turns the track into a twinkling night sky.

A small card for choosing effort, from Faster to Smarter in named steps. The thumb is a pink brain that glides from stop to stop while the track fills pink to violet behind it, and the step’s name changes letter by letter. Reach the last step and the brain grows blue-violet in a flash, rays of specks shoot out around it, and the track becomes a mosaic of squares that keeps twinkling.

A React component by Bruno Delorence: https://brunodelorence.com/components/effort-slider. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Label**: Names the setting. The slider is labelled by it, so a screen reader hears “Effort, High”.
2. **Level name**: The current step’s name. It changes letter by letter: the old one rises and blurs away as the new one comes into focus.
3. **Help button**: Opens a short line on what the setting does. Leave `help` empty to drop it.
4. **Track**: Takes a click or a drag anywhere along it; the thumb follows, then snaps to the nearest step.
5. **Fill**: One gradient across the whole track, pink to violet, clipped at the thumb: the higher, the more violet its bright end.
6. **Step**: A dot per step, so the stops are visible before the thumb reaches them.
7. **Thumb**: The brain, a real `role="slider"`: arrows, Page Up/Down, Home and End move it, and it glides on a 56 ms ease.
8. **Recommended**: A taller tick and a label under the step most people should keep.

## When to use

- Choosing how hard a model should think before it answers: a trade of speed for quality in a few named steps.
- Ordered settings of 3 to 7 steps where each step has a name worth reading: `Low`, `Medium`, `High`.
- A setting with a sensible default to point at: the `recommended` tick says where most people should stay.
- Product moments where the top step deserves a little celebration, and the slider is seen rarely enough to enjoy it.

## When not to use

- Continuous values like volume or a price range. Use a plain slider with numbers.
- Two or three unordered options. A segmented control shows them all at once.
- Settings changed often in a dense form: the payoff gets old, and the steps’ names cost reading time.
- When the steps’ effect isn’t explained anywhere. Keep the help text, or say it next to the slider.

## Installation

Install it with the shadcn CLI, into components/effort-slider of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/effort-slider.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { EffortSlider } from './effort-slider';

export function ModelSettings() {
  const [effort, setEffort] = useState(1);
  return (
    <EffortSlider
      value={effort}
      onChange={(index) => setEffort(index)}
      recommended={1}
      autoplay={false}
    />
  );
}
```

## States

- **Lowest**: The fill is only a short pink stub behind the thumb.
- **Recommended**: On the marked step the value text adds “recommended” for screen readers.
- **High**: Past the middle the fill’s bright end turns violet.
- **Top step, plain**: With `galaxy={false}` the last step is just another step.
- **Payoff**: The last step’s reward: the brain turns blue-violet and the track becomes a patch of night sky.
- **Themed**: A theme swaps the thumb, the fill and the payoff, and names the steps to suit.

## Examples

### Black hole

theme="galaxy": a spiral that grows and spins faster each step, then collapses. On a light page it becomes a black hole that pulls the fill in; on a dark one, a white hole that throws light out.

```tsx
import { EffortSlider } from './effort-slider';

export function BlackHole() {
  return (
    <EffortSlider
      theme="galaxy"
      label="Gravity"
      minLabel="Drift"
      maxLabel="Collapse"
      recommended={2}
      help="How much mass the galaxy gathers. At the top it collapses."
    />
  );
}
```

### Race car

theme="formula": a car on tarmac with kerbs streaming past. The last step opens its rear flap for the overtake, with light trails, sparks and a whoosh down the track.

```tsx
import { EffortSlider } from './effort-slider';

export function RaceCar() {
  return (
    <EffortSlider
      theme="formula"
      label="Pace"
      minLabel="Steady"
      maxLabel="Flat out"
      recommended={2}
      help="How hard the car is pushed, from the pit lane to an overtake."
    />
  );
}
```

### Rocket launch

theme="missile": the flame grows from a wisp to a full burn. At the top the rocket lifts in a bright plume, with shockwave rings and smoke billowing along the track.

```tsx
import { EffortSlider } from './effort-slider';

export function RocketLaunch() {
  return (
    <EffortSlider
      theme="missile"
      label="Thrust"
      minLabel="Idle"
      maxLabel="Full burn"
      recommended={3}
      recommendedLabel="Nominal"
      help="How much thrust the engine gives, from armed on the pad to hypersonic."
    />
  );
}
```

### Sound barrier

theme="jet": the wings sweep back and the afterburner lights step by step. At Mach 1 a vapour cone blooms over the jet, ring shocks spread and the track becomes a streaked sky.

```tsx
import { EffortSlider } from './effort-slider';

export function SoundBarrier() {
  return (
    <EffortSlider
      theme="jet"
      label="Speed"
      minLabel="Subsonic"
      maxLabel="Supersonic"
      recommended={3}
      help="How fast the jet flies. The top step breaks the sound barrier."
    />
  );
}
```

## How it works

- The thumb glides on an exponential ease: each frame closes the same share of the gap, with a time constant of 56 ms, so a step lands in about 200 ms with no overshoot. The place is a single custom property, --pos, from 0 to 1, written once per frame.
- Everything that follows the thumb reads --pos: the thumb’s transform, the clip that ends the fill under its middle, and the size of the mask that fades the fill in from the left. One style write moves them all.
- The fill is one pink-to-violet gradient across the whole track, clipped at the thumb and faded over the filled length, so the further it goes the more violet its bright end.
- The step’s name changes letter by letter: the old one rises and blurs away in 110 ms while each new letter comes up into focus over 170 ms, 22 ms after the one before.
- The brain is one path for the left half and its mirror, drawn as a thick round stroke under its fill, so the stroke becomes the outline and fills the gap, the fold and the notches. The last step’s brain sits in the same drawing and crossfades in as the whole grows 1.3 times.
- The last step draws on two small canvases: the track’s mosaic (5 rows of 3 px squares, 4 px apart, dimmer and sparser to the left) and 100 px around the thumb for the rays, the sparks on the brain and the ring of specks that flies out on arrival. They twinkle in steps 7.5 times a second; only the first second of the burst draws every frame.
- The flash on arrival and the glow that stays are CSS radial gradients; nothing on the canvases runs off screen, in a hidden tab or under reduced motion.
- While on screen and untouched, the demo climbs one step every 525 ms, rests on the last for 3.4 s and starts again from the bottom. Any key or press hands it to the reader at the step it was on.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. The night sky, the glow and the brains are the same on both.
- The theme prop swaps the look and keeps the slider: a galaxy, a racing car, a missile or a jet, each with its own thumb drawn in SVG, its own fill and its own payoff painted on the same two canvases. The thumb’s parts read --pos, so they change with the glide itself: the spiral grows, the flame lengthens, the wings sweep back. What changes only on the last step has a transition of its own.
- A theme’s loops (the spin, the streaming kerbs, the flicker) speed up one step at a time through the Web Animations API: updatePlaybackRate changes their pace without a jump in their phase.
- The galaxy’s payoff differs by scheme: a black hole on a light page, which pulls the fill into a funnel (a clip-path whose points follow a registered custom property, so only the warp transitions) while motes stream into it; a white hole on a dark one, which throws rings, rays and light out. The slider learns the scheme from a light-dark() colour it reads back.

## API reference

- **theme** (`'brain' | 'galaxy' | 'formula' | 'missile' | 'jet'`, default `'brain'`): The look: the brain and its night sky, or a spiral galaxy that collapses into a black (light) or white (dark) hole, a racing car that overtakes, a missile that launches, or a jet that breaks the sound barrier. Each names its steps to suit.
- **levels** (`string[]`, default `['Low', 'Medium', 'High', 'Extra', 'Max', 'Galaxy']`): The steps’ names, from the fastest to the smartest. Without it, the theme’s own (for the brain, these).
- **value** (`number`): The chosen step’s index, for a controlled slider.
- **defaultValue** (`number`, default `0`): The step chosen at first, for an uncontrolled slider.
- **onChange** (`(index: number, level: string) => void`): Called with the new step’s index and name when the reader picks one.
- **recommended** (`number`, default `1`): The step marked as recommended, with a taller tick and a label under it; -1 for none.
- **label** (`string`, default `'Effort'`): The slider’s name, before the step’s name.
- **minLabel** (`string`, default `'Faster'`): The words at the left end.
- **maxLabel** (`string`, default `'Smarter'`): The words at the right end.
- **recommendedLabel** (`string`, default `'Recommended'`): The words under the recommended tick.
- **help** (`string`, default `'How long the model thinks before it answers. Higher is slower, and smarter.'`): What the help button explains; an empty string leaves the button out.
- **galaxy** (`boolean`, default `true`): The last step is the theme’s payoff (the brain’s night sky); false keeps it like the others.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo climbs the steps and rests on the last.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The thumb is the slider (role="slider"), named by the visible label, with aria-valuenow and an aria-valuetext of the step’s name (“Medium, recommended”). Arrows, Page Up/Down, Home and End move it; a click or drag on the track does too.
- The focus ring is drawn around the brain, and the help button shows its explanation as a tooltip on hover and keyboard focus (aria-describedby).
- The visible step name, the end words, the stops, the rays and the canvases are hidden from screen readers, which hear the slider’s value instead.
- Under reduced motion the thumb jumps, the name swaps at once, there is no flash or burst, the last step shows a still sky, and the demo never plays.

## Source

### effort-slider/effort-slider.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type RefObject,
} from 'react';
import { Brain } from './brain';
import { Galaxy, TRACK_WIDTH } from './galaxy';
import type { Engine } from './payoff';
import { LOOKS, type Theme } from './themes';
import { useAutoplay } from './use-autoplay';
import styles from './effort-slider.module.css';
import looks from './themes.module.css';

/*
 * Effort slider: how hard a model should think, from Faster to Smarter, in a few named steps. The
 * thumb is a small pink brain; the track fills behind it in pink turning violet. The last step is
 * the payoff: the brain grows and turns blue-violet in a flash of light, rays of specks shoot out
 * around it, and the track becomes a mosaic of twinkling squares, a patch of night sky.
 *
 * - The thumb glides to each stop on an exponential ease (each frame closes the same share of the
 *   gap, a time constant of 56 ms), written as one custom property, --pos, from 0 to 1. The thumb,
 *   the fill's edge and the fill's fade all read it, so a frame is one style write.
 * - The fill is one gradient across the whole track (pink at the left, violet at the right),
 *   clipped at the thumb, with a mask that fades it in from the left over the filled length. So
 *   the further it goes, the more violet its bright end.
 * - The level's name changes letter by letter: the old one rises and blurs away while the new one
 *   comes up into focus, each letter a beat after the one before.
 * - The last step draws on two canvases (galaxy.ts): the mosaic in the track, and the rays, sparks
 *   and flying ring around the thumb. The flash and the glow are CSS gradients.
 * - It is a real slider: the thumb has role="slider" and takes arrows, Page Up/Down, Home and End;
 *   the track takes a click or a drag, which the thumb follows and then snaps to the nearest stop.
 * - While on screen and untouched, a demo climbs the steps one by one, rests on the last, and
 *   starts again from the bottom.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. The night sky is the same on both.
 * - Themes (themes.ts): the brain is the default; the galaxy, formula, missile and jet themes swap
 *   the thumb's drawing (one that changes step by step), the fill (themes.module.css) and the last
 *   step's payoff (a painter for the same two canvases), and name the steps to suit. Their looping
 *   animations run faster with each step: the slider sets their playback rate.
 */

const LEVELS = ['Low', 'Medium', 'High', 'Extra', 'Max', 'Galaxy'];

/** The thumb's ease: each frame closes the gap by 1 - e^(-dt / TAU). */
const TAU = 56;
/** The thumb is 16 px wide and stays inside the 196 px track: its middle runs 8 px to 188 px. */
const RAIL = TRACK_WIDTH - 16;

/** The demo: how long it rests at the bottom, between steps, and on the last. */
const DEMO_FIRST = 700;
const DEMO_STEP = 525;
const DEMO_REST = 3400;
const DEMO_AGAIN = 1100;

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

/**
 * Whether the card is drawn dark, for the themes whose canvases or words differ by scheme. The
 * themes' stylesheet sets the card's caret colour to light-dark(black, white): read it back when
 * the system's scheme, the page's theme or scheme attributes, or the colorScheme prop change.
 */
function useDark(card: RefObject<HTMLElement | null>, active: boolean, colorScheme?: string) {
  const [dark, setDark] = useState(false);
  useEffect(() => {
    const element = card.current;
    if (!active || !element) return;
    const read = () => setDark(getComputedStyle(element).caretColor === 'rgb(255, 255, 255)');
    read();
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    media.addEventListener('change', read);
    const observer = new MutationObserver(read);
    observer.observe(document.documentElement, {
      attributes: true,
      subtree: true,
      attributeFilter: ['data-theme', 'data-color-scheme'],
    });
    return () => {
      media.removeEventListener('change', read);
      observer.disconnect();
    };
  }, [card, active, colorScheme]);
  return active && dark;
}

/** The level's name, changed letter by letter. */
function LevelName({ text, special }: { text: string; special: boolean }) {
  type Entry = { id: number; text: string; special: boolean; out: boolean };
  const [entries, setEntries] = useState<Entry[]>([{ id: 0, text, special, out: false }]);
  const [shown, setShown] = useState(text);
  if (text !== shown) {
    // A new name: the current one leaves, the new one arrives (state adjusted while rendering).
    setShown(text);
    setEntries((now) => {
      const id = now[now.length - 1].id + 1;
      const leaving = now.filter((entry) => !entry.out).map((entry) => ({ ...entry, out: true }));
      return [...leaving, { id, text, special, out: false }];
    });
  }
  return (
    <span className={styles.level} data-part="level" aria-hidden="true">
      {entries.map((entry) => (
        <span
          key={entry.id}
          className={entry.out ? styles.levelOut : styles.levelIn}
          data-special={entry.special || undefined}
          onAnimationEnd={
            entry.out
              ? (event) => {
                  // The last letter has gone: drop the old name.
                  if (event.target === event.currentTarget.lastElementChild)
                    setEntries((now) => now.filter((item) => item.id !== entry.id));
                }
              : undefined
          }
        >
          {[...entry.text].map((letter, index) => (
            <span key={index} className={styles.letter} style={{ '--i': index } as CSSProperties}>
              {letter === ' ' ? ' ' : letter}
            </span>
          ))}
        </span>
      ))}
    </span>
  );
}

export function EffortSlider({
  theme = 'brain',
  levels: levelsProp,
  value,
  defaultValue = 0,
  onChange,
  recommended = 1,
  label = 'Effort',
  minLabel = 'Faster',
  maxLabel = 'Smarter',
  recommendedLabel = 'Recommended',
  help = 'How long the model thinks before it answers. Higher is slower, and smarter.',
  galaxy = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The look: the brain, or a galaxy, a racing car, a missile or a jet. */
  theme?: Theme;
  /** The steps' names, from the fastest to the smartest (each theme has its own). */
  levels?: string[];
  /** The chosen step's index, for a controlled slider; leave it out and use defaultValue. */
  value?: number;
  /** The step chosen at first, for an uncontrolled slider. */
  defaultValue?: number;
  /** Called with the new step's index and name when the reader picks one. */
  onChange?: (index: number, level: string) => void;
  /** The step marked as recommended (a taller tick and a label under it); -1 for none. */
  recommended?: number;
  /** The slider's name, before the step's name. */
  label?: string;
  /** The words at either end. */
  minLabel?: string;
  maxLabel?: string;
  /** The words under the recommended tick. */
  recommendedLabel?: string;
  /** What the help button explains; empty for no button. */
  help?: string;
  /** The last step is the theme's payoff (the brain's night sky). */
  galaxy?: boolean;
  /** While on screen and untouched, a demo climbs the steps and rests on the last. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const look = theme === 'brain' ? null : LOOKS[theme];
  const card = useRef<HTMLDivElement>(null);
  const dark = useDark(card, look !== null, colorScheme);
  const levels =
    levelsProp ??
    (look
      ? [
          ...look.levels.slice(0, -1),
          (dark && look.darkLast) || look.levels[look.levels.length - 1],
        ]
      : LEVELS);
  const count = Math.max(levels.length, 2);
  const last = count - 1;
  const [inner, setInner] = useState(() => clamp(Math.round(defaultValue), 0, last));
  const index = clamp(Math.round(value ?? inner), 0, last);
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [demoIndex, setDemoIndex] = useState<number | null>(null);
  const shown = demoIndex ?? index;
  const sky = galaxy && shown === last;

  // Each arrival on the last step plays the burst once (state adjusted while rendering).
  const [arrivals, setArrivals] = useState(0);
  const [wasSky, setWasSky] = useState(sky);
  if (sky !== wasSky) {
    setWasSky(sky);
    if (sky) setArrivals((now) => now + 1);
  }

  const id = useId();
  const track = useRef<HTMLDivElement>(null);
  const thumb = useRef<HTMLSpanElement>(null);
  const mosaic = useRef<HTMLCanvasElement>(null);
  const burst = useRef<HTMLCanvasElement>(null);
  const sky2d = useRef<Engine>(null);

  /** The thumb's place, 0 to 1, where it is and where it is going. */
  const [startPos] = useState(() => shown / last);
  const pos = useRef(startPos);
  const target = useRef(startPos);
  const frame = useRef(0);
  const dragging = useRef(false);
  /** The step a drag or a key last picked, before React has rendered it. */
  const picked = useRef(index);
  const latest = useRef({ index, controlled: value !== undefined });

  // Synced when the step really changes, so a render still on its way never undoes a pick.
  const controlled = value !== undefined;
  useEffect(() => {
    latest.current = { index, controlled };
  }, [index, controlled]);

  const glide = () => {
    if (frame.current) return;
    const element = card.current;
    if (!element) return;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    let before = performance.now();
    const step = (now: number) => {
      const gap = target.current - pos.current;
      // A frame's time can be a little before the glide began: never step backwards.
      const share = reduced ? 1 : 1 - Math.exp(-Math.min(64, Math.max(0, now - before)) / TAU);
      before = now;
      pos.current = Math.abs(gap) < 0.0004 ? target.current : pos.current + gap * share;
      element.style.setProperty('--pos', pos.current.toFixed(4));
      frame.current = pos.current === target.current ? 0 : requestAnimationFrame(step);
    };
    frame.current = requestAnimationFrame(step);
  };
  useEffect(
    () => () => {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    },
    [],
  );

  // A new step, from a key, a prop or the demo: the thumb glides there (a drag leads it itself).
  useEffect(() => {
    if (dragging.current) return;
    target.current = shown / last;
    glide();
  }, [shown, last]);

  const pick = (next: number) => {
    next = clamp(next, 0, last);
    picked.current = next;
    if (next === latest.current.index) return;
    if (!latest.current.controlled) {
      latest.current.index = next;
      setInner(next);
    }
    onChange?.(next, levels[next]);
  };

  /** The pointer's place along the rail, 0 to 1. */
  const along = (clientX: number) => {
    const element = track.current!;
    const rect = element.getBoundingClientRect();
    const unit = element.offsetWidth / TRACK_WIDTH || 1;
    const scale = rect.width / element.offsetWidth || 1;
    return clamp(((clientX - rect.left) / scale / unit - 8) / RAIL, 0, 1);
  };

  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    thumb.current?.focus({ preventScroll: true });
    event.currentTarget.setPointerCapture(event.pointerId);
    dragging.current = true;
    const at = along(event.clientX);
    target.current = at;
    glide();
    pick(Math.round(at * last));
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!dragging.current) return;
    const at = along(event.clientX);
    target.current = at;
    glide();
    pick(Math.round(at * last));
  };
  const onPointerUp = () => {
    if (!dragging.current) return;
    dragging.current = false;
    // Snap to the step picked, or back to the one a controlling parent kept.
    const { index: now, controlled } = latest.current;
    target.current = (controlled ? now : picked.current) / last;
    glide();
  };

  const onKeyDown = (event: ReactKeyboardEvent) => {
    const from = latest.current.index;
    const moves: Record<string, number> = {
      ArrowRight: from + 1,
      ArrowUp: from + 1,
      PageUp: from + 1,
      ArrowLeft: from - 1,
      ArrowDown: from - 1,
      PageDown: from - 1,
      Home: 0,
      End: last,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    pick(moves[event.key]);
  };

  // The payoff's canvases, made once per theme; told when the last step is reached or left.
  useEffect(() => {
    if (!mosaic.current || !burst.current || !track.current) return;
    const Painter = theme === 'brain' ? Galaxy : LOOKS[theme].Payoff;
    const instance = new Painter(mosaic.current, burst.current, track.current);
    sky2d.current = instance;
    return () => {
      instance.destroy();
      sky2d.current = null;
    };
  }, [theme]);
  useEffect(() => {
    sky2d.current?.scheme?.(dark);
  }, [dark, theme]);
  useEffect(() => {
    sky2d.current?.set(sky, arrivals > 0);
  }, [sky, arrivals, theme]);

  // A theme's loops (named pace…) speed up step by step: from 1 at the bottom to 4 at the top.
  useEffect(() => {
    if (!look || !card.current) return;
    const rate = 1 + (3 * shown) / last;
    for (const animation of card.current.getAnimations({ subtree: true }))
      if (animation instanceof CSSAnimation && animation.animationName.includes('pace'))
        animation.updatePlaybackRate(rate);
  }, [look, shown, last, sky]);

  // The demo: one step up every beat from wherever the thumb is, a rest on the last, and again
  // from the bottom. The reader's first touch ends it, keeping the step it was on.
  const demo = autoplay && playing && count > 1;
  useEffect(() => {
    if (!demo) return;
    let at = latest.current.index;
    let timer = 0;
    const show = (next: number) => {
      at = next;
      setDemoIndex(next);
    };
    const next = () => {
      if (at >= last) {
        timer = window.setTimeout(() => {
          show(0);
          timer = window.setTimeout(next, DEMO_AGAIN);
        }, DEMO_REST);
        return;
      }
      timer = window.setTimeout(() => {
        show(at + 1);
        next();
      }, DEMO_STEP);
    };
    show(at);
    timer = window.setTimeout(next, at >= last ? 0 : DEMO_FIRST - DEMO_STEP);
    return () => {
      window.clearTimeout(timer);
      setDemoIndex(null);
      // An uncontrolled slider keeps the step the demo was on. The reader's input that ended the
      // demo is handled after this (React runs it between the capture and the bubble phase), so
      // the step it picks wins.
      if (!latest.current.controlled) {
        latest.current.index = at;
        setInner(at);
      }
    };
  }, [demo, last]);

  const tick = recommended >= 0 && recommended <= last ? recommended : -1;
  const stop = (step: number) => ({ '--stop': step / last }) as CSSProperties;
  const name = levels[shown] ?? '';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div
        ref={card}
        className={look ? `${styles.card} ${looks.themed}` : styles.card}
        data-part="card"
        data-sky={sky || undefined}
        data-theme={look ? theme : undefined}
        data-dark={dark || undefined}
        style={{ '--pos': startPos } as CSSProperties}
      >
        <div className={styles.header}>
          <span id={`${id}-label`} className={styles.name} data-part="label">
            {label}
          </span>
          <LevelName text={name} special={sky} />
          {help && (
            <button
              type="button"
              className={styles.help}
              data-part="help"
              aria-label={`About ${label.toLowerCase()}`}
              aria-describedby={`${id}-help`}
            >
              <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
                <circle cx="12" cy="12" r="10" />
                <path d="M9.2 9.3a2.9 2.9 0 0 1 5.6 0.9c0 1.9-2.8 2.5-2.8 4.1" />
                <path d="M12 17.6h0.01" />
              </svg>
              <span id={`${id}-help`} role="tooltip" className={styles.tip}>
                {help}
              </span>
            </button>
          )}
        </div>

        <div className={styles.ends} data-part="ends" aria-hidden="true">
          <span>{minLabel}</span>
          <span>{maxLabel}</span>
        </div>

        <div
          ref={track}
          className={styles.control}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
        >
          <div className={styles.track} data-part="track" aria-hidden="true">
            {look ? (
              <div className={`${styles.fill} ${looks.warp}`}>
                <span className={looks.texture} />
                <span className={looks.lines} />
              </div>
            ) : (
              <div className={styles.fill} data-part="fill" />
            )}
            <div className={look ? `${styles.sky} ${looks.sky} ${looks.warp}` : styles.sky}>
              <canvas ref={mosaic} className={styles.mosaic} />
            </div>
            {levels.map((_, step) =>
              step === tick ? (
                <span key={step} className={styles.tick} data-part="tick" style={stop(step)} />
              ) : (
                <span key={step} className={styles.dot} data-part="stop" style={stop(step)} />
              ),
            )}
          </div>
          <div className={styles.rail}>
            <span
              ref={thumb}
              className={styles.thumb}
              data-part="thumb"
              role="slider"
              tabIndex={0}
              aria-labelledby={`${id}-label`}
              aria-valuemin={0}
              aria-valuemax={last}
              aria-valuenow={shown}
              aria-valuetext={`${name}${shown === tick ? `, ${recommendedLabel.toLowerCase()}` : ''}`}
              aria-orientation="horizontal"
              onKeyDown={onKeyDown}
            >
              <span
                className={look ? `${styles.halo} ${looks.glow}` : styles.halo}
                aria-hidden="true"
              />
              {look ? (
                <look.Icon id={`${id}-${theme}`} />
              ) : (
                <Brain
                  className={styles.brain}
                  pinkClass={styles.pink}
                  galaxyClass={styles.galaxy}
                  gradient={`${id}-sky`}
                />
              )}
              {sky && arrivals > 0 && (
                <span
                  key={arrivals}
                  className={look ? `${styles.flash} ${looks.flash}` : styles.flash}
                  aria-hidden="true"
                />
              )}
              <canvas
                ref={burst}
                className={look ? `${styles.burst} ${looks.box}` : styles.burst}
                style={
                  look
                    ? ({ '--rx': look.reach[0], '--ry': look.reach[1] } as CSSProperties)
                    : undefined
                }
                aria-hidden="true"
              />
            </span>
          </div>
        </div>

        <div className={styles.under} aria-hidden="true">
          {tick >= 0 && (
            <span
              className={styles.recommended}
              data-part="recommended"
              data-edge={tick === 0 ? 'start' : tick === last ? 'end' : undefined}
              style={stop(tick)}
            >
              {recommendedLabel}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}
```

### effort-slider/effort-slider.module.css

```css
/* Effort slider (effort-slider.tsx): a small card on a stage. At night the stage is near-black and
   the card a step lighter; by day the card is white on a pale grey.

   Every size is in --u, a px of the card at its own size (13 px type). The stage sets the card's
   type size from its own width (a container query), so the whole card scales as one drawing.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). The last step's night sky, its glow and the brain
   are the same on both.

   Memory. The stage is one layout boundary sized from outside. The thumb moves by a transform; the
   fill by a clip and a mask that read the same --pos; the two canvases are small (the track, and
   100 px around the thumb) and only redraw 7.5 times a second while the last step is on. */

/* A length that stays the card's px wherever it is read, even in smaller type. */
@property --u {
  syntax: '<length>';
  inherits: true;
  initial-value: 1px;
}

.root {
  /* The stage, the card and its edge. */
  --stage: #f4f4f5;
  --card: #fff;
  --edge: rgb(0 0 0 / 0.08);
  --lift: rgb(0 0 0 / 0.06);
  /* The words: the slider's name and the ends, the recommended label, the step's name. */
  --muted: #747474;
  --muted-2: #6c6c6c;
  --ink: #171717;
  /* The last step's name. */
  --sky-ink: #1d7fc6;
  /* The help icon. */
  --icon: #7a7a7a;
  /* The track, its dots and the recommended tick. */
  --track: #ececec;
  --dot: rgb(0 0 0 / 0.2);
  --tick: rgb(0 0 0 / 0.3);
  /* The fill: pink at the track's left, violet at its right. */
  --fill-from: #e489bb;
  --fill-to: #8b70dc;
  /* The help bubble. */
  --tip: #1d1d1f;
  --tip-ink: #f3f3f3;
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 1890 / 1032;
  min-height: 260px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--edge);
  -webkit-tap-highlight-color: transparent;
  display: grid;
  place-items: center;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f4f4f5, #101010);
    --card: light-dark(#fff, #171717);
    --edge: light-dark(rgb(0 0 0 / 0.08), #262626);
    --lift: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.28));
    --muted: light-dark(#747474, #7e7e7e);
    --muted-2: light-dark(#6c6c6c, #8a8a8a);
    --ink: light-dark(#171717, #f3f3f3);
    --sky-ink: light-dark(#1d7fc6, #9fd9ff);
    --icon: light-dark(#7a7a7a, #757575);
    --track: light-dark(#ececec, #262626);
    --dot: light-dark(rgb(0 0 0 / 0.2), rgb(255 255 255 / 0.2));
    --tick: light-dark(rgb(0 0 0 / 0.3), rgb(255 255 255 / 0.27));
    --fill-from: light-dark(#e489bb, #975179);
    --fill-to: light-dark(#8b70dc, #6b55a4);
    --tip: light-dark(#1d1d1f, #2b2b2d);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The card: 220 px wide at 13 px type, scaled with the stage (from 13 px on a phone to 26). */
.card {
  --u: calc(1em / 13);
  position: relative;
  box-sizing: border-box;
  width: calc(220 * var(--u));
  padding: calc(11.3 * var(--u)) calc(11 * var(--u)) calc(11.9 * var(--u));
  border: max(1px, var(--u)) solid var(--edge);
  border-radius: calc(8 * var(--u));
  background: var(--card);
  box-shadow:
    0 calc(1 * var(--u)) calc(2 * var(--u)) var(--lift),
    0 calc(5 * var(--u)) calc(14 * var(--u)) var(--lift);
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    sans-serif;
  font-size: clamp(13px, 2.75cqi, 26px);
  -webkit-font-smoothing: antialiased;
  user-select: none;
  -webkit-user-select: none;
}

/* "Effort  Low", and the help button at the right. */
.header {
  display: flex;
  align-items: baseline;
  height: calc(16 * var(--u));
  font-size: calc(13 * var(--u));
  line-height: calc(16 * var(--u));
  letter-spacing: 0.015em;
}
.name {
  color: var(--muted);
}
.level {
  display: inline-grid;
  margin-left: calc(8 * var(--u));
  color: var(--ink);
}
.levelIn,
.levelOut {
  grid-area: 1 / 1;
  white-space: nowrap;
}
.levelIn[data-special],
.levelOut[data-special] {
  color: var(--sky-ink);
}
/* Each letter rises into focus a beat after the one before; the old name rises and blurs away. */
.letter {
  display: inline-block;
}
.levelIn .letter {
  animation: letterIn 170ms cubic-bezier(0.2, 0.7, 0.3, 1) calc(var(--i) * 22ms) both;
}
.levelOut .letter {
  animation: letterOut 110ms ease-in calc(var(--i) * 10ms) both;
}
@keyframes letterIn {
  from {
    opacity: 0;
    transform: translateY(0.3em);
    filter: blur(0.14em);
  }
}
@keyframes letterOut {
  to {
    opacity: 0;
    transform: translateY(-0.3em);
    filter: blur(0.14em);
  }
}

.help {
  position: absolute;
  top: calc(13 * var(--u));
  right: calc(13 * var(--u));
  display: grid;
  place-items: center;
  width: calc(13 * var(--u));
  height: calc(13 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--icon);
  cursor: help;
}
.help svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  overflow: visible;
}
.help:focus {
  outline: none;
}
.help:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.tip {
  position: absolute;
  right: calc(-4 * var(--u));
  bottom: calc(100% + 6 * var(--u));
  z-index: 3;
  width: max-content;
  max-width: calc(170 * var(--u));
  padding: calc(5 * var(--u)) calc(8 * var(--u));
  border-radius: calc(6 * var(--u));
  background: var(--tip);
  color: var(--tip-ink);
  font-size: calc(11 * var(--u));
  line-height: 1.35;
  text-align: left;
  opacity: 0;
  transform: translateY(calc(2 * var(--u)));
  transition:
    opacity 140ms ease,
    transform 140ms ease;
  pointer-events: none;
}
.help:hover .tip,
.help:focus-visible .tip {
  opacity: 1;
  transform: none;
}

/* "Faster … Smarter". */
.ends {
  display: flex;
  justify-content: space-between;
  height: calc(14 * var(--u));
  margin-top: calc(21.3 * var(--u));
  color: var(--muted);
  font-size: calc(12 * var(--u));
  line-height: calc(14 * var(--u));
  letter-spacing: 0.01em;
}

/* The control: the track, and the thumb's rail over it. */
.control {
  position: relative;
  height: calc(20 * var(--u));
  margin-top: calc(11.4 * var(--u));
  touch-action: pan-y;
  cursor: pointer;
}
.track {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: calc(6 * var(--u));
  background: var(--track);
}
/* The fill ends under the thumb's middle, 8 px + --pos of the 180 px rail. */
.fill,
.sky {
  position: absolute;
  inset: 0;
  clip-path: inset(0 calc((188 - var(--pos) * 180) * var(--u)) 0 0);
}
.fill {
  background: linear-gradient(90deg, var(--fill-from), var(--fill-to));
  mask: linear-gradient(90deg, rgb(0 0 0 / 0.11), #000) 0 0 /
    calc((8 + var(--pos) * 180) * var(--u)) 100% no-repeat;
}
/* The night sky: deep indigo, warming to violet towards the thumb, under the mosaic. */
.sky {
  opacity: 0;
  background: linear-gradient(
    90deg,
    #0f0920 40%,
    #2d133a 57%,
    #371b52 70%,
    #34205e 77%,
    #372d71 83%,
    #464693 89%,
    #5a5cb0
  );
  transition: opacity 200ms ease;
}
.card[data-sky] .sky {
  opacity: 1;
  transition-duration: 60ms;
}
.mosaic {
  display: block;
  width: 100%;
  height: 100%;
}
/* Stops: dots, and the recommended one's taller tick, 10.75 px in from either end. */
.dot,
.tick {
  position: absolute;
  top: 50%;
  left: calc((10.75 + var(--stop) * 174.5) * var(--u));
  width: calc(3 * var(--u));
  border-radius: calc(1.5 * var(--u));
  transform: translate(-50%, -50%);
}
.dot {
  height: calc(3 * var(--u));
  background: var(--dot);
}
.tick {
  height: calc(10 * var(--u));
  background: var(--tick);
}

/* The thumb's middle runs from 8 px to 188 px. */
.rail {
  position: absolute;
  top: 50%;
  left: calc(8 * var(--u));
  width: 0;
  height: 0;
}
.thumb {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(16 * var(--u));
  height: calc(20 * var(--u));
  margin: calc(-10 * var(--u)) 0 0 calc(-8 * var(--u));
  border-radius: calc(8 * var(--u));
  transform: translateX(calc(var(--pos) * 180 * var(--u)));
  cursor: grab;
}
.thumb:active {
  cursor: grabbing;
}
.thumb:focus {
  outline: none;
}
.thumb::after {
  content: '';
  position: absolute;
  inset: calc(-5 * var(--u)) calc(-7 * var(--u));
  border-radius: 50%;
  box-shadow: 0 0 0 2px var(--focus);
  opacity: 0;
  pointer-events: none;
}
.thumb:focus-visible::after {
  opacity: 1;
}
.card[data-sky] .thumb:focus-visible::after {
  inset: calc(-9 * var(--u)) calc(-10 * var(--u));
}

/* The brain: 24 px of drawing around the thumb's middle, grown 1.3 times on the last step. */
.brain {
  position: absolute;
  left: calc(-4 * var(--u));
  top: calc(-2 * var(--u));
  width: calc(24 * var(--u));
  height: calc(24 * var(--u));
  overflow: visible;
  filter: drop-shadow(0 0 calc(1.2 * var(--u)) rgb(0 0 0 / 0.35));
  transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pink,
.galaxy {
  stroke-width: 4.4;
  stroke-linejoin: round;
  paint-order: stroke;
  transition: opacity 120ms ease;
}
.pink {
  fill: #f684ba;
  stroke: #5f0a35;
}
.galaxy {
  stroke: #220a78;
  stroke-width: 3.8;
  opacity: 0;
}
.card[data-sky] .brain {
  transform: scale(1.3);
}
.card[data-sky] .pink {
  opacity: 0;
}
.card[data-sky] .galaxy {
  opacity: 1;
}

/* The glow that stays around the brain on the last step, and the flash as it arrives. */
.halo,
.flash {
  position: absolute;
  left: 50%;
  top: 50%;
  border-radius: 50%;
  pointer-events: none;
}
.halo {
  width: calc(60 * var(--u));
  height: calc(60 * var(--u));
  margin: calc(-30 * var(--u)) 0 0 calc(-30 * var(--u));
  background: radial-gradient(
    closest-side,
    rgb(150 130 255 / 0.2),
    rgb(150 130 255 / 0.1) 50%,
    rgb(150 130 255 / 0.04) 70%,
    transparent
  );
  opacity: 0;
  transition: opacity 200ms ease;
}
.card[data-sky] .halo {
  opacity: 1;
  transition-duration: 500ms;
}
.flash {
  width: calc(64 * var(--u));
  height: calc(64 * var(--u));
  margin: calc(-32 * var(--u)) 0 0 calc(-32 * var(--u));
  background: radial-gradient(
    closest-side,
    rgb(255 255 255 / 0.62),
    rgb(255 255 255 / 0.3) 47%,
    rgb(255 255 255 / 0.14) 69%,
    transparent
  );
  animation: flash 700ms linear both;
}
@keyframes flash {
  0% {
    opacity: 0.8;
  }
  5% {
    opacity: 1;
  }
  24% {
    opacity: 0.55;
  }
  38% {
    opacity: 0.4;
  }
  52% {
    opacity: 0.22;
  }
  100% {
    opacity: 0;
  }
}
/* The rays, sparks and ring: 100 px around the thumb's middle. */
.burst {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(100 * var(--u));
  height: calc(100 * var(--u));
  margin: calc(-50 * var(--u)) 0 0 calc(-50 * var(--u));
  opacity: 0;
  transition: opacity 200ms ease;
  pointer-events: none;
}
.card[data-sky] .burst {
  opacity: 1;
  transition-duration: 0ms;
}

/* "Recommended", under its tick. */
.under {
  position: relative;
  height: calc(14 * var(--u));
  margin-top: calc(7.6 * var(--u));
  color: var(--muted-2);
  font-size: calc(12 * var(--u));
  line-height: calc(14 * var(--u));
  letter-spacing: 0.01em;
}
.recommended {
  position: absolute;
  top: 0;
  left: calc((10.75 + var(--stop) * 174.5) * var(--u));
  white-space: nowrap;
  transform: translateX(-39%);
}
.recommended[data-edge='start'] {
  left: 0;
  transform: none;
}
.recommended[data-edge='end'] {
  left: auto;
  right: 0;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .levelIn .letter,
  .levelOut .letter {
    animation: none;
  }
  .levelOut {
    display: none;
  }
  .brain,
  .pink,
  .galaxy,
  .sky,
  .halo,
  .burst,
  .tip {
    transition: none;
  }
  .flash {
    display: none;
  }
}
```

### effort-slider/brain.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The thumb's drawing: a brain seen from above, two lobed halves either side of a gap, each with a
 * fold that branches from the middle. One path draws the left half and a mirror of it the right.
 *
 * Units are px of the slider at its own size (13 px type), around the brain's middle. Each half is
 * painted twice in one go: a thick round stroke first, which becomes the dark outline outside the
 * fill and fills the gap, the fold and the notches, then the fill over the inner half of it.
 */

/** The left half, its edges in px from the brain's middle. */
export const HALF =
  'M-1.2-7.3C-1.2-8.4-2-9-3.1-9C-4.3-9-5.1-8.3-5.1-7.2L-5.1-6.2C-5.1-5.6-4.9-5.2-4.6-4.9' +
  'C-5.4-4.5-6.2-5.2-7-5.1C-7.8-5-8.1-4.3-8.1-3.6L-8.1-2.6C-8.1-2-7.3-1.8-7.3-1.2' +
  'C-7.3-0.6-8.1-0.3-8.6 0.3C-9 0.7-9.1 1.2-9.1 1.8L-9.1 3.4C-9.1 4.4-8.3 4.9-7.4 5' +
  'C-6.6 5.1-5.6 5.6-5 6.1C-5.9 6.4-6.6 7-6.6 8C-6.6 9-5.5 9.6-4.3 9.6' +
  'C-2.6 9.6-1.2 8.5-1.2 6.8L-1.2 1.4C-1.6 1.4-2.2 1.9-3 2C-3.8 2.1-4.4 2-4.7 1.6' +
  'C-5 1.2-4.8 0.7-4.3 0.5C-3.5 0.1-2.6-0.6-2.2-1.2C-1.9-1.7-1.6-2.3-1.2-2.4Z';

/** How much larger the brain grows at the last stop. */
export const GROWN = 1.3;

function Halves({ className }: { className: string }) {
  return (
    <g className={className}>
      <path d={HALF} />
      <path d={HALF} transform="scale(-1 1)" />
    </g>
  );
}

/**
 * Both brains in one drawing: the everyday one, pink, and the last stop's, blue to violet. The
 * stylesheet fades one into the other; `gradient` must be unique on the page.
 */
export function Brain({
  className,
  pinkClass,
  galaxyClass,
  gradient,
}: {
  className: string;
  pinkClass: string;
  galaxyClass: string;
  gradient: string;
}) {
  return (
    <svg className={className} viewBox="-12 -12 24 24" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient id={gradient} x1="-8" y1="-9" x2="8" y2="9" gradientUnits="userSpaceOnUse">
          <stop offset="0" stopColor="#93bbfd" />
          <stop offset="0.5" stopColor="#9172f7" />
          <stop offset="1" stopColor="#ca7cee" />
        </linearGradient>
      </defs>
      <Halves className={pinkClass} />
      <g className={galaxyClass} fill={`url(#${gradient})`}>
        <path d={HALF} />
        <path d={HALF} transform="scale(-1 1)" />
      </g>
    </svg>
  );
}
```

### effort-slider/galaxy.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { GROWN, HALF } from './brain';

/*
 * The last stop's two canvases.
 *
 * - The mosaic fills the track: a grid of small rounded squares, 4 px apart in 5 rows, each with a
 *   colour of its own and a brightness that steps now and then, so the track twinkles like a patch
 *   of sky. Squares are fewer and dimmer towards the left, where the effort is lowest.
 * - The burst sits on the thumb: dotted rays all around the brain that twinkle, sparks on the brain
 *   itself, and, as the stop is reached, a ring of coloured specks that flies out from the brain
 *   while the rays shoot outwards.
 *
 * Everything twinkles in steps, 7.5 times a second; only the burst, for its first second, draws
 * every frame. Nothing runs while the slider is off screen, the tab is hidden, or the reader asks
 * for less motion (then a still frame is drawn instead). The colours are the night sky's on both
 * schemes, so they live here rather than in the stylesheet.
 *
 * Units are px of the slider at its own size (13 px type); `unit` scales them to the page.
 */

type Rgb = readonly [number, number, number];

/** The mosaic's colours: orchid, magenta, violet, pale lilac, cyan, sky, mauve. */
const CELL_HUES: Rgb[] = [
  [216, 122, 217],
  [195, 103, 189],
  [144, 119, 245],
  [225, 223, 238],
  [131, 223, 235],
  [124, 190, 223],
  [169, 155, 175],
];
/** The rays' specks: grey, teal, lilac and a near-white. */
const RAY_HUES: Rgb[] = [
  [150, 150, 168],
  [96, 168, 170],
  [146, 136, 206],
  [196, 196, 214],
];
/** Sparks on the brain. */
const SPARK_HUES: Rgb[] = [
  [242, 240, 252],
  [143, 242, 253],
  [215, 211, 251],
  [184, 212, 255],
];
/** The ring that flies out. */
const BURST_HUES: Rgb[] = [
  [240, 138, 214],
  [127, 233, 245],
  [107, 77, 255],
  [255, 255, 255],
  [90, 108, 255],
  [201, 184, 255],
];

/** The track, and the mosaic's grid on it. */
export const TRACK_WIDTH = 196;
const TRACK_HEIGHT = 20;
const PITCH = 4;
const CELL = 3;
const COLUMNS = TRACK_WIDTH / PITCH;
const ROWS = TRACK_HEIGHT / PITCH;
/** The brightness steps a lit square can take. */
const LEVELS = [0.45, 0.6, 0.8, 1];
/** Ms between twinkles. */
const STEP = 133;
/** Half the burst canvas, around the thumb's middle. */
export const REACH = 50;
/** Where the rays start: just outside the grown brain. */
const RAY_START = 16;
/** How long the burst draws every frame. */
const BURST = 1000;

const rgb = ([r, g, b]: Rgb) => `rgb(${r} ${g} ${b})`;
const pick = <T>(list: readonly T[]) => list[Math.floor(Math.random() * list.length)];

type Cell = { hue: Rgb; level: number; flash: boolean };
type Ray = { angle: number; length: number; specks: { hue: Rgb; level: number }[] };
type Spark = { x: number; y: number; hue: Rgb; life: number };
type Speck = { angle: number; spread: number; speed: number; fade: number; hue: Rgb };

/** How likely a square is lit, and how bright it can be, from 0 at the left to 1 at the right. */
const presence = (x: number) => 0.4 + 0.5 * x;
const ramp = (x: number) => 0.3 + 0.7 * x ** 1.1;

export class Galaxy {
  private mosaic: CanvasRenderingContext2D;
  private burst: CanvasRenderingContext2D;
  private cells: Cell[] = [];
  private rays: Ray[] = [];
  private sparks: Spark[] = [];
  private specks: Speck[] = [];
  /** Points inside the grown brain, for sparks. */
  private inside: { x: number; y: number }[] = [];
  private unit = 1;
  private ratio = 1;
  private on = false;
  private started = 0;
  private lastStep = 0;
  private frame = 0;
  private visible = false;
  private reduced = false;
  private cleanups: (() => void)[] = [];

  constructor(
    private mosaicCanvas: HTMLCanvasElement,
    private burstCanvas: HTMLCanvasElement,
    /** The element whose width is the track's: it sets the scale. */
    private track: HTMLElement,
  ) {
    this.mosaic = mosaicCanvas.getContext('2d')!;
    this.burst = burstCanvas.getContext('2d')!;
    this.seed();

    const resize = new ResizeObserver(() => this.resize());
    resize.observe(track);
    const seen = new IntersectionObserver(([entry]) => {
      this.visible = entry.isIntersecting;
      this.update();
    });
    seen.observe(track);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => {
      this.reduced = motion.matches;
      this.update();
    };
    this.reduced = motion.matches;
    motion.addEventListener('change', readMotion);
    const onVisibility = () => this.update();
    document.addEventListener('visibilitychange', onVisibility);
    this.cleanups.push(
      () => resize.disconnect(),
      () => seen.disconnect(),
      () => motion.removeEventListener('change', readMotion),
      () => document.removeEventListener('visibilitychange', onVisibility),
    );
    this.resize();
  }

  /** Turns the last stop on or off; `burst` plays the arrival. */
  set(on: boolean, burst: boolean) {
    const arriving = on && !this.on;
    this.on = on;
    if (arriving) {
      this.seed();
      this.started = burst && !this.reduced ? performance.now() : -Infinity;
      if (burst && !this.reduced) this.launch();
    }
    this.update();
  }

  destroy() {
    cancelAnimationFrame(this.frame);
    for (const cleanup of this.cleanups) cleanup();
  }

  /** Lays out a fresh sky: the squares, the rays and the points the sparks may take. */
  private seed() {
    this.cells = [];
    for (let row = 0; row < ROWS; row++)
      for (let column = 0; column < COLUMNS; column++) {
        const cell: Cell = { hue: pick(CELL_HUES), level: 0, flash: false };
        this.roll(cell, column / (COLUMNS - 1));
        this.cells.push(cell);
      }
    const count = 14;
    this.rays = Array.from({ length: count }, (_, index) => {
      const length = 30 + Math.random() * 16;
      const specks = Array.from({ length: Math.floor((length - RAY_START) / 3) + 1 }, () => ({
        hue: pick(RAY_HUES),
        level: Math.random(),
      }));
      return {
        angle: ((index + (Math.random() - 0.5) * 0.6) / count) * Math.PI * 2,
        length,
        specks,
      };
    });
    if (!this.inside.length) {
      const left = new Path2D(HALF);
      const both = new Path2D();
      both.addPath(left);
      both.addPath(left, new DOMMatrix([-1, 0, 0, 1, 0, 0]));
      const context = this.burst;
      context.save();
      context.setTransform(1, 0, 0, 1, 0, 0);
      const fits = (x: number, y: number) =>
        [
          [0, 0],
          [1.2, 0],
          [-1.2, 0],
          [0, 1.2],
          [0, -1.2],
        ].every(([dx, dy]) => context.isPointInPath(both, x + dx, y + dy));
      for (let y = -8.5; y <= 8.5; y += 1)
        for (let x = -8.5; x <= 8.5; x += 1)
          if (fits(x, y)) this.inside.push({ x: x * GROWN, y: y * GROWN });
      context.restore();
    }
    this.sparks = [];
  }

  /** A square's new brightness: lit or not, more often lit towards the right. */
  private roll(cell: Cell, x: number) {
    cell.flash = false;
    cell.level = Math.random() < presence(x) ? pick(LEVELS) : 0;
  }

  /** The ring of specks that flies out from the brain. */
  private launch() {
    const count = 28;
    this.specks = Array.from({ length: count }, (_, index) => ({
      angle: ((index + (Math.random() - 0.5) * 0.5) / count) * Math.PI * 2,
      spread: 0.8 + Math.random() * 0.5,
      speed: 0.6 + Math.random() * 0.9,
      fade: 0.7 + Math.random() * 0.6,
      hue: pick(BURST_HUES),
    }));
  }

  /** One twinkle: some squares, specks and sparks change. */
  private twinkle() {
    this.cells.forEach((cell, index) => {
      if (Math.random() > 0.12) return;
      const x = (index % COLUMNS) / (COLUMNS - 1);
      if (Math.random() < 0.03 * x) {
        cell.flash = true;
        cell.level = 1;
      } else this.roll(cell, x);
    });
    for (const ray of this.rays)
      for (const speck of ray.specks) if (Math.random() < 0.25) speck.level = Math.random();
    this.sparks = this.sparks.filter((spark) => --spark.life > 0);
    while (this.sparks.length < 7 && this.inside.length) {
      const at = pick(this.inside);
      this.sparks.push({ ...at, hue: pick(SPARK_HUES), life: 2 + Math.floor(Math.random() * 4) });
    }
  }

  private resize() {
    const unit = this.track.offsetWidth / TRACK_WIDTH;
    if (!unit) return;
    this.unit = unit;
    this.ratio = Math.min(window.devicePixelRatio || 1, 2);
    const size = (canvas: HTMLCanvasElement, width: number, height: number) => {
      canvas.width = Math.round(width * unit * this.ratio);
      canvas.height = Math.round(height * unit * this.ratio);
    };
    size(this.mosaicCanvas, TRACK_WIDTH, TRACK_HEIGHT);
    size(this.burstCanvas, REACH * 2, REACH * 2);
    this.draw(performance.now());
  }

  /** Runs the loop while it can be seen and moved; otherwise draws one still frame. */
  private update() {
    cancelAnimationFrame(this.frame);
    this.frame = 0;
    if (!this.on) return;
    if (this.visible && !document.hidden && !this.reduced) {
      const tick = (now: number) => {
        let changed = now - this.started < BURST + 100;
        if (now - this.lastStep >= STEP) {
          this.lastStep = now;
          this.twinkle();
          changed = true;
        }
        if (changed) this.draw(now);
        this.frame = requestAnimationFrame(tick);
      };
      this.frame = requestAnimationFrame(tick);
    } else {
      if (!this.sparks.length) this.twinkle();
      this.draw(Infinity);
    }
  }

  private draw(now: number) {
    if (!this.on) return;
    this.drawMosaic();
    this.drawBurst(now - this.started);
  }

  private drawMosaic() {
    const context = this.mosaic;
    const scale = this.unit * this.ratio;
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.clearRect(0, 0, this.mosaicCanvas.width, this.mosaicCanvas.height);
    context.setTransform(scale, 0, 0, scale, 0, 0);
    this.cells.forEach((cell, index) => {
      if (!cell.level) return;
      const column = index % COLUMNS;
      const row = Math.floor(index / COLUMNS);
      const x = column * PITCH + (PITCH - CELL) / 2;
      const y = row * PITCH + (PITCH - CELL) / 2;
      context.globalAlpha = cell.flash ? 0.95 : cell.level * ramp(column / (COLUMNS - 1));
      context.fillStyle = cell.flash ? '#f1eeff' : rgb(cell.hue);
      context.beginPath();
      context.roundRect(x, y, CELL, CELL, 0.75);
      context.fill();
    });
    context.globalAlpha = 1;
  }

  /** `t`: ms since the stop was reached (Infinity: long since). */
  private drawBurst(t: number) {
    const context = this.burst;
    const scale = this.unit * this.ratio;
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.clearRect(0, 0, this.burstCanvas.width, this.burstCanvas.height);
    context.setTransform(scale, 0, 0, scale, REACH * scale, REACH * scale);
    // Specks sit on a 1 px grid, as on a screen of big pixels.
    const square = (x: number, y: number, size: number) =>
      context.fillRect(Math.round(x) - size / 2, Math.round(y) - size / 2, size, size);

    // The rays shoot out over the first ~300 ms, then stay, twinkling.
    const reach = t < 30 ? 0 : RAY_START + 34 * (1 - Math.exp(-(t - 30) / 90));
    const intro = Math.min(1, Math.max(0, t / 160));
    for (const ray of this.rays) {
      const cos = Math.cos(ray.angle);
      const sin = Math.sin(ray.angle);
      ray.specks.forEach((speck, index) => {
        const r = RAY_START + index * 3;
        if (r > reach || r > ray.length) return;
        const fall = 1 - (r - RAY_START) / (ray.length - RAY_START + 3);
        context.globalAlpha = (0.3 + 0.7 * speck.level) * fall * intro;
        context.fillStyle = rgb(speck.hue);
        square(cos * r, sin * r, 1.25);
      });
    }

    // Sparks on the brain, once it has grown.
    if (t > 120) {
      for (const spark of this.sparks) {
        context.globalAlpha = 0.95;
        context.fillStyle = rgb(spark.hue);
        square(spark.x, spark.y, 1.5);
      }
    }

    // The ring: it opens from inside the brain to its edge in ~150 ms, hangs there, then
    // scatters outwards and fades.
    if (t < BURST) {
      for (const speck of this.specks) {
        const out = 7 + 10 * (1 - Math.exp(-t / 80)) + Math.max(0, t - 160) * 0.016 * speck.speed;
        const r = out * speck.spread ** (t > 160 ? 1 : 0.3);
        const fade = t < 220 ? 1 : Math.max(0, 1 - (t - 220) / (420 * speck.fade));
        if (!fade) continue;
        context.globalAlpha = fade;
        context.fillStyle = rgb(speck.hue);
        square(Math.cos(speck.angle) * r * 1.05, Math.sin(speck.angle) * r, 2);
      }
    }
    context.globalAlpha = 1;
  }
}
```

### effort-slider/themes.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { ComponentType } from 'react';
import { Car } from './car';
import { Hole, HOLE_REACH } from './hole';
import { Jet } from './jet';
import { Launch, LAUNCH_REACH } from './launch';
import { Missile } from './missile';
import { Overtake, OVERTAKE_REACH } from './overtake';
import type { Payoff } from './payoff';
import { Sonic, SONIC_REACH } from './sonic';
import { Spiral } from './spiral';

/*
 * The slider's themes besides the brain: each a thumb that changes step by step (its own drawing),
 * a fill (themes.module.css) and a last-step payoff (its own canvases' painter).
 */

export type Theme = 'brain' | 'galaxy' | 'formula' | 'missile' | 'jet';

type Look = {
  /** The steps' names when none are given. */
  levels: string[];
  /** The last step's name on a dark page, where it differs. */
  darkLast?: string;
  Icon: ComponentType<{ id: string }>;
  Payoff: new (track: HTMLCanvasElement, box: HTMLCanvasElement, host: HTMLElement) => Payoff;
  /** Half the payoff's box around the thumb, in px. */
  reach: readonly [number, number];
};

export const LOOKS: Record<Exclude<Theme, 'brain'>, Look> = {
  galaxy: {
    levels: ['Nebula', 'Spiral', 'Cluster', 'Quasar', 'Supermassive', 'Event horizon'],
    darkLast: 'White hole',
    Icon: Spiral,
    Payoff: Hole,
    reach: HOLE_REACH,
  },
  formula: {
    levels: ['Pit lane', 'Formation', 'Race pace', 'Push', 'Qualifying', 'Overtake'],
    Icon: Car,
    Payoff: Overtake,
    reach: OVERTAKE_REACH,
  },
  missile: {
    levels: ['Armed', 'Fuelled', 'Ignition', 'Liftoff', 'Boost', 'Hypersonic'],
    Icon: Missile,
    Payoff: Launch,
    reach: LAUNCH_REACH,
  },
  jet: {
    levels: ['Taxi', 'Takeoff', 'Climb', 'Cruise', 'Afterburner', 'Mach 1'],
    Icon: Jet,
    Payoff: Sonic,
    reach: SONIC_REACH,
  },
};
```

### effort-slider/themes.module.css

```css
/* The effort slider's themes (themes.ts): the galaxy, formula, missile and jet looks. The brain is
   effort-slider.module.css alone; nothing here touches it.

   The card says its theme (data-theme), whether the page is dark (data-dark, read by the slider
   from its own colours) and whether the last step is on (data-sky). Each theme sets its tokens on
   the card, styles the classes below that the slider adds next to its own, and draws its thumb in
   its own SVG, whose parts read --pos (0 to 1, where the thumb is) to change step by step: the
   change follows the thumb's glide, so it is smooth with no transitions of its own. What changes
   only on the last step sits on a group of its own, with a transition.

   Animations named pace… run faster step by step: the slider sets their playback rate. Inside the
   SVGs, transform origins are in the drawing's units (its 0 0 is the thumb's middle). */

@property --es-warp {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

/* Tokens. */
.themed[data-theme='galaxy'] {
  --fill-from: light-dark(#c9bcff, #2a2070);
  --fill-to: light-dark(#5a36d4, #9a5cf0);
  --sky-ink: light-dark(#4527b0, #f2f4ff);
}
.themed[data-theme='formula'] {
  --fill-from: light-dark(#6a6e76, #44474e);
  --fill-to: light-dark(#2a2c31, #1c1e22);
  --sky-ink: light-dark(#d0190f, #ff6a5c);
}
.themed[data-theme='missile'] {
  --fill-from: light-dark(#c9cdd4, #3a3d44);
  --fill-to: light-dark(#ff9b3f, #e0711f);
  --sky-ink: light-dark(#c4560f, #ffb066);
}
.themed[data-theme='jet'] {
  --fill-from: light-dark(#cfe6ff, #182a55);
  --fill-to: light-dark(#3a86e2, #3a62c4);
  --sky-ink: light-dark(#1f6fd1, #a9d2ff);
}
/* The slider reads the page's scheme from this colour. */
.themed[data-theme] {
  caret-color: light-dark(rgb(0 0 0), rgb(255 255 255));
}

/* The thumb's drawing: 32 px around its middle, which is 8 px in and 10 px down in the thumb. */
.icon {
  position: absolute;
  left: calc(-8 * var(--u));
  top: calc(-6 * var(--u));
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  overflow: visible;
  filter: drop-shadow(0 0 calc(0.9 * var(--u)) rgb(0 0 0 / 0.35));
  pointer-events: none;
}

/* The fill's two layers, and the payoff's sky and box. */
.texture,
.lines {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.texture {
  right: calc(-24 * var(--u));
}
.lines {
  right: calc(-24 * var(--u));
}
.themed[data-theme] .box {
  width: calc(var(--rx) * 2 * var(--u));
  height: calc(var(--ry) * 2 * var(--u));
  margin: calc(var(--ry) * -1 * var(--u)) 0 0 calc(var(--rx) * -1 * var(--u));
}

/* ---- Galaxy: a spiral that grows and spins faster, then a black hole (light) or white hole. ---- */

.themed[data-theme='galaxy'] .texture {
  /* Stars in two layers on one 12 px tile, drifting left. */
  background:
    radial-gradient(
        circle,
        rgb(255 255 255 / 0.95) 0 calc(0.45 * var(--u)),
        transparent calc(0.75 * var(--u))
      )
      0 calc(3 * var(--u)) / calc(12 * var(--u)) calc(9 * var(--u)),
    radial-gradient(
        circle,
        rgb(255 236 250 / 0.7) 0 calc(0.35 * var(--u)),
        transparent calc(0.6 * var(--u))
      )
      calc(6 * var(--u)) calc(-1 * var(--u)) / calc(12 * var(--u)) calc(7 * var(--u));
  opacity: calc(0.35 + var(--pos) * 0.65);
  animation: pace-drift12 9s linear infinite;
}
.themed[data-theme='galaxy'] .lines {
  display: none;
}
@keyframes pace-drift12 {
  to {
    transform: translateX(calc(-12 * var(--u)));
  }
}
/* On a light page the last step pulls the fill into a funnel towards the hole. */
.themed[data-theme='galaxy'] .warp {
  --x: calc((8 + var(--pos) * 180) * var(--u));
  clip-path: polygon(
    0 0,
    50% calc(var(--es-warp) * 20%),
    var(--x) calc(var(--es-warp) * 42%),
    var(--x) calc(100% - var(--es-warp) * 42%),
    50% calc(100% - var(--es-warp) * 20%),
    0 100%
  );
  transition: --es-warp 260ms ease;
}
.themed[data-theme='galaxy'][data-sky]:not([data-dark]) .warp {
  --es-warp: 1;
  transition: --es-warp 760ms cubic-bezier(0.55, 0, 0.25, 1);
}
.themed[data-theme='galaxy'] .sky {
  background:
    radial-gradient(
      circle at calc(188 / 196 * 100%) 50%,
      #05020c 0 calc(5 * var(--u)),
      #2a1458 calc(9 * var(--u)),
      transparent calc(40 * var(--u))
    ),
    linear-gradient(90deg, #ddd5ff, #a48ef3 50%, #4a2aa6 80%, #150a33);
}
.themed[data-theme='galaxy'][data-dark] .sky {
  background:
    radial-gradient(
      circle at calc(188 / 196 * 100%) 50%,
      #fff 0 calc(4 * var(--u)),
      #d4ecff calc(8 * var(--u)),
      transparent calc(44 * var(--u))
    ),
    linear-gradient(90deg, #060a1c, #121a44 50%, #34489a 80%, #9ec2ff);
}
.themed[data-theme='galaxy'] .glow {
  background: radial-gradient(
    closest-side,
    rgb(48 24 110 / 0.34),
    rgb(48 24 110 / 0.14) 55%,
    transparent
  );
}
.themed[data-theme='galaxy'][data-dark] .glow {
  background: radial-gradient(
    closest-side,
    rgb(225 242 255 / 0.55),
    rgb(190 220 255 / 0.2) 50%,
    transparent
  );
}
.themed[data-theme='galaxy'] .flash {
  background: radial-gradient(
    closest-side,
    rgb(20 8 48 / 0.7),
    rgb(40 18 96 / 0.3) 50%,
    transparent
  );
}
.themed[data-theme='galaxy'][data-dark] .flash {
  width: calc(90 * var(--u));
  height: calc(90 * var(--u));
  margin: calc(-45 * var(--u)) 0 0 calc(-45 * var(--u));
  background: radial-gradient(
    closest-side,
    #fff,
    rgb(255 255 255 / 0.7) 35%,
    rgb(200 230 255 / 0.25) 65%,
    transparent
  );
}

.grow {
  transform: scale(calc(0.95 + var(--pos) * 0.3));
}
.spiral {
  transition:
    transform 420ms cubic-bezier(0.6, 0, 0.8, 0.4),
    opacity 300ms ease 120ms;
}
.spin {
  animation: pace-spin 5s linear infinite;
}
@keyframes pace-spin {
  to {
    transform: rotate(-360deg);
  }
}
.arm {
  opacity: calc(0.6 + var(--pos) * 0.4);
}
.star {
  fill: #fff;
  opacity: calc(var(--pos) * 1.6 - 0.15);
}
[data-sky] .spiral {
  transform: scale(0.12) rotate(-200deg);
  opacity: 0;
}
.blackHole,
.whiteHole {
  opacity: 0;
  transform: scale(0.25);
  transition:
    transform 200ms ease,
    opacity 160ms ease;
}
[data-sky]:not([data-dark]) .blackHole,
[data-sky][data-dark] .whiteHole {
  opacity: 1;
  transform: scale(1.12);
  transition:
    transform 520ms cubic-bezier(0.3, 1.5, 0.5, 1) 330ms,
    opacity 200ms ease 330ms;
}
.themed[data-theme='galaxy'][data-dark] .blackHole,
.themed[data-theme='galaxy']:not([data-dark]) .whiteHole {
  visibility: hidden;
}
.lensed,
.disc,
.discFront {
  fill: none;
  stroke-linecap: round;
}
.lensed {
  stroke-width: 1.25;
  opacity: 0.85;
}
.disc {
  stroke-width: 1.7;
  opacity: 0.8;
}
.discFront {
  stroke-width: 2.3;
  stroke-dasharray: 2.6 0.9;
  animation: swirl 900ms linear infinite;
}
@keyframes swirl {
  to {
    stroke-dashoffset: -3.5;
  }
}
.photonRing {
  fill: none;
  stroke: #ffe4b5;
  stroke-width: 0.6;
}
.horizon {
  fill: #06030e;
}
.whiteRays {
  stroke: #fff;
  stroke-width: 1;
  stroke-linecap: round;
  animation: rays 1.4s ease-in-out infinite alternate;
}
@keyframes rays {
  to {
    transform: scale(1.18) rotate(8deg);
    opacity: 0.7;
  }
}
.whiteRing {
  fill: none;
  stroke: #d6efff;
  stroke-width: 0.8;
}
.whiteCore {
  fill: #fff;
}

/* ---- Formula: a racing car, its tarmac and kerbs streaming past, then the overtake. ---- */

.themed[data-theme='formula'] .texture {
  /* Kerbs at the edges and a dashed line down the middle, one 8 px period, streaming left. */
  background:
    repeating-linear-gradient(90deg, #e3241b 0 calc(4 * var(--u)), #f2f2f2 0 calc(8 * var(--u))) 0
      0 / 100% calc(1.5 * var(--u)) no-repeat,
    repeating-linear-gradient(90deg, #e3241b 0 calc(4 * var(--u)), #f2f2f2 0 calc(8 * var(--u))) 0
      100% / 100% calc(1.5 * var(--u)) no-repeat,
    repeating-linear-gradient(
        90deg,
        rgb(255 255 255 / 0.7) 0 calc(4 * var(--u)),
        transparent 0 calc(8 * var(--u))
      )
      0 50% / 100% calc(0.7 * var(--u)) no-repeat;
  animation: pace-drift8 900ms linear infinite;
}
.themed[data-theme='formula'] .lines {
  /* Speed lines, from the middle steps. */
  background:
    linear-gradient(90deg, transparent, rgb(255 255 255 / 0.6)) 0 calc(5 * var(--u)) /
      calc(14 * var(--u)) calc(0.5 * var(--u)) repeat-x,
    linear-gradient(90deg, transparent, rgb(255 255 255 / 0.45)) calc(9 * var(--u))
      calc(13.5 * var(--u)) / calc(24 * var(--u)) calc(0.5 * var(--u)) repeat-x;
  opacity: calc((var(--pos) - 0.3) * 1.6);
  animation: pace-drift24 700ms linear infinite;
}
@keyframes pace-drift8 {
  to {
    transform: translateX(calc(-8 * var(--u)));
  }
}
@keyframes pace-drift24 {
  to {
    transform: translateX(calc(-24 * var(--u)));
  }
}
.themed[data-theme='formula'] .sky {
  background: linear-gradient(90deg, #1f2125, #2a2d32 60%, #3b3f47);
}
.themed[data-theme='formula'] .glow {
  background: radial-gradient(
    closest-side,
    rgb(255 70 50 / 0.2),
    rgb(255 70 50 / 0.08) 55%,
    transparent
  );
}
.themed[data-theme='formula'] .flash {
  background: radial-gradient(
    closest-side,
    rgb(220 245 255 / 0.85),
    rgb(160 220 255 / 0.35) 50%,
    transparent
  );
}

.ahead {
  transition: transform 260ms ease;
}
[data-sky] .ahead {
  transform: translateX(2.4px);
  transition: transform 420ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.speedLines {
  fill: none;
  stroke-width: 0.8;
  stroke-linecap: round;
  stroke-dasharray: 7 3;
  opacity: calc((var(--pos) - 0.2) * 1.6);
  animation: pace-dash 500ms linear infinite;
}
@keyframes pace-dash {
  to {
    stroke-dashoffset: 10;
  }
}
.chassis {
  animation: pace-shiver 180ms steps(2) infinite;
}
@keyframes pace-shiver {
  to {
    transform: translateY(0.25px);
  }
}
.heat {
  opacity: calc(var(--pos) * 1.3 - 0.25);
}
.arms {
  stroke: #24262a;
  stroke-width: 0.5;
}
.tyres {
  fill: #141518;
}
.frontWing {
  fill: #23252a;
}
.body {
  stroke: #45100c;
  stroke-width: 0.45;
}
.stripe {
  stroke: #fff;
  stroke-width: 0.55;
  opacity: 0.9;
}
.cockpit {
  fill: #0e0f12;
}
.cockpitHalo {
  fill: none;
  stroke: #a1a8b2;
  stroke-width: 0.5;
}
.wing {
  fill: #18191c;
}
.flap {
  fill: #33363b;
  transform-origin: -11px 0;
  transition: transform 160ms ease;
}
[data-sky] .flap {
  transform: scaleX(0.3);
}
.rainLight {
  fill: #ff3030;
  opacity: 0;
}
[data-sky] .rainLight {
  animation: blink 500ms steps(1) infinite;
}
@keyframes blink {
  50% {
    opacity: 1;
  }
}

/* ---- Missile: a rocket whose flame ramps up, on a trail of smoke, then the launch. ---- */

.themed[data-theme='missile'] .texture {
  /* Puffs of smoke on one 12 px tile, drifting left. */
  background:
    radial-gradient(closest-side, rgb(255 255 255 / 0.55), transparent) 0 calc(2 * var(--u)) /
      calc(12 * var(--u)) calc(9 * var(--u)),
    radial-gradient(closest-side, rgb(255 255 255 / 0.4), transparent) calc(6 * var(--u))
      calc(9 * var(--u)) / calc(12 * var(--u)) calc(11 * var(--u));
  opacity: calc(0.25 + var(--pos) * 0.75);
  animation: pace-drift12 2.4s linear infinite;
}
.themed[data-theme='missile'][data-dark] .texture {
  filter: brightness(0.55);
}
.themed[data-theme='missile'] .lines {
  display: none;
}
.themed[data-theme='missile'] .sky {
  background: linear-gradient(90deg, #d2d5db, #e7e9ed 55%, #ffd6a3 85%, #fff2dc);
}
.themed[data-theme='missile'][data-dark] .sky {
  background: linear-gradient(90deg, #1e2024, #33363c 55%, #7a4421 85%, #f39a4a);
}
.themed[data-theme='missile'] .glow {
  background: radial-gradient(
    closest-side,
    rgb(255 168 60 / 0.38),
    rgb(255 140 40 / 0.14) 55%,
    transparent
  );
}
.themed[data-theme='missile'] .flash {
  background: radial-gradient(
    closest-side,
    rgb(255 250 230 / 0.95),
    rgb(255 200 110 / 0.5) 45%,
    transparent
  );
}

.rocket {
  transition: transform 260ms ease;
}
[data-sky] .rocket {
  transform: translateY(-1.5px) rotate(-12deg);
  transition: transform 560ms cubic-bezier(0.25, 1.4, 0.45, 1);
}
[data-sky] .shake {
  animation: shake 90ms steps(2) infinite;
}
@keyframes shake {
  to {
    transform: translate(0.2px, -0.2px);
  }
}
.wisps {
  fill: rgb(255 255 255 / 0.9);
  stroke: rgb(120 128 140 / 0.5);
  stroke-width: 0.3;
  opacity: min(calc(var(--pos) * 5), calc((0.5 - var(--pos)) * 4));
}
.flameAt {
  transform: translateX(-11.3px);
}
.flame {
  transform: scale(calc(var(--pos) * 1.15), calc(0.55 + var(--pos) * 0.5));
}
.boost {
  transition: transform 200ms ease;
}
[data-sky] .boost {
  transform: scale(1.55, 1.3);
  transition: transform 380ms cubic-bezier(0.2, 1.5, 0.4, 1);
}
.flicker {
  animation: pace-flicker 140ms steps(2) infinite;
}
@keyframes pace-flicker {
  to {
    transform: scale(0.9, 0.94);
  }
}
.flameInner {
  fill: #fff4cc;
}
.diamonds {
  fill: #fff;
  opacity: 0;
  transition: opacity 200ms ease;
}
[data-sky] .diamonds {
  opacity: 0.95;
}
.nozzle {
  fill: #2f3744;
}
.fin {
  fill: #3d4a60;
}
.hull {
  stroke: #2b3340;
  stroke-width: 0.45;
}
.noseCone {
  fill: #e4572e;
}
.band {
  stroke: #2b3340;
  stroke-width: 0.6;
}

/* ---- Jet: wings that sweep back, an afterburner, then the vapour cone of Mach 1. ---- */

.themed[data-theme='jet'] .texture {
  /* Wisps of cloud, drifting left. */
  background:
    radial-gradient(closest-side, rgb(255 255 255 / 0.5), transparent) 0 calc(3 * var(--u)) /
      calc(24 * var(--u)) calc(5 * var(--u)),
    radial-gradient(closest-side, rgb(255 255 255 / 0.35), transparent) calc(12 * var(--u))
      calc(13 * var(--u)) / calc(24 * var(--u)) calc(4 * var(--u));
  opacity: calc(0.5 + var(--pos) * 0.5);
  animation: pace-drift24 4s linear infinite;
}
.themed[data-theme='jet'][data-dark] .texture {
  opacity: calc(0.2 + var(--pos) * 0.3);
}
.themed[data-theme='jet'] .lines {
  /* Two contrails behind the thumb, from the middle steps. */
  right: 0;
  background:
    linear-gradient(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.9)) 0 calc(50% - 3.4 * var(--u)) /
      100% calc(0.9 * var(--u)) no-repeat,
    linear-gradient(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.9)) 0 calc(50% + 3.4 * var(--u)) /
      100% calc(0.9 * var(--u)) no-repeat;
  mask: linear-gradient(90deg, transparent 20%, #000);
  opacity: calc((var(--pos) - 0.3) * 1.6);
}
.themed[data-theme='jet'] .sky {
  background: linear-gradient(90deg, #8cc0f2, #64a4e8 55%, #b8daff 85%, #f2f8ff);
}
.themed[data-theme='jet'][data-dark] .sky {
  background: linear-gradient(90deg, #07112a, #122858 55%, #3c63c2 85%, #a9c6ff);
}
.themed[data-theme='jet'] .glow {
  background: radial-gradient(
    closest-side,
    rgb(90 160 235 / 0.3),
    rgb(90 160 235 / 0.1) 55%,
    transparent
  );
}
.themed[data-theme='jet'][data-dark] .glow {
  background: radial-gradient(
    closest-side,
    rgb(205 230 255 / 0.32),
    rgb(205 230 255 / 0.1) 55%,
    transparent
  );
}
.themed[data-theme='jet'] .flash {
  width: calc(80 * var(--u));
  height: calc(80 * var(--u));
  margin: calc(-40 * var(--u)) 0 0 calc(-40 * var(--u));
  background: radial-gradient(
    closest-side,
    rgb(255 255 255 / 0.95),
    rgb(225 240 255 / 0.5) 45%,
    rgb(150 200 255 / 0.15) 70%,
    transparent
  );
}

.wingUp,
.wingDown {
  transform: rotate(calc(var(--pos) * -32deg));
  transform-origin: 0.6px -1.2px;
}
.wingDown {
  transform: rotate(calc(var(--pos) * 32deg));
  transform-origin: 0.6px 1.2px;
}
.wingSkin,
.tail {
  fill: #b9c5d3;
  stroke: #2a3442;
  stroke-width: 0.45;
  stroke-linejoin: round;
}
.fuselage {
  stroke: #2a3442;
  stroke-width: 0.45;
}
.canopy {
  fill: #2b5a96;
  stroke: #1a2b44;
  stroke-width: 0.35;
}
.burner {
  transform: scaleX(calc(max(0, var(--pos) - 0.35) * 1.6));
  transform-origin: -12px 0;
}
.themed[data-theme='jet'] .boost,
.themed[data-theme='jet'] .flicker {
  transform-origin: -12px 0;
}
.coneAt {
  opacity: 0;
  transform: scale(0.55);
  transform-origin: 7px 0;
  transition:
    opacity 160ms ease,
    transform 200ms ease;
}
[data-sky] .coneAt {
  opacity: 1;
  transform: none;
  transition:
    opacity 220ms ease 60ms,
    transform 420ms cubic-bezier(0.15, 1.3, 0.4, 1) 60ms;
}
.cone {
  stroke: light-dark(rgb(70 130 210 / 0.6), rgb(255 255 255 / 0.4));
  stroke-width: 0.45;
  transform-origin: 7px 0;
  animation: cone 160ms steps(2) infinite;
}
@keyframes cone {
  to {
    transform: scale(1.02, 0.97);
    opacity: 0.85;
  }
}

/* Reduced motion: nothing loops, and the last step's changes land at once. */
@media (prefers-reduced-motion: reduce) {
  .texture,
  .lines,
  .spin,
  .discFront,
  .whiteRays,
  .speedLines,
  .chassis,
  .rainLight,
  .shake,
  .flicker,
  .cone {
    animation: none !important;
  }
  .warp,
  .spiral,
  .blackHole,
  .whiteHole,
  .ahead,
  .flap,
  .rocket,
  .boost,
  .diamonds,
  .coneAt {
    transition: none !important;
  }
  [data-sky] .rainLight {
    opacity: 1;
  }
}
```

### effort-slider/payoff.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * What the themed payoffs share (the brain's night sky has its own, galaxy.ts): two canvases, the
 * track and a box around the thumb, and a loop that draws them while the last step is on.
 *
 * - A theme says how big its box is, how long its arrival lasts, and draws three things: the
 *   arrival (t is ms since the last step was reached), what keeps going after it, and the track.
 * - The loop draws every frame through the arrival, then 30 times a second. It stops while the
 *   slider is off screen or the tab is hidden. Under reduced motion there is no arrival: the
 *   theme's world is run forward a second and a half out of sight and drawn once, as a still.
 * - Light or dark is told by the slider (`scheme`), which reads it from the page's CSS.
 *
 * Units are px of the slider at its own size (13 px type); `unit` scales them to the page. On the
 * track, (0, 0) is its top left and the thumb's middle is at (THUMB_X, 10). In the box, (0, 0) is
 * the thumb's middle.
 */

import { TRACK_WIDTH } from './galaxy';

export const TRACK_HEIGHT = 20;
/** The thumb's middle on the last step. */
export const THUMB_X = TRACK_WIDTH - 8;

/** Ms between frames once the arrival is over. */
const AMBIENT = 33;
/** The still frame's lead under reduced motion. */
const WARM_UP = 1500;

export type Rgb = readonly [number, number, number];
export const rgba = ([r, g, b]: Rgb, a = 1) => `rgb(${r} ${g} ${b} / ${a})`;
export const pick = <T>(list: readonly T[]) => list[Math.floor(Math.random() * list.length)];
export const between = (min: number, max: number) => min + Math.random() * (max - min);
export const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
/** Rises from 0 to 1 between `from` and `to` ms, eased out. */
export const easeOut = (t: number, from: number, to: number) =>
  1 - (1 - clamp01((t - from) / (to - from))) ** 3;

/** A payoff as the slider drives it: this, or the brain's Galaxy. */
export type Engine = {
  set(on: boolean, burst: boolean): void;
  scheme?(dark: boolean): void;
  destroy(): void;
};

export abstract class Payoff implements Engine {
  protected track: CanvasRenderingContext2D;
  protected box: CanvasRenderingContext2D;
  protected dark = false;
  private unit = 1;
  private ratio = 1;
  private on = false;
  private started = -Infinity;
  private previous = 0;
  private drawn = 0;
  private frame = 0;
  private visible = false;
  private reduced = false;
  private sized = false;
  private cleanups: (() => void)[] = [];

  constructor(
    private trackCanvas: HTMLCanvasElement,
    private boxCanvas: HTMLCanvasElement,
    /** The element whose width is the track's: it sets the scale. */
    private host: HTMLElement,
    /** Half the box's width and height. */
    private reach: readonly [number, number],
    /** How long the arrival draws every frame. */
    private arrival: number,
  ) {
    this.track = trackCanvas.getContext('2d')!;
    this.box = boxCanvas.getContext('2d')!;

    const resize = new ResizeObserver(() => this.resize());
    resize.observe(host);
    const seen = new IntersectionObserver(([entry]) => {
      this.visible = entry.isIntersecting;
      this.update();
    });
    seen.observe(host);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => {
      this.reduced = motion.matches;
      this.update();
    };
    this.reduced = motion.matches;
    motion.addEventListener('change', readMotion);
    const onVisibility = () => this.update();
    document.addEventListener('visibilitychange', onVisibility);
    this.cleanups.push(
      () => resize.disconnect(),
      () => seen.disconnect(),
      () => motion.removeEventListener('change', readMotion),
      () => document.removeEventListener('visibilitychange', onVisibility),
    );
  }

  /** Turns the last step on or off; `burst` plays the arrival. */
  set(on: boolean, burst: boolean) {
    const arriving = on && !this.on;
    this.on = on;
    if (arriving) {
      this.reset();
      const live = burst && !this.reduced;
      this.started = live ? performance.now() : -Infinity;
      if (live) this.arrive();
      else this.settle();
    }
    this.update();
  }

  scheme(dark: boolean) {
    if (dark === this.dark) return;
    this.dark = dark;
    if (this.on) {
      this.reset();
      this.settle();
    }
    this.update();
  }

  destroy() {
    cancelAnimationFrame(this.frame);
    for (const cleanup of this.cleanups) cleanup();
  }

  /** A fresh world, before the arrival. */
  protected abstract reset(): void;
  /** The arrival's start: what it throws out. */
  protected abstract arrive(): void;
  /** Moves the world on by `dt` ms; `t` is ms since the arrival. */
  protected abstract advance(dt: number, t: number): void;
  /** Draws the track (origin at its top left) and the box (origin at the thumb's middle). */
  protected abstract paintTrack(context: CanvasRenderingContext2D, t: number): void;
  protected abstract paintBox(context: CanvasRenderingContext2D, t: number): void;

  /** The world as it is a while after the arrival, for a still frame. */
  private settle() {
    for (let t = 0; t < WARM_UP; t += AMBIENT) this.advance(AMBIENT, Infinity);
  }

  private resize() {
    const unit = this.host.offsetWidth / TRACK_WIDTH;
    if (!unit) return;
    this.unit = unit;
    this.ratio = Math.min(window.devicePixelRatio || 1, 2);
    const size = (canvas: HTMLCanvasElement, width: number, height: number) => {
      canvas.width = Math.round(width * unit * this.ratio);
      canvas.height = Math.round(height * unit * this.ratio);
    };
    size(this.trackCanvas, TRACK_WIDTH, TRACK_HEIGHT);
    size(this.boxCanvas, this.reach[0] * 2, this.reach[1] * 2);
    this.sized = true;
    this.draw(performance.now());
  }

  /** Runs the loop while it can be seen and moved; otherwise draws one still frame. */
  private update() {
    cancelAnimationFrame(this.frame);
    this.frame = 0;
    if (!this.on) return;
    if (this.visible && !document.hidden && !this.reduced) {
      this.previous = performance.now();
      const tick = (now: number) => {
        const t = now - this.started;
        if (t < this.arrival + 100 || now - this.drawn >= AMBIENT) {
          this.advance(Math.min(100, Math.max(0, now - this.previous)), t);
          this.previous = now;
          this.draw(now);
        }
        this.frame = requestAnimationFrame(tick);
      };
      this.frame = requestAnimationFrame(tick);
    } else this.draw(Infinity);
  }

  private draw(now: number) {
    if (!this.on || !this.sized) return;
    this.drawn = now;
    const t = now - this.started;
    const scale = this.unit * this.ratio;
    for (const [context, canvas, x, y] of [
      [this.track, this.trackCanvas, 0, 0],
      [this.box, this.boxCanvas, this.reach[0], this.reach[1]],
    ] as const) {
      context.setTransform(1, 0, 0, 1, 0, 0);
      context.globalAlpha = 1;
      context.globalCompositeOperation = 'source-over';
      context.clearRect(0, 0, canvas.width, canvas.height);
      context.setTransform(scale, 0, 0, scale, x * scale, y * scale);
    }
    this.paintTrack(this.track, t);
    this.paintBox(this.box, t);
    this.track.globalAlpha = 1;
    this.box.globalAlpha = 1;
  }
}
```

### effort-slider/spiral.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import styles from './themes.module.css';

/*
 * The galaxy theme's thumb: a spiral galaxy seen face on, two arms winding out of a warm core, with
 * a few stars on them. It grows and turns faster step by step (the stylesheet reads --pos; the
 * slider sets the spin's rate). On the last step it collapses into what sits in the same drawing:
 * a black hole on a light page (a dark disc ringed by bent light, its accretion disc passing in
 * front and, lensed, over the top) or a white hole on a dark one (a blinding core throwing rings
 * and rays outwards).
 *
 * Units are px of the slider at its own size, around the thumb's middle.
 */

/** One arm: a logarithmic spiral, r = 1.1 e^(0.3 θ), as a band that thins outwards. */
function arm(turn: number) {
  const steps = 36;
  const end = Math.PI * 2.25;
  const point = (theta: number) => {
    const r = 1.1 * Math.exp(0.3 * theta);
    return [Math.cos(theta + turn) * r, Math.sin(theta + turn) * r];
  };
  const outer: string[] = [];
  const inner: string[] = [];
  for (let step = 0; step <= steps; step++) {
    const theta = (step / steps) * end;
    const [x, y] = point(theta);
    const [nx, ny] = point(theta + 0.01);
    const length = Math.hypot(nx - x, ny - y) || 1;
    // The band's half-width, from 1.25 at the core to 0.2 at the tip.
    const half = 1.25 - (step / steps) * 1.05;
    const ox = (-(ny - y) / length) * half;
    const oy = ((nx - x) / length) * half;
    outer.push(`${(x + ox).toFixed(2)} ${(y + oy).toFixed(2)}`);
    inner.unshift(`${(x - ox).toFixed(2)} ${(y - oy).toFixed(2)}`);
  }
  return `M${outer.join('L')}L${inner.join('L')}Z`;
}

const ARMS = [arm(0), arm(Math.PI)];
/** Stars on the arms, in polar terms (angle, radius, size). */
const STARS: [number, number, number][] = [
  [0.9, 5.4, 0.55],
  [2.6, 7.6, 0.45],
  [4.2, 4.6, 0.5],
  [5.5, 8.2, 0.4],
  [1.7, 3.4, 0.4],
  [3.5, 9, 0.35],
];

export function Spiral({ id }: { id: string }) {
  return (
    <svg className={styles.icon} viewBox="-16 -16 32 32" aria-hidden="true" focusable="false">
      <defs>
        <radialGradient id={`${id}-arm`} cx="0" cy="0" r="10" gradientUnits="userSpaceOnUse">
          <stop offset="0" stopColor="#fff1d6" />
          <stop offset="0.35" stopColor="#c7b2ff" />
          <stop offset="1" stopColor="#6e86ff" />
        </radialGradient>
        <radialGradient id={`${id}-core`}>
          <stop offset="0" stopColor="#fff" />
          <stop offset="0.4" stopColor="#ffe2b3" />
          <stop offset="1" stopColor="#ffb86b" stopOpacity="0" />
        </radialGradient>
        <linearGradient
          id={`${id}-disc`}
          x1="-12"
          y1="0"
          x2="12"
          y2="0"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#fff4d4" />
          <stop offset="0.35" stopColor="#ffb44a" />
          <stop offset="0.75" stopColor="#ff7a2e" />
          <stop offset="1" stopColor="#c2451c" />
        </linearGradient>
        <radialGradient id={`${id}-white`}>
          <stop offset="0" stopColor="#fff" />
          <stop offset="0.3" stopColor="#fff" />
          <stop offset="0.55" stopColor="#dff3ff" stopOpacity="0.8" />
          <stop offset="1" stopColor="#9fd6ff" stopOpacity="0" />
        </radialGradient>
      </defs>

      <g className={styles.grow}>
        <g className={styles.spiral}>
          <g className={styles.spin}>
            {ARMS.map((d, index) => (
              <path key={index} d={d} className={styles.arm} fill={`url(#${id}-arm)`} />
            ))}
            {STARS.map(([angle, r, size], index) => (
              <circle
                key={index}
                className={styles.star}
                cx={(Math.cos(angle) * r).toFixed(2)}
                cy={(Math.sin(angle) * r).toFixed(2)}
                r={size}
              />
            ))}
          </g>
          <circle r="3.8" fill={`url(#${id}-core)`} />
        </g>
      </g>

      <g className={styles.blackHole}>
        {/* The far side of the disc, bent over the top by the hole's pull. */}
        <path
          className={styles.lensed}
          d="M-7.6 0A7.6 6.8 0 0 1 7.6 0"
          stroke={`url(#${id}-disc)`}
        />
        <path
          className={styles.disc}
          d="M-11.5 0A11.5 2.6 0 0 1 11.5 0"
          stroke={`url(#${id}-disc)`}
        />
        <circle className={styles.photonRing} r="5.3" />
        <circle className={styles.horizon} r="4.6" />
        {/* The near side, in front of the hole. */}
        <path
          className={styles.discFront}
          d="M-11.5 0A11.5 2.6 0 0 0 11.5 0"
          stroke={`url(#${id}-disc)`}
        />
      </g>

      <g className={styles.whiteHole}>
        <g className={styles.whiteRays}>
          {Array.from({ length: 8 }, (_, index) => (
            <path key={index} d="M0-7.2V-12.4" transform={`rotate(${index * 45 + 22.5})`} />
          ))}
        </g>
        <circle className={styles.whiteRing} r="7" />
        <circle r="7.4" fill={`url(#${id}-white)`} />
        <circle className={styles.whiteCore} r="2.6" />
      </g>
    </svg>
  );
}
```

### effort-slider/hole.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { Payoff, THUMB_X, between, clamp01, easeOut, pick, rgba, type Rgb } from './payoff';

/*
 * The galaxy theme's last step. The spiral has collapsed, and what it became depends on the page.
 *
 * - Light: a black hole. A ring of light falls in on it as it forms; the track's sky is pulled
 *   towards it, every mote of it streaking to the right faster and faster as it nears, until the
 *   dark swallows it; specks orbit in the hole's tilted disc (hidden while behind it), and the
 *   ring of bent light around it breathes.
 * - Dark: a white hole, the same thing run backwards. Two rings and a crown of rays burst out of
 *   it, and from then on it throws light out: motes stream away left along the track, slowing as
 *   they go, specks fly out all around, and a faint ring leaves it every 1.4 s.
 */

/** Half the box around the thumb. */
export const HOLE_REACH = [44, 34] as const;
const ARRIVAL = 1000;

const FALLING: Rgb[] = [
  [160, 132, 255],
  [236, 128, 214],
  [255, 255, 255],
  [110, 86, 230],
  [255, 200, 140],
];
const THROWN: Rgb[] = [
  [255, 255, 255],
  [191, 230, 255],
  [255, 233, 184],
  [199, 184, 255],
];
const DISC: Rgb[] = [
  [255, 214, 150],
  [255, 168, 80],
  [255, 240, 210],
];

/** A mote on the track. */
type Mote = { x: number; y: number; vx: number; vy: number; age: number; hue: Rgb; size: number };
/** A speck in the box, in polar terms around the hole. */
type Speck = { angle: number; r: number; speed: number; age: number; life: number; hue: Rgb };

export class Hole extends Payoff {
  private motes: Mote[] = [];
  private specks: Speck[] = [];
  private orbit: Speck[] = [];
  private rays: { angle: number; length: number }[] = [];
  private clock = 0;
  private due = 0;

  constructor(track: HTMLCanvasElement, box: HTMLCanvasElement, host: HTMLElement) {
    super(track, box, host, HOLE_REACH, ARRIVAL);
  }

  protected reset() {
    this.motes = [];
    this.specks = [];
    this.clock = 0;
    this.due = 0;
    this.orbit = Array.from({ length: 16 }, () => ({
      angle: between(0, Math.PI * 2),
      r: between(6.5, 14),
      speed: 0,
      age: 0,
      life: Infinity,
      hue: pick(DISC),
    }));
    this.rays = Array.from({ length: 16 }, (_, index) => ({
      angle: ((index + between(-0.3, 0.3)) / 16) * Math.PI * 2,
      length: between(14, 26),
    }));
  }

  protected arrive() {
    if (this.dark) {
      // The burst: a wave of motes thrown down the track, and specks all around.
      for (let i = 0; i < 70; i++) this.motes.push(this.thrown(between(0.35, 0.9)));
      for (let i = 0; i < 30; i++) this.specks.push(this.outward(between(0.04, 0.09)));
    } else {
      // The whole fill gives way at once, and a ring of specks falls in.
      for (let i = 0; i < 90; i++) this.motes.push(this.falling(between(0, THUMB_X - 12)));
      for (let i = 0; i < 26; i++)
        this.specks.push({
          angle: (i / 26) * Math.PI * 2,
          r: between(30, 40),
          speed: 0,
          age: 0,
          life: 520,
          hue: pick(FALLING),
        });
    }
  }

  private falling(x: number): Mote {
    return {
      x,
      y: between(1, 19),
      vx: 0,
      vy: 0,
      age: 0,
      hue: pick(FALLING),
      size: between(0.7, 1.3),
    };
  }
  private thrown(speed: number): Mote {
    const spread = between(-0.18, 0.18);
    return {
      x: THUMB_X - 4,
      y: 10 + spread * 10,
      vx: -speed,
      vy: spread * speed * 0.35,
      age: 0,
      hue: pick(THROWN),
      size: between(0.7, 1.4),
    };
  }
  private outward(speed: number): Speck {
    return {
      angle: between(0, Math.PI * 2),
      r: 5,
      speed,
      age: 0,
      life: between(500, 900),
      hue: pick(THROWN),
    };
  }

  protected advance(dt: number) {
    this.clock += dt;
    this.due -= dt;
    if (this.dark) {
      // Motes leave the hole, slowing as they go.
      while (this.due <= 0) {
        this.due += 22;
        this.motes.push(this.thrown(between(0.12, 0.32)));
        if (Math.random() < 0.5) this.specks.push(this.outward(between(0.02, 0.05)));
      }
      const drag = 0.9985 ** dt;
      for (const mote of this.motes) {
        mote.vx *= drag;
        mote.vy *= drag;
        mote.x += mote.vx * dt;
        mote.y += mote.vy * dt;
        mote.age += dt;
      }
      this.motes = this.motes.filter(
        (mote) => mote.x > -4 && mote.age < 2600 && mote.y > -2 && mote.y < 22,
      );
    } else {
      // Motes fall towards the hole, faster the nearer they are.
      while (this.due <= 0) {
        this.due += 26;
        this.motes.push(this.falling(between(0, THUMB_X - 30)));
      }
      for (const mote of this.motes) {
        const dx = THUMB_X - mote.x;
        const dy = 10 - mote.y;
        const distance = Math.hypot(dx, dy) || 1;
        const speed = 0.018 + 5 / (distance + 6);
        // A little swirl as they near: the disc turns.
        const swirl = Math.min(0.6, 8 / distance);
        mote.vx = ((dx - dy * swirl) / distance) * speed;
        mote.vy = ((dy + dx * swirl * 0.25) / distance) * speed;
        mote.x += mote.vx * dt;
        mote.y += mote.vy * dt;
        mote.age += dt;
      }
      this.motes = this.motes.filter((mote) => Math.hypot(THUMB_X - mote.x, 10 - mote.y) > 4.5);
    }
    for (const speck of this.specks) {
      speck.age += dt;
      if (this.dark) speck.r += speck.speed * dt;
      // Falling in: from its start to the edge of the dark, faster and faster, turning.
      else {
        const k = clamp01(speck.age / speck.life);
        speck.r = Math.max(4.6, speck.r - (speck.r - 4.6) * k * 0.12 * (dt / 16));
        speck.angle += 0.004 * dt * (1 + k * 3);
      }
    }
    this.specks = this.specks.filter((speck) => speck.age < speck.life);
    for (const speck of this.orbit) speck.angle += 0.0035 * (9 / speck.r) ** 1.5 * dt;
  }

  protected paintTrack(context: CanvasRenderingContext2D) {
    context.lineCap = 'round';
    for (const mote of this.motes) {
      const speed = Math.hypot(mote.vx, mote.vy);
      const fadeIn = clamp01(mote.age / 240);
      const tail = 24 + speed * 30;
      if (this.dark) {
        const fade = fadeIn * clamp01(1 - mote.age / 2600) * clamp01(mote.x / 40);
        context.globalAlpha = fade;
      } else {
        const near = clamp01(1 - (THUMB_X - mote.x) / 180);
        context.globalAlpha = fadeIn * (0.45 + 0.55 * near);
      }
      context.strokeStyle = rgba(mote.hue);
      context.lineWidth = mote.size;
      context.beginPath();
      context.moveTo(mote.x, mote.y);
      context.lineTo(mote.x - mote.vx * tail, mote.y - mote.vy * tail);
      context.stroke();
    }
  }

  protected paintBox(context: CanvasRenderingContext2D, t: number) {
    const clock = this.clock;
    if (this.dark) {
      // Rings and rays out of the core on arrival.
      if (t < ARRIVAL) {
        for (const [delay, reach] of [
          [0, 46],
          [150, 34],
        ]) {
          const k = easeOut(t, delay, delay + 650);
          if (t < delay) continue;
          context.globalAlpha = (1 - k) * 0.9;
          context.strokeStyle = '#fff';
          context.lineWidth = 2.4 * (1 - k) + 0.4;
          context.beginPath();
          context.arc(0, 0, 5 + reach * k, 0, Math.PI * 2);
          context.stroke();
        }
        const grow = easeOut(t, 0, 260);
        const fade = 1 - clamp01((t - 300) / 600);
        context.lineCap = 'round';
        for (const ray of this.rays) {
          const end = 6 + ray.length * grow;
          const gradient = context.createLinearGradient(
            0,
            0,
            Math.cos(ray.angle) * end,
            Math.sin(ray.angle) * end,
          );
          gradient.addColorStop(0.2, 'rgb(255 255 255 / 0.9)');
          gradient.addColorStop(1, 'rgb(190 225 255 / 0)');
          context.globalAlpha = fade;
          context.strokeStyle = gradient;
          context.lineWidth = 1.1;
          context.beginPath();
          context.moveTo(Math.cos(ray.angle) * 6, Math.sin(ray.angle) * 6);
          context.lineTo(Math.cos(ray.angle) * end, Math.sin(ray.angle) * end);
          context.stroke();
        }
      }
      // A faint ring leaves every 1.4 s.
      const k = (clock % 1400) / 1400;
      context.globalAlpha = 0.4 * (1 - k);
      context.strokeStyle = 'rgb(210 236 255)';
      context.lineWidth = 0.8;
      context.beginPath();
      context.arc(0, 0, 7 + 24 * k, 0, Math.PI * 2);
      context.stroke();
      for (const speck of this.specks) {
        context.globalAlpha = 1 - speck.age / speck.life;
        context.fillStyle = rgba(speck.hue);
        const x = Math.cos(speck.angle) * speck.r;
        const y = Math.sin(speck.angle) * speck.r;
        context.fillRect(x - 0.6, y - 0.6, 1.2, 1.2);
      }
      return;
    }

    // Black hole: the ring of light that falls in as it forms.
    if (t < ARRIVAL) {
      const k = 1 - (1 - clamp01(t / 480)) ** 2;
      const r = 38 - 32 * k;
      context.globalAlpha = clamp01(t / 60) * (1 - clamp01((t - 420) / 200));
      context.strokeStyle = 'rgb(176 156 255)';
      context.lineWidth = 0.8 + 2.2 * k;
      context.beginPath();
      context.arc(0, 0, r, 0, Math.PI * 2);
      context.stroke();
      for (const speck of this.specks) {
        context.globalAlpha = 1 - clamp01((speck.r - 4.6) / 30) * 0.4;
        context.fillStyle = rgba(speck.hue);
        const x = Math.cos(speck.angle) * speck.r;
        const y = Math.sin(speck.angle) * speck.r;
        context.fillRect(x - 0.7, y - 0.7, 1.4, 1.4);
      }
    }
    // The ring of bent light breathes.
    const formed = easeOut(t, 300, 700);
    context.globalAlpha = formed * (0.3 + 0.18 * Math.sin(clock / 320));
    context.strokeStyle = 'rgb(255 220 160)';
    context.lineWidth = 1.4;
    context.beginPath();
    context.arc(0, 0, 6.4, 0, Math.PI * 2);
    context.stroke();
    // Specks in the tilted disc, hidden while they pass behind the dark.
    for (const speck of this.orbit) {
      const x = Math.cos(speck.angle) * speck.r;
      const y = Math.sin(speck.angle) * speck.r * 0.24;
      if (y < 0 && Math.abs(x) < 4.8) continue;
      context.globalAlpha = formed * (0.55 + 0.45 * Math.cos(speck.angle));
      context.fillStyle = rgba(speck.hue);
      context.fillRect(x - 0.55, y - 0.55, 1.1, 1.1);
    }
  }
}
```

### effort-slider/car.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import styles from './themes.module.css';

/*
 * The formula theme's thumb: a single-seater racing car seen from above, nose to the right. Step
 * by step (the stylesheet reads --pos) speed lines stream off its back, the exhaust glows and the
 * car shivers on its springs. On the last step the rear wing's flap opens, the rain light blinks
 * and the car pulls ahead of its own place on the track.
 *
 * Units are px of the slider at its own size, around the thumb's middle.
 */

/** The body: narrow at the gearbox, wide at the sidepods, a long tapering nose. */
const BODY =
  'M-11 -2.2L-6.4 -3.3C-2.6 -3.6 -0.4 -3.2 1.4 -1.9L11.9 -0.9Q13.3 0 11.9 0.9L1.4 1.9' +
  'C-0.4 3.2 -2.6 3.6 -6.4 3.3L-11 2.2Z';
/** The front wing, a shallow arrow under the nose. */
const FRONT_WING = 'M9.4 -5.6L11.4 -5.4L12.4 -0.4L12.4 0.4L11.4 5.4L9.4 5.6L10.4 0Z';

export function Car({ id }: { id: string }) {
  return (
    <svg className={styles.icon} viewBox="-16 -16 32 32" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient
          id={`${id}-paint`}
          x1="0"
          y1="-4"
          x2="0"
          y2="4"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#ff5a4f" />
          <stop offset="0.5" stopColor="#e3241b" />
          <stop offset="1" stopColor="#a8130d" />
        </linearGradient>
        <radialGradient id={`${id}-heat`}>
          <stop offset="0" stopColor="#fff3c4" />
          <stop offset="0.4" stopColor="#ffa040" stopOpacity="0.8" />
          <stop offset="1" stopColor="#ff5a1f" stopOpacity="0" />
        </radialGradient>
        <linearGradient
          id={`${id}-line`}
          x1="-26"
          y1="0"
          x2="-13"
          y2="0"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#fff" stopOpacity="0" />
          <stop offset="1" stopColor="#fff" stopOpacity="0.9" />
        </linearGradient>
      </defs>

      <g className={styles.ahead}>
        <g className={styles.speedLines} stroke={`url(#${id}-line)`}>
          <path d="M-26 -3.6H-14" />
          <path d="M-24 0.4H-13.4" />
          <path d="M-27 4H-14.4" />
        </g>

        <g className={styles.chassis}>
          <circle className={styles.heat} cx="-12.4" r="3" fill={`url(#${id}-heat)`} />
          {/* Suspension, under the body. */}
          <path
            className={styles.arms}
            d="M-8.6 -2.6L-8.6 -4.6M-8.6 2.6L-8.6 4.6M6.4 -1.4L6.8 -4.2M6.4 1.4L6.8 4.2"
          />
          <g className={styles.tyres}>
            <rect x="-11" y="-6.4" width="4.8" height="2.9" rx="0.9" />
            <rect x="-11" y="3.5" width="4.8" height="2.9" rx="0.9" />
            <rect x="5" y="-5.9" width="3.8" height="2.5" rx="0.8" />
            <rect x="5" y="3.4" width="3.8" height="2.5" rx="0.8" />
          </g>
          <path className={styles.frontWing} d={FRONT_WING} />
          <path className={styles.body} d={BODY} fill={`url(#${id}-paint)`} />
          <path className={styles.stripe} d="M-9.6 0H10.6" />
          <ellipse className={styles.cockpit} cx="-1.6" rx="2.3" ry="1.25" />
          <path className={styles.cockpitHalo} d="M-3.6 -1.5Q0.4 -2 0.9 0Q0.4 2 -3.6 1.5" />
          {/* The rear wing: its fixed plane and the flap that opens on the last step. */}
          <rect className={styles.wing} x="-13.2" y="-4.8" width="1.2" height="9.6" rx="0.4" />
          <rect className={styles.flap} x="-12.2" y="-4.6" width="1.2" height="9.2" rx="0.4" />
          <rect className={styles.rainLight} x="-13.9" y="-0.7" width="0.8" height="1.4" rx="0.3" />
        </g>
      </g>
    </svg>
  );
}
```

### effort-slider/overtake.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { Payoff, THUMB_X, between, clamp01, easeOut, pick, rgba, type Rgb } from './payoff';

/*
 * The formula theme's last step: the overtake.
 *
 * - The track runs: its kerbs stream past at the edges and speed lines rush left across the tarmac.
 * - The car draws light trails, a red one from its rain light and two pale ones from its wing tips,
 *   that shoot back down the track in a third of a second and then ripple as it weaves.
 * - A whoosh, a bright band of air, sweeps from the car down the whole track on arrival and again
 *   every 2.4 s, a lap.
 * - Around the car: arcs of pushed air leave its nose on arrival, and sparks spray from under its
 *   floor where it scrapes the tarmac.
 */

export const OVERTAKE_REACH = [56, 26] as const;
const ARRIVAL = 900;
/** Where the car's rear wing is on the track, with the theme's last-step scale. */
const REAR = THUMB_X - 14;
const LAP = 2400;

const SPARKS: Rgb[] = [
  [255, 236, 170],
  [255, 176, 72],
  [255, 255, 255],
  [255, 120, 40],
];

type Line = { x: number; y: number; length: number; speed: number; alpha: number };
type Spark = { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: Rgb };

export class Overtake extends Payoff {
  private lines: Line[] = [];
  private sparks: Spark[] = [];
  private clock = 0;
  private due = 0;

  constructor(track: HTMLCanvasElement, box: HTMLCanvasElement, host: HTMLElement) {
    super(track, box, host, OVERTAKE_REACH, ARRIVAL);
  }

  protected reset() {
    this.clock = 0;
    this.due = 0;
    this.sparks = [];
    this.lines = Array.from({ length: 30 }, () => this.line(between(0, REAR)));
  }

  private line(x: number): Line {
    return {
      x,
      y: between(2.5, 17.5),
      length: between(8, 34),
      speed: between(0.35, 0.8),
      alpha: between(0.15, 0.5),
    };
  }

  private spark(speed: number): Spark {
    return {
      x: between(-11, -6),
      y: pick([-3.6, 3.6]) + between(-0.8, 0.8),
      vx: -speed,
      vy: between(-0.05, 0.05),
      age: 0,
      life: between(220, 460),
      hue: pick(SPARKS),
    };
  }

  protected arrive() {
    for (let i = 0; i < 30; i++) this.sparks.push(this.spark(between(0.1, 0.32)));
  }

  protected advance(dt: number) {
    this.clock += dt;
    this.due -= dt;
    while (this.due <= 0) {
      this.due += 28;
      this.sparks.push(this.spark(between(0.06, 0.2)));
    }
    for (const line of this.lines) {
      line.x -= line.speed * dt;
      if (line.x + line.length < 0) Object.assign(line, this.line(REAR + between(0, 10)));
    }
    for (const spark of this.sparks) {
      spark.x += spark.vx * dt;
      spark.y += spark.vy * dt;
      spark.vy += (spark.y > 0 ? 1 : -1) * 0.00012 * dt;
      spark.age += dt;
    }
    this.sparks = this.sparks.filter((spark) => spark.age < spark.life);
  }

  protected paintTrack(context: CanvasRenderingContext2D, t: number) {
    const clock = this.clock;
    // The kerbs stream past, red and white, 4 px a stripe.
    const shift = (clock * 0.3) % 8;
    context.globalAlpha = 0.9;
    for (let i = 0; i * 4 - shift < THUMB_X; i++) {
      context.fillStyle = i % 2 ? '#f4f4f4' : '#e3241b';
      context.fillRect(i * 4 - shift, 0, 4, 1.5);
      context.fillRect(i * 4 - shift, 18.5, 4, 1.5);
    }
    // Speed lines.
    for (const line of this.lines) {
      const gradient = context.createLinearGradient(line.x, 0, line.x + line.length, 0);
      gradient.addColorStop(0, 'rgb(255 255 255 / 0)');
      gradient.addColorStop(1, 'rgb(255 255 255 / 1)');
      context.globalAlpha = line.alpha;
      context.fillStyle = gradient;
      context.fillRect(line.x, line.y - 0.3, line.length, 0.6);
    }
    // The light trails: they shoot back over a third of a second, then ripple.
    const reach = REAR * easeOut(t, 0, 320);
    for (const [offset, hue, width] of [
      [-4.6, [210, 236, 255], 0.7],
      [4.6, [210, 236, 255], 0.7],
      [0, [255, 40, 40], 1.2],
    ] as const) {
      const gradient = context.createLinearGradient(REAR - reach, 0, REAR, 0);
      gradient.addColorStop(0, rgba(hue, 0));
      gradient.addColorStop(0.7, rgba(hue, 0.55));
      gradient.addColorStop(1, rgba(hue, 0.95));
      context.globalAlpha = 1;
      context.strokeStyle = gradient;
      context.lineWidth = width;
      context.lineCap = 'round';
      context.beginPath();
      for (let x = REAR; x >= REAR - reach; x -= 3) {
        const away = (REAR - x) / REAR;
        const y = 10 + offset * (1 - away * 0.25) + Math.sin((x + clock * 0.25) / 13) * 1.4 * away;
        if (x === REAR) context.moveTo(x, y);
        else context.lineTo(x, y);
      }
      context.stroke();
    }
    // The whoosh: on arrival, then once a lap.
    const since = clock % LAP;
    const at = THUMB_X - since * 0.62;
    if (at > -30) {
      const fade = clamp01(1 - since / 380) * 0.4 + 0.45;
      const band = context.createLinearGradient(at - 26, 0, at + 6, 0);
      band.addColorStop(0, 'rgb(170 225 255 / 0)');
      band.addColorStop(0.8, `rgb(225 245 255 / ${fade})`);
      band.addColorStop(1, 'rgb(255 255 255 / 0)');
      context.globalAlpha = 1;
      context.fillStyle = band;
      context.fillRect(at - 26, 0, 32, 20);
    }
  }

  protected paintBox(context: CanvasRenderingContext2D, t: number) {
    // Pushed air leaves the nose on arrival.
    if (t < ARRIVAL) {
      context.lineCap = 'round';
      for (const delay of [0, 110, 220]) {
        if (t < delay) continue;
        const k = easeOut(t, delay, delay + 520);
        context.globalAlpha = (1 - k) * 0.85;
        context.strokeStyle = this.dark ? '#cfeaff' : '#4e8fd6';
        context.lineWidth = 1.2 * (1 - k) + 0.4;
        context.beginPath();
        context.arc(6 + 26 * k, 0, 9 + 6 * k, -0.75, 0.75);
        context.stroke();
      }
    }
    for (const spark of this.sparks) {
      const k = spark.age / spark.life;
      context.globalAlpha = 1 - k;
      context.strokeStyle = rgba(spark.hue);
      context.lineWidth = 0.7;
      context.beginPath();
      context.moveTo(spark.x, spark.y);
      context.lineTo(spark.x - spark.vx * 14, spark.y - spark.vy * 14);
      context.stroke();
    }
  }
}
```

### effort-slider/missile.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import styles from './themes.module.css';

/*
 * The missile theme's thumb: a slim rocket seen side on, nose to the right, with fins at the back
 * and a flame out of its nozzle. Step by step (the stylesheet reads --pos) the first wisps of vapour
 * give way to a flame that grows and flickers faster. On the last step it lifts: the nose tips up,
 * the plume blazes white with shock diamonds in it, and the body shakes.
 *
 * Units are px of the slider at its own size, around the thumb's middle.
 */

const BODY = 'M-10 -2.2L5.6 -2.2C8.8 -2.2 11.4 -1.2 13.2 0C11.4 1.2 8.8 2.2 5.6 2.2L-10 2.2Z';
const NOSE = 'M8.6 -1.85C10.6 -1.5 12 -0.8 13.2 0C12 0.8 10.6 1.5 8.6 1.85Z';
const FIN = 'M-10 -2.2L-12.3 -6.2L-9.4 -6.2L-5.8 -2.2Z';
const CANARD = 'M0.4 -2.2L-1.2 -3.9L-3.2 -3.9L-2.8 -2.2Z';
/** The flame, from the nozzle (x 0) back to the left, and its hotter middle. */
const FLAME = 'M0 -1.7C-3 -2 -8.5 -1.1 -13 0C-8.5 1.1 -3 2 0 1.7Z';
const FLAME_INNER = 'M0 -1.1C-2.4 -1.2 -5.6 -0.6 -8.4 0C-5.6 0.6 -2.4 1.2 0 1.1Z';

export function Missile({ id }: { id: string }) {
  return (
    <svg className={styles.icon} viewBox="-16 -16 32 32" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient
          id={`${id}-hull`}
          x1="0"
          y1="-2.2"
          x2="0"
          y2="2.2"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#ffffff" />
          <stop offset="0.55" stopColor="#dde2ea" />
          <stop offset="1" stopColor="#a9b2c0" />
        </linearGradient>
        <linearGradient
          id={`${id}-flame`}
          x1="-13"
          y1="0"
          x2="0"
          y2="0"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#ff4d1a" stopOpacity="0" />
          <stop offset="0.45" stopColor="#ff7a1f" />
          <stop offset="1" stopColor="#ffc247" />
        </linearGradient>
      </defs>

      <g className={styles.rocket}>
        <g className={styles.shake}>
          <g className={styles.wisps}>
            <circle cx="-13" cy="-0.8" r="1.3" />
            <circle cx="-15.2" cy="0.6" r="1.7" />
            <circle cx="-12.6" cy="1.4" r="1" />
          </g>
          <g className={styles.flameAt}>
            <g className={styles.flame}>
              <g className={styles.boost}>
                <g className={styles.flicker}>
                  <path d={FLAME} fill={`url(#${id}-flame)`} />
                  <path className={styles.flameInner} d={FLAME_INNER} />
                  <g className={styles.diamonds}>
                    <path d="M-2.6 0L-3.6 -0.8L-4.6 0L-3.6 0.8Z" />
                    <path d="M-5.6 0L-6.5 -0.65L-7.4 0L-6.5 0.65Z" />
                    <path d="M-8.3 0L-9 -0.5L-9.7 0L-9 0.5Z" />
                  </g>
                </g>
              </g>
            </g>
          </g>
          <rect className={styles.nozzle} x="-11.3" y="-1.6" width="1.6" height="3.2" rx="0.4" />
          <path className={styles.fin} d={FIN} />
          <path className={styles.fin} d={FIN} transform="scale(1 -1)" />
          <path className={styles.hull} d={BODY} fill={`url(#${id}-hull)`} />
          <path className={styles.noseCone} d={NOSE} />
          <path className={styles.fin} d={CANARD} />
          <path className={styles.fin} d={CANARD} transform="scale(1 -1)" />
          <path className={styles.band} d="M-6.6 -2.2V2.2M2.6 -2.2V2.2" />
        </g>
      </g>
    </svg>
  );
}
```

### effort-slider/launch.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { Payoff, THUMB_X, between, clamp01, easeOut, pick, rgba, type Rgb } from './payoff';

/*
 * The missile theme's last step: the launch.
 *
 * - On arrival a flash of plume lights the thumb and two shockwave rings spread from the nozzle,
 *   flattened as if over the ground.
 * - Smoke billows: puffs leave the nozzle down and back, hit the bottom of the track, roll left
 *   along it, swell and thin out. The first second throws a big cloud at once. Near the nozzle the
 *   smoke is lit orange by the flame.
 * - Embers fly back out of the plume, and a faint heat ripple leaves the nozzle every 1.8 s.
 */

export const LAUNCH_REACH = [50, 34] as const;
const ARRIVAL = 1000;
/** The nozzle, around the thumb's middle, with the rocket tipped up 12° and lifted. */
const NOZZLE = [-11, 0.9] as const;
/** The exhaust's way: back and a little down. */
const EXHAUST = [-0.978, 0.208] as const;

const EMBERS: Rgb[] = [
  [255, 226, 140],
  [255, 160, 60],
  [255, 250, 230],
  [255, 110, 40],
];

type Puff = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  r: number;
  grow: number;
  age: number;
  life: number;
  shade: number;
};
type Ember = { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: Rgb };

export class Launch extends Payoff {
  private puffs: Puff[] = [];
  private embers: Ember[] = [];
  private clock = 0;
  private due = 0;

  constructor(track: HTMLCanvasElement, box: HTMLCanvasElement, host: HTMLElement) {
    super(track, box, host, LAUNCH_REACH, ARRIVAL);
  }

  protected reset() {
    this.puffs = [];
    this.embers = [];
    this.clock = 0;
    this.due = 0;
  }

  private puff(speed: number, spread: number): Puff {
    const angle = between(-spread, spread);
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
      x: THUMB_X + NOZZLE[0] - 2,
      y: 10 + NOZZLE[1],
      vx: (EXHAUST[0] * cos - EXHAUST[1] * sin) * speed,
      vy: (EXHAUST[0] * sin + EXHAUST[1] * cos) * speed,
      r: between(1, 1.8),
      grow: between(0.0025, 0.005),
      age: 0,
      life: between(1500, 2600),
      shade: Math.random(),
    };
  }

  private ember(speed: number): Ember {
    const angle = between(-0.5, 0.5);
    return {
      x: NOZZLE[0] - 4,
      y: NOZZLE[1],
      vx: (EXHAUST[0] * Math.cos(angle) - EXHAUST[1] * Math.sin(angle)) * speed,
      vy: (EXHAUST[0] * Math.sin(angle) + EXHAUST[1] * Math.cos(angle)) * speed,
      age: 0,
      life: between(280, 620),
      hue: pick(EMBERS),
    };
  }

  protected arrive() {
    for (let i = 0; i < 44; i++) this.puffs.push(this.puff(between(0.25, 0.6), 0.7));
    for (let i = 0; i < 26; i++) this.embers.push(this.ember(between(0.08, 0.2)));
  }

  protected advance(dt: number) {
    this.clock += dt;
    this.due -= dt;
    while (this.due <= 0) {
      this.due += 24;
      this.puffs.push(this.puff(between(0.14, 0.28), 0.35));
      if (Math.random() < 0.6) this.embers.push(this.ember(between(0.05, 0.14)));
    }
    const drag = 0.9975 ** dt;
    for (const puff of this.puffs) {
      puff.vx *= drag;
      puff.vy *= drag;
      // The ground: the bottom of the track turns the smoke along it.
      if (puff.y + puff.r * 0.5 > 19 && puff.vy > 0) {
        puff.vx -= puff.vy * 0.6;
        puff.vy *= -0.15;
      }
      // Smoke rises a little as it cools.
      puff.vy -= 0.00002 * dt;
      puff.x += puff.vx * dt;
      puff.y += puff.vy * dt;
      puff.r += puff.grow * dt;
      puff.age += dt;
    }
    this.puffs = this.puffs.filter((puff) => puff.age < puff.life && puff.x + puff.r > -2);
    for (const ember of this.embers) {
      ember.x += ember.vx * dt;
      ember.y += ember.vy * dt;
      ember.vy += 0.00025 * dt;
      ember.age += dt;
    }
    this.embers = this.embers.filter((ember) => ember.age < ember.life);
  }

  protected paintTrack(context: CanvasRenderingContext2D) {
    const nozzleX = THUMB_X + NOZZLE[0];
    // The flame lights the smoke near it.
    const glow = context.createRadialGradient(nozzleX, 11, 0, nozzleX, 11, 34);
    glow.addColorStop(0, 'rgb(255 190 90 / 0.75)');
    glow.addColorStop(0.4, 'rgb(255 140 50 / 0.3)');
    glow.addColorStop(1, 'rgb(255 120 40 / 0)');
    context.fillStyle = glow;
    context.fillRect(nozzleX - 34, 0, 68, 20);

    for (const puff of this.puffs) {
      const k = puff.age / puff.life;
      const fade = clamp01(puff.age / 120) * (1 - k) ** 1.4;
      // Lit warm near the nozzle, cooling to the smoke's grey.
      const heat = clamp01(1 - (nozzleX - puff.x) / 40);
      const base = this.dark ? 96 + puff.shade * 40 : 196 + puff.shade * 50;
      const r = base + (255 - base) * heat * 0.8;
      const g = base + (190 - base) * heat * 0.8;
      const b = base + (120 - base) * heat * 0.8 + (this.dark ? 6 : 8);
      context.globalAlpha = fade * (this.dark ? 0.7 : 0.85);
      context.fillStyle = `rgb(${r} ${g} ${b})`;
      context.beginPath();
      context.arc(puff.x, puff.y, puff.r, 0, Math.PI * 2);
      context.fill();
      // A lighter top, so each puff reads round.
      context.globalAlpha = fade * 0.35;
      context.fillStyle = this.dark ? 'rgb(170 172 180)' : '#fff';
      context.beginPath();
      context.arc(puff.x - puff.r * 0.2, puff.y - puff.r * 0.3, puff.r * 0.55, 0, Math.PI * 2);
      context.fill();
    }
  }

  protected paintBox(context: CanvasRenderingContext2D, t: number) {
    const [nx, ny] = NOZZLE;
    if (t < ARRIVAL) {
      // The plume's flash.
      const flash = 1 - clamp01(t / 520);
      const light = context.createRadialGradient(nx - 4, ny, 0, nx - 4, ny, 26);
      light.addColorStop(0, `rgb(255 250 225 / ${flash})`);
      light.addColorStop(0.35, `rgb(255 196 90 / ${flash * 0.6})`);
      light.addColorStop(1, 'rgb(255 140 40 / 0)');
      context.fillStyle = light;
      context.fillRect(nx - 30, ny - 26, 52, 52);
      // Two shockwaves, flattened as over the ground.
      for (const [delay, reach] of [
        [0, 46],
        [150, 30],
      ]) {
        if (t < delay) continue;
        const k = easeOut(t, delay, delay + 620);
        context.globalAlpha = (1 - k) * 0.9;
        context.strokeStyle = this.dark ? 'rgb(255 240 220)' : 'rgb(110 118 136)';
        context.lineWidth = 2.2 * (1 - k) + 0.4;
        context.beginPath();
        context.ellipse(nx, ny, 3 + reach * k, (3 + reach * k) * 0.6, 0, 0, Math.PI * 2);
        context.stroke();
      }
    }
    // A heat ripple every 1.8 s.
    const k = (this.clock % 1800) / 1800;
    context.globalAlpha = 0.3 * (1 - k);
    context.strokeStyle = this.dark ? 'rgb(255 214 170)' : 'rgb(150 156 170)';
    context.lineWidth = 0.7;
    context.beginPath();
    context.ellipse(nx, ny, 4 + 22 * k, (4 + 22 * k) * 0.6, 0, 0, Math.PI * 2);
    context.stroke();
    for (const ember of this.embers) {
      context.globalAlpha = 1 - ember.age / ember.life;
      context.fillStyle = rgba(ember.hue);
      context.fillRect(ember.x - 0.55, ember.y - 0.55, 1.1, 1.1);
    }
  }
}
```

### effort-slider/jet.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import styles from './themes.module.css';

/*
 * The jet theme's thumb: a swing-wing fighter seen from above, nose to the right. Step by step (the
 * stylesheet reads --pos) its wings sweep back from spread to tucked, and from the middle steps
 * the afterburner lights. On the last step it breaks the sound barrier: a vapour cone blooms over
 * it from the nose back, and the flame shows its shock diamonds.
 *
 * Units are px of the slider at its own size, around the thumb's middle.
 */

const FUSELAGE = 'M-12 -1.4L5.8 -1.6C9 -1.5 11.6 -0.8 13.6 0C11.6 0.8 9 1.5 5.8 1.6L-12 1.4Z';
/** The upper wing, pivoting at (0.6, -1.2); the lower is its mirror. */
const WING = 'M2.2 -1.4L-1.4 -10.4L-3.6 -10.4L-3.8 -1.4Z';
const TAIL = 'M-7.6 -1.3L-10.8 -5.2L-12.4 -5.2L-11.6 -1.3Z';
/** The vapour cone: a bell from just behind the nose that opens backwards. */
const CONE =
  'M7.4 0C6 -2.6 3.2 -7.2 -1 -10.6C-4 -12.8 -7.6 -13.6 -9.4 -13.4C-8.6 -11 -8.6 11 -9.4 13.4' +
  'C-7.6 13.6 -4 12.8 -1 10.6C3.2 7.2 6 2.6 7.4 0Z';

export function Jet({ id }: { id: string }) {
  return (
    <svg className={styles.icon} viewBox="-16 -16 32 32" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient
          id={`${id}-skin`}
          x1="0"
          y1="-1.6"
          x2="0"
          y2="1.6"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#eef2f6" />
          <stop offset="1" stopColor="#a7b3c2" />
        </linearGradient>
        <linearGradient
          id={`${id}-burner`}
          x1="-24"
          y1="0"
          x2="-12"
          y2="0"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#ff6a1f" stopOpacity="0" />
          <stop offset="0.5" stopColor="#ff9a3a" />
          <stop offset="0.85" stopColor="#ffe7a8" />
          <stop offset="1" stopColor="#bfe3ff" />
        </linearGradient>
        <radialGradient id={`${id}-cone`} cx="7" cy="0" r="17" gradientUnits="userSpaceOnUse">
          <stop offset="0.25" stopColor="#fff" stopOpacity="0.05" />
          <stop offset="0.75" stopColor="#fff" stopOpacity="0.55" />
          <stop offset="1" stopColor="#fff" stopOpacity="0.9" />
        </radialGradient>
      </defs>

      <g className={styles.burner}>
        <g className={styles.boost}>
          <g className={styles.flicker}>
            <path
              d="M-12 -1.2C-15 -1.4 -20 -0.7 -24 0C-20 0.7 -15 1.4 -12 1.2Z"
              fill={`url(#${id}-burner)`}
            />
            <g className={styles.diamonds}>
              <path d="M-14 0L-14.9 -0.7L-15.8 0L-14.9 0.7Z" />
              <path d="M-16.9 0L-17.7 -0.55L-18.5 0L-17.7 0.55Z" />
            </g>
          </g>
        </g>
      </g>
      <path className={styles.tail} d={TAIL} />
      <path className={styles.tail} d={TAIL} transform="scale(1 -1)" />
      <g className={styles.wingUp}>
        <path className={styles.wingSkin} d={WING} />
      </g>
      <g className={styles.wingDown}>
        <path className={styles.wingSkin} d={WING} transform="scale(1 -1)" />
      </g>
      <path className={styles.fuselage} d={FUSELAGE} fill={`url(#${id}-skin)`} />
      <ellipse className={styles.canopy} cx="6.4" rx="2.5" ry="0.85" />
      <g className={styles.coneAt}>
        <path className={styles.cone} d={CONE} fill={`url(#${id}-cone)`} />
      </g>
    </svg>
  );
}
```

### effort-slider/sonic.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { Payoff, THUMB_X, between, clamp01, easeOut } from './payoff';

/*
 * The jet theme's last step: breaking the sound barrier. The vapour cone itself is part of the
 * thumb's drawing (jet.tsx); this draws what goes with it.
 *
 * - On arrival, the boom: three ring shocks spread from the jet, a beat apart, and a shock front, a
 *   hard bright edge with a glow behind it, sweeps the whole track from the jet to the left end.
 * - The track becomes a streaked sky: thin wind lines rush left past the jet, fast, and two
 *   contrails trail from its wing tips, swelling and drifting as they age.
 * - Specks of condensation peel off the cone's rim and fall back; a faint ring leaves the jet every
 *   two seconds.
 */

export const SONIC_REACH = [46, 34] as const;
const ARRIVAL = 1100;
/** Where the contrails start, around the thumb's middle: the swept wings' tips. */
const TIPS = [-6, 7.2] as const;

type Streak = { x: number; y: number; length: number; speed: number; alpha: number; width: number };
type Speck = { x: number; y: number; vx: number; vy: number; age: number; life: number };

/** A point on the vapour cone's rim, from its nose (0) to its open end (1), above or below. */
const rim = (s: number, side: number) => [
  7 - 16 * s,
  side * (2 + 11.5 * Math.sin((s * Math.PI) / 2)),
];

export class Sonic extends Payoff {
  private streaks: Streak[] = [];
  private specks: Speck[] = [];
  private clock = 0;
  private due = 0;

  constructor(track: HTMLCanvasElement, box: HTMLCanvasElement, host: HTMLElement) {
    super(track, box, host, SONIC_REACH, ARRIVAL);
  }

  protected reset() {
    this.clock = 0;
    this.due = 0;
    this.specks = [];
    this.streaks = Array.from({ length: 40 }, () => this.streak(between(0, THUMB_X)));
  }

  private streak(x: number): Streak {
    return {
      x,
      y: between(1, 19),
      length: between(12, 46),
      speed: between(0.55, 1.3),
      alpha: between(0.2, 0.65),
      width: between(0.4, 0.9),
    };
  }

  private speck(): Speck {
    const [x, y] = rim(between(0.2, 1), Math.random() < 0.5 ? -1 : 1);
    return { x, y, vx: -between(0.05, 0.13), vy: y * 0.002, age: 0, life: between(300, 700) };
  }

  protected arrive() {
    for (let i = 0; i < 24; i++) this.specks.push(this.speck());
  }

  protected advance(dt: number) {
    this.clock += dt;
    this.due -= dt;
    while (this.due <= 0) {
      this.due += 30;
      this.specks.push(this.speck());
    }
    for (const streak of this.streaks) {
      streak.x -= streak.speed * dt;
      if (streak.x + streak.length < 0)
        Object.assign(streak, this.streak(THUMB_X + between(-10, 4)));
    }
    for (const speck of this.specks) {
      speck.x += speck.vx * dt;
      speck.y += speck.vy * dt;
      speck.age += dt;
    }
    this.specks = this.specks.filter((speck) => speck.age < speck.life);
  }

  protected paintTrack(context: CanvasRenderingContext2D, t: number) {
    const clock = this.clock;
    const ink = this.dark ? '226 238 255' : '255 255 255';
    for (const streak of this.streaks) {
      const gradient = context.createLinearGradient(streak.x, 0, streak.x + streak.length, 0);
      gradient.addColorStop(0, `rgb(${ink} / 0)`);
      gradient.addColorStop(1, `rgb(${ink} / 1)`);
      context.globalAlpha = streak.alpha;
      context.fillStyle = gradient;
      context.fillRect(streak.x, streak.y - streak.width / 2, streak.length, streak.width);
    }
    // The contrails, from the wing tips: swelling, drifting and thinning as they age.
    const start = THUMB_X + TIPS[0];
    context.fillStyle = `rgb(${ink})`;
    for (const side of [-1, 1]) {
      for (let x = start; x > 0; x -= 2) {
        const age = (start - x) / start;
        const y =
          10 + side * TIPS[1] * (1 - age * 0.3) + Math.sin((x + clock * 0.4) / 9) * age * 1.6;
        context.globalAlpha = 0.75 * (1 - age) ** 1.3;
        context.beginPath();
        context.arc(x, y, 0.55 + age * 2.4, 0, Math.PI * 2);
        context.fill();
      }
    }
    // The shock front sweeps the track on arrival.
    if (t < ARRIVAL) {
      const at = THUMB_X - t * 0.36;
      const fade = 1 - clamp01((t - 300) / 260);
      context.globalAlpha = 1;
      const glow = context.createLinearGradient(at, 0, at + 40, 0);
      glow.addColorStop(0, `rgb(255 255 255 / ${0.9 * fade})`);
      glow.addColorStop(1, 'rgb(255 255 255 / 0)');
      context.fillStyle = glow;
      context.fillRect(at, 0, 40, 20);
      context.fillStyle = `rgb(255 255 255 / ${fade})`;
      context.fillRect(at - 0.6, 0, 1.2, 20);
    }
  }

  protected paintBox(context: CanvasRenderingContext2D, t: number) {
    const ring = this.dark ? '232 244 255' : '74 136 214';
    if (t < ARRIVAL) {
      for (const delay of [0, 120, 260]) {
        if (t < delay) continue;
        const k = easeOut(t, delay, delay + 700);
        context.globalAlpha = (1 - k) * 0.9;
        context.strokeStyle = `rgb(${ring})`;
        context.lineWidth = 1.6 * (1 - k) + 0.35;
        context.beginPath();
        context.ellipse(2, 0, 6 + 40 * k, (6 + 40 * k) * 0.82, 0, 0, Math.PI * 2);
        context.stroke();
      }
    }
    const k = (this.clock % 2000) / 2000;
    context.globalAlpha = 0.28 * (1 - k);
    context.strokeStyle = `rgb(${ring})`;
    context.lineWidth = 0.6;
    context.beginPath();
    context.ellipse(2, 0, 9 + 22 * k, (9 + 22 * k) * 0.82, 0, 0, Math.PI * 2);
    context.stroke();
    context.fillStyle = this.dark ? '#fff' : 'rgb(120 168 226)';
    for (const speck of this.specks) {
      context.globalAlpha = (1 - speck.age / speck.life) * 0.9;
      context.fillRect(speck.x - 0.5, speck.y - 0.5, 1, 1);
    }
  }
}
```

### effort-slider/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```