# Sticker lens

> A coming-soon page with a pile of stickers to throw around and a glass ball that magnifies and bends everything under it.

A shop that has not opened yet, with a pile of black-and-white stickers to pick up, stack and fling, and a glass ball lying on top. The ball magnifies what is beneath it, the page’s own words included, squeezes the world in at its rim and splits colours along its steep edge. A settings panel tunes its radius, magnification, index of refraction, aberration, thickness, bevel, oblateness, refraction and frost, in light and dark.

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

Category: Media.

## Anatomy

1. **Page**: One stage at 4:3, at least 600 px tall, with rounded corners: everything inside is placed on it and scaled with it.
2. **Brand**: The hand mark and the `brand` name at the top left, in the muted ink.
3. **Scheme switch**: Dark, light and system in one white pill; the pressed one follows `colorScheme` or the page.
4. **Settings button**: A solid black disc that opens and closes the lens’s settings panel.
5. **Words**: `eyebrow` in capitals, `title` large and light, `lead` with a down arrow: real text, painted into the scene so the lens can bend it.
6. **Sticker**: One of twelve SVG drawings in four inks, each a button: drag to throw it, or move it with the arrow keys.
7. **Lens**: A WebGL canvas in a round button that always lies on top, sized by `lens.radius` and dragged like a sticker.
8. **Nav pill**: Work, About, Shop and Contact in one pill at the bottom; the current item gets a grey pill of its own.

## When to use

- A holding page while a shop or a launch is not ready yet, where the page itself is the thing to play with until it opens.
- A brand with physical goods (stickers, prints, merch) that wants the reader to pick them up, stack them and throw them around.
- Showing off a tactile, glassy material: the lens magnifies and bends the page’s own words, not a stock picture.
- A playground for tuning a refraction effect, with presets to compare and the values to copy as JSON.

## When not to use

- A page whose job is to collect an email or sell something now. Use a plain hero with a form and one clear call to action.
- Magnifying a product photo to inspect its detail. Use an image zoom with a loupe on the picture alone.
- Arranging items that must keep their order or be saved. Use a sortable list or a board with drag handles.
- Low-power screens or pages that must stay quiet: it paints the page into a canvas and runs a WebGL shader. Use a static hero.
- A frosted surface behind a menu or a sheet. Use backdrop-filter on that surface.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 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 { StickerLens } from './sticker-lens';

export function ShopPage() {
  return (
    <StickerLens
      eyebrow="Shop"
      title="Coming soon"
      lens={{ magnification: 2, oblateness: 0.8 }}
      autoplay={false}
    />
  );
}
```

## States

- **Resting**: The pile at home and the lens on it, the panel closed: what a reader finds with `autoplay={false}`.
- **Panel open**: `defaultPanelOpen`: the settings panel over the header, with the Open switch, the versions and nine scrub rows.
- **Lens off**: `defaultLensOpen={false}`: the page and the pile without the glass, as after Open → Off.
- **Strong**: `lens={{ radius: 140, magnification: 2.4, frost: 0.35, oblateness: 0.9 }}`: a smaller, frosted ball that magnifies more, as Version 3.
- **Thick and flat**: `lens={{ magnification: 1.3, oblateness: 0.3, aberration: 0.02, thickness: 1.2 }}`: a rounder dome with wider colour fringes, as Version 2.
- **Narrow**: Under 640 px the stickers lie in a taller pile and the lens moves to its middle.
- **Light**: `colorScheme="light"`: a pale grey page with black ink on white stickers.
- **Dark**: `colorScheme="dark"`: a black page; ink and paper swap, so a black label turns white.

## How it works

- The glass needs a picture of the page. The HTML-in-canvas proposal (a canvas with layoutsubtree and drawElementImage) would hand it the live DOM but is still behind a flag, and an SVG displacement filter as a backdrop-filter only works in Chromium and cannot follow a parametric dome. So the page is painted into a canvas from what it is made of: its boxes and words from their computed styles, on their real baselines, then the stickers, in stacking order, as the very SVG images the page shows.
- That canvas becomes a WebGL texture, and a fragment shader draws the lens. Each pixel reads the scene at the centre plus its offset over the magnification, plus an outward bend from the slope of a superellipse dome: the higher the oblateness, the flatter the middle and the steeper the rim, so the edge squeezes in what lies beyond the disc.
- The bend grows with the thickness, with 1 − 1/ior (Snell’s law for small angles) and with the refraction displacement, and reaches full strength past the bevel start. Red and blue are read a few px either side of green along the slope (the aberration), frost averages nine reads, and a fresnel edge, a thin ring of light and a soft glint make it read as glass by day and by night.
- The scene is repainted only when something under the glass changes: a sticker moves, the scheme or the size changes, fonts arrive. Moving the lens only redraws the lens. Without WebGL, a 2D canvas draws a plain magnifier with the same rim.
- Every sticker is an original SVG drawing in four inks (ink, paper, a grey and a pale grey) read from the colour tokens, so at night ink and paper swap and a black label turns white.
- Picking a sticker up raises it to the top of the pile, lifts it 5% with a deeper shadow and tilts it with the drag’s speed; letting go throws it with the release speed, slowed by friction and bounced off the page’s edges. The lens glides after the pointer that drags it, and always lies on top.
- Positions are kept in the design’s px around the pile’s middle and scaled to the stage, so the arrangement survives any size; under 640 px the stickers lie in a taller pile.
- While on screen and untouched, a demo pointer rolls the lens over the pile and the title, slides a sticker under it, opens the panel and scrubs the oblateness, then tidies up. Any real input hands it over.

## API reference

- **title** (`string`, default `'Coming soon'`): The big line in the middle of the page.
- **eyebrow** (`string`, default `'Shop'`): The small capitals above it.
- **lead** (`string`, default `'Meanwhile, click, drag and explore'`): The line under it, followed by a down arrow.
- **brand** (`string`, default `'Studio Kite'`): The name at the top left.
- **lens** (`Partial<LensParams>`): The lens’s starting tuning: radius (px), magnification, ior, aberration, thickness, bevelStart, oblateness, refraction and frost. Anything left out keeps its default (180, 1.75, 1.52, 0.01, 0.9, 0.9, 0.65, 0.6, 0).
- **defaultLensOpen** (`boolean`, default `true`): The lens starts on the page (the panel’s Open switch).
- **defaultPanelOpen** (`boolean`, default `false`): The settings panel starts open.
- **follow** (`boolean`, default `false`): The lens follows a mouse or pen around the page, instead of waiting to be dragged.
- **onLensChange** (`(params: LensParams) => void`): Called with the tuning whenever the panel changes it.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer plays with the lens, a sticker and the panel.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme. The page’s own header buttons change it too.
- **className** (`string`): Classes for the page’s box, which takes its width from outside.

## Accessibility

- Every sticker and the lens is a real button with a name (what the sticker shows) and a hint: Tab reaches each one, and the arrow keys move it (Shift for bigger steps), raising a sticker to the top.
- The settings panel is a labelled dialog: the toolbar button says whether it is expanded, Escape closes it and gives the focus back, and every row is a native range input, so keyboards and screen readers read and change its value as on any slider.
- The Open switch and the colour-scheme buttons are toggle buttons with aria-pressed; the nav marks the current page with aria-current.
- Saving or loading a version, copying the settings, switching the lens and resetting the pile are announced in a polite live region.
- The lens canvas, the sticker art and the demo pointer are hidden from assistive technology; the words the lens bends stay real text.
- With reduced motion there is no demo, a thrown sticker stops where it is let go and the lens jumps to the pointer instead of gliding.

## Source

### sticker-lens/sticker-lens.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { createLensRenderer, type LensParams, type LensRenderer } from './lens-renderer';
import { Panel, ROWS, SlidersIcon, type Version } from './panel';
import { paintScene, type ScenePiece } from './scene';
import { LENS_HOME, PILE, STICKERS, toDataUrl, type Inks } from './stickers';
import { useAutoplay } from './use-autoplay';
import styles from './sticker-lens.module.css';

/*
 * Sticker lens: a "coming soon" shop page with a pile of black-and-white stickers to pick up and
 * throw around, and a glass ball lying on the pile that magnifies and bends whatever is under it,
 * the page's own words included. A settings panel tunes the glass.
 *
 * How the glass sees the page. Three ways were weighed:
 * - The HTML-in-canvas proposal (a canvas with `layoutsubtree`, drawing its live children with
 *   `drawElementImage`) would hand the lens the real DOM, but it is still behind a flag.
 * - An SVG filter (feDisplacementMap) as a backdrop-filter bends what lies behind an element,
 *   but only Chromium takes url() filters in backdrop-filter, and it cannot split colours per
 *   channel cheaply nor follow a parametric dome.
 * - The one used: the page is painted into a canvas from what it is made of (scene.ts: its boxes
 *   and words from their computed styles, then the stickers as the very SVG images the page
 *   shows), uploaded as a WebGL texture, and a fragment shader draws the lens (lens-renderer.ts).
 *   This works in every current browser; without WebGL a 2D canvas draws a plain magnifier.
 *   The bitmap is repainted only when something under the glass changes (a sticker moves, the
 *   scheme or the size changes, fonts arrive); moving the lens only redraws the lens.
 *
 * The pile: every sticker is a real button with an <img> of its SVG. Pointer or touch picks it
 * up (it rises to the top, lifts 5% with a deeper shadow and tilts with the drag's speed);
 * letting go throws it with the release speed, slowed by friction and bounced off the stage's
 * edges. Arrow keys move a focused sticker or the lens. "Reset stickers", under the pile, sends
 * everything home. The lens glides after the pointer that drags it (or follows the pointer with
 * `follow`), and always lies on top.
 *
 * Positions are kept in the design's px around the pile's middle and scaled to the stage, so the
 * pile keeps its arrangement at any size; below 640 px the stickers lie in a taller pile.
 *
 * While on screen and untouched, a demo pointer drags the lens across the pile and the title,
 * slides a sticker under it, opens the panel and scrubs the oblateness, then tidies up.
 */

export type { LensParams } from './lens-renderer';

export const DEFAULT_LENS: LensParams = {
  radius: 180,
  magnification: 1.75,
  ior: 1.52,
  aberration: 0.01,
  thickness: 0.9,
  bevelStart: 0.9,
  oblateness: 0.65,
  refraction: 0.6,
  frost: 0,
};

const PRESETS: Version[] = [
  { name: 'Version 1', params: DEFAULT_LENS },
  {
    name: 'Version 2',
    params: {
      ...DEFAULT_LENS,
      magnification: 1.3,
      oblateness: 0.3,
      aberration: 0.02,
      thickness: 1.2,
    },
  },
  {
    name: 'Version 3',
    params: { ...DEFAULT_LENS, radius: 140, magnification: 2.4, frost: 0.35, oblateness: 0.9 },
  },
];

type Point = { x: number; y: number };

type Item = {
  id: string;
  label: string;
  w: number;
  h: number;
  /** Middle, in design px from the pile's middle; turn in degrees. */
  x: number;
  y: number;
  r: number;
  /** Speed while thrown, design px per second. */
  vx: number;
  vy: number;
  /** Lifted while held (0 to 1), and the tilt the drag gives it. */
  lift: number;
  tilt: number;
  z: number;
  held: boolean;
  homing: boolean;
};

type Layout = { w: number; h: number; cx: number; cy: number; scale: number; narrow: boolean };

/** Px under which the stickers lie in the taller pile. */
const NARROW = 640;
const MIN_TOP = 52;

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

/** Reads the colour a custom property resolves to in this scheme. */
function resolve(probe: HTMLElement, token: string) {
  probe.style.color = `var(${token})`;
  return getComputedStyle(probe).color;
}

function isDark(color: string) {
  const [r = 255, g = 255, b = 255] = color.match(/[\d.]+/g)?.map(Number) ?? [];
  return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
}

class Stopped extends Error {}

export function StickerLens({
  title = 'Coming soon',
  eyebrow = 'Shop',
  lead = 'Meanwhile, click, drag and explore',
  brand = 'Studio Kite',
  lens: lensProp,
  defaultLensOpen = true,
  defaultPanelOpen = false,
  follow = false,
  onLensChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The big line in the middle of the page. */
  title?: string;
  /** The small capitals above it. */
  eyebrow?: string;
  /** The line under it. */
  lead?: string;
  /** The name at the top left. */
  brand?: string;
  /** The lens's starting tuning; anything left out keeps its default. */
  lens?: Partial<LensParams>;
  /** The lens starts on the page (the panel's Open switch). */
  defaultLensOpen?: boolean;
  /** The settings panel starts open. */
  defaultPanelOpen?: boolean;
  /** The lens follows the pointer around the page, instead of waiting to be dragged. */
  follow?: boolean;
  /** Called with the tuning whenever the panel changes it. */
  onLensChange?: (params: LensParams) => void;
  /** While on screen and untouched, a demo pointer plays with the lens, a sticker and the panel. */
  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 { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const panelId = useId();
  const hintId = useId();
  const [scheme, setScheme] = useState(colorScheme);
  const [params, setParams] = useState<LensParams>(() => ({ ...DEFAULT_LENS, ...lensProp }));
  const [lensOpen, setLensOpen] = useState(defaultLensOpen);
  const [panelOpen, setPanelOpen] = useState(defaultPanelOpen);
  const [versions, setVersions] = useState<Version[]>(() =>
    lensProp
      ? [{ name: 'Version 1', params: { ...DEFAULT_LENS, ...lensProp } }, ...PRESETS.slice(1)]
      : PRESETS,
  );
  const [version, setVersion] = useState(0);
  const [scrubbing, setScrubbing] = useState<keyof LensParams | null>(null);
  const [inks, setInks] = useState<Inks | null>(null);
  const [dark, setDark] = useState(false);
  const [message, setMessage] = useState('');
  const [activeNav, setActiveNav] = useState('Shop');

  const hero = useRef<HTMLDivElement>(null);
  const nav = useRef<HTMLElement>(null);
  const probe = useRef<HTMLSpanElement>(null);
  const lensBox = useRef<HTMLButtonElement>(null);
  const lensCanvas = useRef<HTMLCanvasElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const settingsButton = useRef<HTMLButtonElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const nodes = useRef(new Map<string, HTMLButtonElement>());
  const images = useRef(new Map<string, HTMLImageElement>());
  const renderer = useRef<LensRenderer | null>(null);
  const scene = useRef<HTMLCanvasElement | null>(null);
  const resetButton = useRef<HTMLButtonElement>(null);
  /** The demo opened the panel (and so closes it, and leaves the focus alone). */
  const demoPanel = useRef(false);

  const layout = useRef<Layout>({ w: 0, h: 0, cx: 0, cy: 0, scale: 1, narrow: false });
  const items = useRef<Item[]>(
    STICKERS.map((art, index) => ({
      id: art.id,
      label: art.label,
      w: art.w,
      h: art.h,
      x: art.wide.x,
      y: art.wide.y,
      r: art.wide.r,
      vx: 0,
      vy: 0,
      lift: 0,
      tilt: 0,
      z: index + 1,
      held: false,
      homing: false,
    })),
  );
  const lens = useRef({
    x: LENS_HOME.wide.x,
    y: LENS_HOME.wide.y,
    tx: LENS_HOME.wide.x,
    ty: LENS_HOME.wide.y,
  });
  const lensHeld = useRef<{ dx: number; dy: number } | null>(null);
  const drag = useRef<{ item: Item; dx: number; dy: number; last: Point; t: number } | null>(null);
  const topZ = useRef(STICKERS.length + 1);
  const dirty = useRef(true);
  const frame = useRef(0);
  const last = useRef(0);
  const reduced = useRef(false);
  const live = useRef({ params, lensOpen, dark, follow });
  live.current = { params, lensOpen, dark, follow };

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  // The colorScheme prop wins whenever it changes; the header's buttons change it in between.
  useEffect(() => setScheme(colorScheme), [colorScheme]);

  const srcs = useMemo(() => {
    if (!inks) return null;
    return Object.fromEntries(STICKERS.map((art) => [art.id, toDataUrl(art.draw(inks))]));
  }, [inks]);

  /* ---------- Drawing ---------- */

  const toStage = useCallback((x: number, y: number): Point => {
    const l = layout.current;
    return { x: l.cx + x * l.scale, y: l.cy + y * l.scale };
  }, []);
  const toDesign = useCallback((p: Point): Point => {
    const l = layout.current;
    return { x: (p.x - l.cx) / l.scale, y: (p.y - l.cy) / l.scale };
  }, []);

  const place = useCallback(() => {
    const l = layout.current;
    for (const item of items.current) {
      const node = nodes.current.get(item.id);
      if (!node) continue;
      const { x, y } = toStage(item.x, item.y);
      const w = item.w * l.scale;
      const h = item.h * l.scale;
      const grow = 1 + item.lift * 0.05;
      node.style.width = `${w}px`;
      node.style.height = `${h}px`;
      node.style.zIndex = String(item.z);
      node.style.transform = `translate(${(x - w / 2).toFixed(2)}px, ${(y - h / 2).toFixed(2)}px) rotate(${(item.r + item.tilt).toFixed(2)}deg) scale(${grow.toFixed(4)})`;
    }
    if (resetButton.current)
      resetButton.current.style.transform = `translate(${l.cx.toFixed(1)}px, ${(l.cy + 30 * l.scale).toFixed(1)}px) translate(-50%, -50%)`;
    const box = lensBox.current;
    const canvas = lensCanvas.current;
    if (box && canvas) {
      const radius = live.current.params.radius * l.scale;
      const { x, y } = toStage(lens.current.x, lens.current.y);
      const size = Math.max(2, radius * 2);
      box.style.width = `${size}px`;
      box.style.height = `${size}px`;
      box.style.transform = `translate(${(x - radius).toFixed(2)}px, ${(y - radius).toFixed(2)}px)`;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const px = Math.round(size * dpr);
      if (canvas.width !== px) {
        canvas.width = px;
        canvas.height = px;
      }
    }
  }, [toStage]);

  const render = useCallback(() => {
    const element = root.current;
    if (!element || !layout.current.w) return;
    const l = layout.current;
    if (dirty.current) {
      dirty.current = false;
      scene.current ??= document.createElement('canvas');
      const quality = Math.min(window.devicePixelRatio || 1, 2, 2400 / Math.max(1, l.w));
      const pieces: ScenePiece[] = [...items.current]
        .sort((a, b) => a.z - b.z)
        .flatMap((item) => {
          const image = images.current.get(item.id);
          if (!image) return [];
          const { x, y } = toStage(item.x, item.y);
          return [
            {
              image,
              x,
              y,
              w: item.w * l.scale,
              h: item.h * l.scale,
              r: item.r + item.tilt,
              lift: item.lift,
            },
          ];
        });
      paintScene(
        scene.current,
        element,
        quality,
        pieces,
        live.current.dark ? 'rgb(0 0 0 / 0.6)' : 'rgb(0 0 0 / 0.14)',
      );
      renderer.current?.upload(scene.current);
    }
    if (!live.current.lensOpen) return;
    const { x, y } = toStage(lens.current.x, lens.current.y);
    renderer.current?.draw({
      x,
      y,
      radius: live.current.params.radius * l.scale,
      stageW: l.w,
      stageH: l.h,
      params: live.current.params,
      dark: live.current.dark,
    });
  }, [root, toStage]);

  /** One step of the pile's physics; true while anything is still moving. */
  const step = useCallback((dt: number) => {
    const l = layout.current;
    let moving = false;
    const minX = -l.cx / l.scale;
    const maxX = (l.w - l.cx) / l.scale;
    const minY = (MIN_TOP - l.cy) / l.scale;
    const maxY = (l.h - 8 - l.cy) / l.scale;
    const approach = (rate: number) => 1 - Math.exp(-dt * rate);
    for (const item of items.current) {
      const before = item.x + item.y + item.r + item.lift + item.tilt;
      if (item.held) {
        item.lift += (1 - item.lift) * approach(18);
        const target = clamp(item.vx * 0.012, -10, 10);
        item.tilt += (target - item.tilt) * approach(10);
        item.vx *= Math.exp(-dt * 8);
        item.vy *= Math.exp(-dt * 8);
      } else if (item.homing) {
        const art = STICKERS.find((sticker) => sticker.id === item.id)!;
        const home = l.narrow ? art.narrow : art.wide;
        const k = reduced.current ? 1 : approach(7);
        item.x += (home.x - item.x) * k;
        item.y += (home.y - item.y) * k;
        item.r += (home.r - item.r) * k;
        item.lift += (0 - item.lift) * k;
        item.tilt += (0 - item.tilt) * k;
        if (
          Math.abs(home.x - item.x) + Math.abs(home.y - item.y) + Math.abs(home.r - item.r) <
          0.2
        ) {
          Object.assign(item, { x: home.x, y: home.y, r: home.r, lift: 0, tilt: 0, homing: false });
        }
      } else {
        if (Math.hypot(item.vx, item.vy) > 4 && !reduced.current) {
          item.x += item.vx * dt;
          item.y += item.vy * dt;
          item.vx *= Math.exp(-dt * 4.5);
          item.vy *= Math.exp(-dt * 4.5);
          if (item.x < minX || item.x > maxX) {
            item.x = clamp(item.x, minX, maxX);
            item.vx *= -0.45;
          }
          if (item.y < minY || item.y > maxY) {
            item.y = clamp(item.y, minY, maxY);
            item.vy *= -0.45;
          }
        } else {
          item.vx = 0;
          item.vy = 0;
        }
        item.lift += (0 - item.lift) * approach(reduced.current ? 60 : 12);
        item.tilt += (0 - item.tilt) * approach(reduced.current ? 60 : 9);
        if (item.lift < 0.002) item.lift = 0;
        if (Math.abs(item.tilt) < 0.01) item.tilt = 0;
      }
      const after = item.x + item.y + item.r + item.lift + item.tilt;
      if (after !== before) {
        dirty.current = true;
        moving = true;
      }
      if (item.held) moving = true;
    }
    const glass = lens.current;
    const k = reduced.current ? 1 : approach(lensHeld.current ? 22 : 14);
    const dx = glass.tx - glass.x;
    const dy = glass.ty - glass.y;
    if (Math.abs(dx) + Math.abs(dy) > 0.05) {
      glass.x += dx * k;
      glass.y += dy * k;
      moving = true;
    } else {
      glass.x = glass.tx;
      glass.y = glass.ty;
    }
    return moving || drag.current !== null || lensHeld.current !== null;
  }, []);

  const tick = useCallback(
    (now: number) => {
      const dt = Math.min(0.05, (now - (last.current || now)) / 1000);
      last.current = now;
      const moving = step(dt);
      place();
      render();
      frame.current = moving ? requestAnimationFrame(tick) : 0;
      if (!moving) last.current = 0;
    },
    [place, render, step],
  );

  const wake = useCallback(() => {
    if (!frame.current) frame.current = requestAnimationFrame(tick);
  }, [tick]);
  const invalidate = useCallback(() => {
    dirty.current = true;
    wake();
  }, [wake]);

  /* ---------- Layout, colours, renderer ---------- */

  const measure = useCallback(() => {
    const element = root.current;
    if (!element) return;
    const w = element.clientWidth;
    const h = element.clientHeight;
    const narrow = w < NARROW;
    const pile = narrow ? PILE.narrow : PILE.wide;
    const heroBottom = (hero.current?.offsetTop ?? 0) + (hero.current?.offsetHeight ?? 0);
    const navTop = nav.current?.offsetTop ?? h;
    const room = Math.max(120, navTop - heroBottom - 8);
    const scale = Math.max(
      0.3,
      Math.min(1, (w - (narrow ? 16 : 24)) / pile.w, room / (pile.h * 0.86)),
    );
    let cy = narrow ? (heroBottom + navTop) / 2 + 6 : h * 0.546;
    cy = Math.max(cy, heroBottom + pile.h * scale * 0.4);
    cy = Math.min(cy, navTop - pile.h * scale * (narrow ? 0.5 : 0.44));
    const switched = layout.current.w > 0 && layout.current.narrow !== narrow;
    layout.current = { w, h, cx: w / 2, cy, scale, narrow };
    if (switched) {
      // The other pile: everything goes to its place there.
      for (const item of items.current) {
        const art = STICKERS.find((sticker) => sticker.id === item.id)!;
        const home = narrow ? art.narrow : art.wide;
        Object.assign(item, { x: home.x, y: home.y, r: home.r, vx: 0, vy: 0, homing: false });
      }
      const home = narrow ? LENS_HOME.narrow : LENS_HOME.wide;
      Object.assign(lens.current, { x: home.x, y: home.y, tx: home.x, ty: home.y });
    }
    place();
    invalidate();
  }, [invalidate, place, root]);

  useEffect(() => {
    const element = root.current;
    if (!element) return;
    // The first layout: at a narrow size the stickers start in the taller pile.
    layout.current.narrow = element.clientWidth < NARROW;
    if (layout.current.narrow) {
      for (const item of items.current) {
        const art = STICKERS.find((sticker) => sticker.id === item.id)!;
        Object.assign(item, { x: art.narrow.x, y: art.narrow.y, r: art.narrow.r });
      }
      Object.assign(lens.current, {
        ...LENS_HOME.narrow,
        tx: LENS_HOME.narrow.x,
        ty: LENS_HOME.narrow.y,
      });
    }
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => (reduced.current = motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    document.fonts?.ready.then(invalidate);
    document.fonts?.addEventListener?.('loadingdone', invalidate);
    return () => {
      observer.disconnect();
      motion.removeEventListener('change', readMotion);
      document.fonts?.removeEventListener?.('loadingdone', invalidate);
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    };
  }, [invalidate, measure, root]);

  // The lens's renderer lives as long as its canvas.
  useEffect(() => {
    const canvas = lensCanvas.current;
    if (!canvas) return;
    renderer.current = createLensRenderer(canvas);
    invalidate();
    return () => {
      renderer.current?.dispose();
      renderer.current = null;
    };
  }, [invalidate]);

  // The inks of the scheme in force: read again whenever the scheme may have changed.
  useEffect(() => {
    const element = root.current;
    const span = probe.current;
    if (!element || !span) return;
    let queued = 0;
    const read = () => {
      queued = 0;
      const next: Inks = {
        ink: resolve(span, '--sticker-ink'),
        paper: resolve(span, '--sticker-paper'),
        mid: resolve(span, '--sticker-mid'),
        soft: resolve(span, '--sticker-soft'),
      };
      setInks((now) =>
        now &&
        now.ink === next.ink &&
        now.paper === next.paper &&
        now.mid === next.mid &&
        now.soft === next.soft
          ? now
          : next,
      );
      setDark(isDark(getComputedStyle(element).backgroundColor));
      invalidate();
    };
    const queue = () => {
      if (!queued) queued = requestAnimationFrame(read);
    };
    read();
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    media.addEventListener('change', queue);
    const observer = new MutationObserver(queue);
    observer.observe(document.documentElement, { attributes: true });
    observer.observe(document.body, { attributes: true });
    return () => {
      cancelAnimationFrame(queued);
      media.removeEventListener('change', queue);
      observer.disconnect();
    };
  }, [scheme, root, invalidate]);

  // New drawings for new inks: the scene waits for them to decode.
  useEffect(() => {
    if (!srcs) return;
    let alive = true;
    for (const art of STICKERS) {
      const image = new Image();
      image.decoding = 'async';
      image.src = srcs[art.id];
      image.decode().then(
        () => {
          if (!alive) return;
          images.current.set(art.id, image);
          invalidate();
        },
        () => {},
      );
    }
    return () => {
      alive = false;
    };
  }, [srcs, invalidate]);

  // The lens redraws for its tuning and its switch; the panel's layout changes nothing below.
  useEffect(() => {
    place();
    wake();
    render();
  }, [params, lensOpen, dark, place, render, wake]);

  const onLensChangeRef = useRef(onLensChange);
  onLensChangeRef.current = onLensChange;
  const changeParam = useCallback((key: keyof LensParams, value: number) => {
    setParams((now) => {
      const next = { ...now, [key]: value };
      onLensChangeRef.current?.(next);
      return next;
    });
  }, []);

  /* ---------- Input ---------- */

  const stagePoint = (event: { clientX: number; clientY: number }): Point => {
    const rect = root.current!.getBoundingClientRect();
    return { x: event.clientX - rect.left, y: event.clientY - rect.top };
  };

  const raise = (item: Item) => {
    topZ.current += 1;
    item.z = topZ.current;
  };

  const grabSticker = useCallback(
    (id: string, at: Point) => {
      const item = items.current.find((entry) => entry.id === id);
      if (!item) return;
      const design = toDesign(at);
      raise(item);
      Object.assign(item, { held: true, homing: false, vx: 0, vy: 0 });
      drag.current = {
        item,
        dx: design.x - item.x,
        dy: design.y - item.y,
        last: design,
        t: performance.now(),
      };
      dirty.current = true;
      wake();
    },
    [toDesign, wake],
  );
  const moveSticker = useCallback(
    (at: Point) => {
      const held = drag.current;
      if (!held) return;
      const l = layout.current;
      const design = toDesign({ x: clamp(at.x, 0, l.w), y: clamp(at.y, MIN_TOP, l.h - 4) });
      const now = performance.now();
      const dt = Math.max(1, now - held.t) / 1000;
      const vx = (design.x - held.last.x) / dt;
      const vy = (design.y - held.last.y) / dt;
      // A little smoothing, so one jumpy event doesn't fling the sticker.
      held.item.vx = held.item.vx * 0.6 + vx * 0.4;
      held.item.vy = held.item.vy * 0.6 + vy * 0.4;
      held.item.x = design.x - held.dx;
      held.item.y = design.y - held.dy;
      held.last = design;
      held.t = now;
      dirty.current = true;
      wake();
    },
    [toDesign, wake],
  );
  const dropSticker = useCallback(() => {
    const held = drag.current;
    if (!held) return;
    held.item.held = false;
    // Released after a pause: it drops where it is.
    if (performance.now() - held.t > 80) {
      held.item.vx = 0;
      held.item.vy = 0;
    }
    const speed = Math.hypot(held.item.vx, held.item.vy);
    if (speed > 2400) {
      held.item.vx *= 2400 / speed;
      held.item.vy *= 2400 / speed;
    }
    drag.current = null;
    wake();
  }, [wake]);

  const grabLens = useCallback(
    (at: Point) => {
      const design = toDesign(at);
      lensHeld.current = { dx: design.x - lens.current.tx, dy: design.y - lens.current.ty };
      wake();
    },
    [toDesign, wake],
  );
  const moveLens = useCallback(
    (at: Point, offset = true) => {
      const l = layout.current;
      const design = toDesign({ x: clamp(at.x, 0, l.w), y: clamp(at.y, MIN_TOP, l.h) });
      const held = offset ? lensHeld.current : null;
      lens.current.tx = design.x - (held?.dx ?? 0);
      lens.current.ty = design.y - (held?.dy ?? 0);
      wake();
    },
    [toDesign, wake],
  );
  const dropLens = useCallback(() => {
    lensHeld.current = null;
    wake();
  }, [wake]);

  const resetPile = useCallback(() => {
    for (const item of items.current)
      Object.assign(item, { homing: true, held: false, vx: 0, vy: 0 });
    const home = layout.current.narrow ? LENS_HOME.narrow : LENS_HOME.wide;
    lens.current.tx = home.x;
    lens.current.ty = home.y;
    wake();
  }, [wake]);

  const onStickerDown = (id: string) => (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    event.currentTarget.focus({ preventScroll: true });
    grabSticker(id, stagePoint(event));
  };
  const onStickerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (drag.current) moveSticker(stagePoint(event));
  };

  const nudge = (event: ReactKeyboardEvent, apply: (dx: number, dy: number) => void) => {
    const by = event.shiftKey ? 32 : 8;
    const moves: Record<string, Point> = {
      ArrowLeft: { x: -by, y: 0 },
      ArrowRight: { x: by, y: 0 },
      ArrowUp: { x: 0, y: -by },
      ArrowDown: { x: 0, y: by },
    };
    const move = moves[event.key];
    if (!move) return;
    event.preventDefault();
    apply(move.x, move.y);
    wake();
  };
  const onStickerKey = (id: string) => (event: ReactKeyboardEvent) =>
    nudge(event, (dx, dy) => {
      const item = items.current.find((entry) => entry.id === id)!;
      const l = layout.current;
      raise(item);
      item.homing = false;
      item.x = clamp(item.x + dx, -l.cx / l.scale, (l.w - l.cx) / l.scale);
      item.y = clamp(item.y + dy, (MIN_TOP - l.cy) / l.scale, (l.h - 8 - l.cy) / l.scale);
      dirty.current = true;
    });
  const onLensKey = (event: ReactKeyboardEvent) =>
    nudge(event, (dx, dy) => {
      const l = layout.current;
      lens.current.tx = clamp(lens.current.tx + dx, -l.cx / l.scale, (l.w - l.cx) / l.scale);
      lens.current.ty = clamp(
        lens.current.ty + dy,
        (MIN_TOP - l.cy) / l.scale,
        (l.h - l.cy) / l.scale,
      );
    });

  const onRootPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!live.current.follow || event.pointerType === 'touch') return;
    if (drag.current || lensHeld.current) return;
    moveLens(stagePoint(event), false);
  };

  /* ---------- Panel ---------- */

  const closePanel = useCallback(() => {
    setPanelOpen(false);
    setScrubbing(null);
    settingsButton.current?.focus({ preventScroll: true });
  }, []);

  useEffect(() => {
    if (panelOpen && !demoPanel.current)
      panel.current?.querySelector<HTMLElement>('button')?.focus({ preventScroll: true });
  }, [panelOpen]);

  const copy = async () => {
    const text = JSON.stringify(params, null, 2);
    try {
      await navigator.clipboard.writeText(text);
      announce('Settings copied');
    } catch {
      announce('Could not copy the settings');
    }
  };

  /* ---------- Demo ---------- */

  const demo = autoplay && playing;

  useEffect(() => {
    const element = root.current;
    const hand = pointer.current;
    if (!demo || !element || !hand) return;
    let alive = true;
    let raf = 0;
    let timer = 0;
    let rejecter: ((error: Stopped) => void) | null = null;
    const l = () => layout.current;
    let at: Point = { x: l().w * 0.86, y: l().h * 0.94 };
    const show = (p: Point) => {
      hand.style.transform = `translate(${(p.x - 3).toFixed(1)}px, ${(p.y - 2).toFixed(1)}px)`;
    };
    const guard = <T,>(run: (resolve: (value: T) => void) => void) =>
      new Promise<T>((resolve, reject) => {
        if (!alive) return reject(new Stopped());
        rejecter = reject;
        run(resolve);
      });
    const wait = (ms: number) => guard<void>((resolve) => (timer = window.setTimeout(resolve, ms)));
    const glide = (to: Point, ms: number, along?: (p: Point) => void) =>
      guard<void>((resolve) => {
        const from = at;
        const bend = {
          x: (from.x + to.x) / 2 - (to.y - from.y) * 0.12,
          y: (from.y + to.y) / 2 + (to.x - from.x) * 0.12,
        };
        const start = performance.now();
        const run = (now: number) => {
          const t = ease(Math.min(1, (now - start) / ms));
          const u = 1 - t;
          at = {
            x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
            y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
          };
          show(at);
          along?.(at);
          if (t < 1) raf = requestAnimationFrame(run);
          else resolve();
        };
        raf = requestAnimationFrame(run);
      });
    const press = (down: boolean) => (hand.dataset.pressed = String(down));
    const centreOf = (target: Element | null | undefined): Point => {
      const rect = target?.getBoundingClientRect();
      const origin = element.getBoundingClientRect();
      if (!rect) return at;
      return {
        x: rect.left - origin.left + rect.width / 2,
        y: rect.top - origin.top + rect.height / 2,
      };
    };
    const stickerAt = (id: string) => {
      const item = items.current.find((entry) => entry.id === id)!;
      return toStage(item.x, item.y);
    };
    const lensAt = () => toStage(lens.current.x, lens.current.y);
    const rowX = (key: keyof LensParams, value: number) => {
      const row = ROWS.find((entry) => entry.key === key)!;
      const input = panel.current?.querySelector(`[data-row='${key}'] input`);
      const rect = input?.getBoundingClientRect();
      const origin = element.getBoundingClientRect();
      if (!rect) return at;
      return {
        x: rect.left - origin.left + ((value - row.min) / (row.max - row.min)) * rect.width,
        y: rect.top - origin.top + rect.height / 2,
      };
    };
    const startParams = live.current.params;

    const script = async () => {
      show(at);
      hand.style.opacity = '1';
      for (;;) {
        await wait(500);
        // Pick up the lens and roll it over the pile and the words.
        if (live.current.lensOpen) {
          await glide(lensAt(), 900);
          press(true);
          grabLens(at);
          await wait(160);
          const key = stickerAt('keycap');
          await glide({ x: key.x + 20, y: key.y - 10 }, 1300, (p) => moveLens(p));
          const words = centreOf(hero.current?.lastElementChild);
          await glide({ x: words.x - 60, y: words.y + 30 }, 1300, (p) => moveLens(p));
          const middle = toStage(0, -30);
          await glide(middle, 1300, (p) => moveLens(p));
          press(false);
          dropLens();
          await wait(350);
          // Slide a sticker under the glass.
          await glide(stickerAt('label'), 600);
          press(true);
          grabSticker('label', at);
          await wait(160);
          const glass = lensAt();
          await glide({ x: glass.x - 30, y: glass.y + 10 }, 1200, (p) => moveSticker(p));
          press(false);
          dropSticker();
          await wait(700);
        }
        // Open the panel and scrub the oblateness.
        await glide(centreOf(settingsButton.current), 900);
        press(true);
        await wait(120);
        press(false);
        demoPanel.current = true;
        setPanelOpen(true);
        await wait(550);
        const from = live.current.params.oblateness;
        await glide(rowX('oblateness', from), 700);
        press(true);
        setScrubbing('oblateness');
        const scrub = (p: Point) => {
          const input = panel.current?.querySelector(`[data-row='oblateness'] input`);
          const rect = input?.getBoundingClientRect();
          const origin = element.getBoundingClientRect();
          if (!rect) return;
          const share = clamp((p.x - (rect.left - origin.left)) / rect.width, 0, 1);
          changeParam('oblateness', Math.round((0.2 + share * 0.8) * 100) / 100);
        };
        await glide(rowX('oblateness', 0.26), 900, scrub);
        await glide(rowX('oblateness', 0.88), 1100, scrub);
        await wait(300);
        await glide(rowX('oblateness', from), 800, scrub);
        changeParam('oblateness', from);
        setScrubbing(null);
        press(false);
        await wait(400);
        await glide(centreOf(panel.current?.querySelector('[data-demo="close"]')), 700);
        press(true);
        await wait(120);
        press(false);
        setPanelOpen(false);
        demoPanel.current = false;
        await wait(500);
        resetPile();
        await glide({ x: l().w * 0.86, y: l().h * 0.94 }, 900);
        await wait(1600);
      }
    };
    script().catch((error: unknown) => {
      if (!(error instanceof Stopped)) throw error;
    });
    return () => {
      alive = false;
      cancelAnimationFrame(raf);
      window.clearTimeout(timer);
      rejecter?.(new Stopped());
      hand.style.opacity = '0';
      press(false);
      if (drag.current) dropSticker();
      if (lensHeld.current) dropLens();
      setScrubbing(null);
      if (demoPanel.current) {
        demoPanel.current = false;
        setPanelOpen(false);
      }
      if (live.current.params.oblateness !== startParams.oblateness)
        changeParam('oblateness', startParams.oblateness);
    };
  }, [
    demo,
    root,
    toStage,
    grabLens,
    moveLens,
    dropLens,
    grabSticker,
    moveSticker,
    dropSticker,
    changeParam,
    resetPile,
  ]);

  /** Any real input hands the page to the reader. */
  const onTake = () => take();

  const schemeShown =
    scheme === 'light' || scheme === 'dark'
      ? scheme
      : scheme === 'system'
        ? 'system'
        : dark
          ? 'dark'
          : 'light';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={scheme}
      data-part="root"
      onPointerDownCapture={onTake}
      onKeyDownCapture={onTake}
      onWheelCapture={onTake}
      onPointerMove={onRootPointerMove}
    >
      <span ref={probe} className={styles.probe} aria-hidden="true" />
      <header className={styles.header} data-part="header">
        <span className={styles.brand}>
          <svg viewBox="0 0 20 20" className={styles.brandMark} aria-hidden="true">
            <path
              d="M7.2 9.6V3.4a1.2 1.2 0 0 1 2.4 0v5.4M9.6 8.4V2.6a1.2 1.2 0 0 1 2.4 0v6.6M12 9.2l1.4-3.4a1.15 1.15 0 0 1 2.1.9L13.8 12c-.6 2.9-2.5 5-5.2 5-2.6 0-4.4-1.9-4.4-4.6V10.2a1.2 1.2 0 0 1 2.4 0v1.6M7.2 9.6c.9-.6 2.5-.5 3.2.5"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.3"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
          <span className={styles.brandName} data-lens-text>
            {brand}
          </span>
        </span>
        <div className={styles.controls}>
          <div
            className={styles.themes}
            role="group"
            aria-label="Colour scheme"
            data-part="schemes"
          >
            {(
              [
                ['dark', 'Dark', 'M13.5 10.6A5.6 5.6 0 0 1 7.4 2.5a5.6 5.6 0 1 0 6.1 8.1z'],
                [
                  'light',
                  'Light',
                  'M8 5.3a2.7 2.7 0 1 0 0 5.4 2.7 2.7 0 0 0 0-5.4zM8 1.5v1.3M8 13.2v1.3M1.5 8h1.3M13.2 8h1.3M3.4 3.4l.9.9M11.7 11.7l.9.9M3.4 12.6l.9-.9M11.7 4.3l.9-.9',
                ],
                ['system', 'System', 'M2.5 3.5h11v7.5h-11zM8 11v2.5M5.5 13.5h5'],
              ] as const
            ).map(([value, label, d]) => (
              <button
                key={value}
                type="button"
                className={styles.theme}
                aria-label={label}
                aria-pressed={schemeShown === value}
                onClick={() => setScheme(value)}
              >
                <svg viewBox="0 0 16 16" className={styles.icon} aria-hidden="true">
                  <path
                    d={d}
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              </button>
            ))}
          </div>
          <button
            ref={settingsButton}
            type="button"
            className={styles.settings}
            aria-label="Lens settings"
            aria-expanded={panelOpen}
            aria-controls={panelOpen ? panelId : undefined}
            onClick={() => (panelOpen ? closePanel() : setPanelOpen(true))}
            data-part="settings"
          >
            <SlidersIcon className={styles.icon} />
          </button>
        </div>
      </header>

      <div ref={hero} className={styles.hero} data-part="hero">
        <p className={styles.eyebrow} data-lens-text>
          {eyebrow}
        </p>
        <h2 className={styles.title} data-lens-text>
          {title}
        </h2>
        <p className={styles.lead} data-lens-text>
          {lead} {'↓'}
        </p>
      </div>

      <div className={styles.pile} data-part="pile">
        <button
          ref={resetButton}
          type="button"
          className={styles.reset}
          data-lens-box
          data-part="reset"
          onClick={() => {
            resetPile();
            announce('Stickers back in their pile');
          }}
        >
          <span data-lens-text>Reset stickers</span>
        </button>
        {STICKERS.map((art) => (
          <button
            key={art.id}
            ref={(node) => {
              if (node) nodes.current.set(art.id, node);
              else nodes.current.delete(art.id);
            }}
            type="button"
            className={styles.sticker}
            aria-label={art.label}
            aria-describedby={hintId}
            onPointerDown={onStickerDown(art.id)}
            onPointerMove={onStickerMove}
            onPointerUp={dropSticker}
            onPointerCancel={dropSticker}
            onLostPointerCapture={dropSticker}
            onKeyDown={onStickerKey(art.id)}
            data-part="sticker"
          >
            {srcs ? (
              <img src={srcs[art.id]} alt="" draggable={false} className={styles.art} />
            ) : null}
          </button>
        ))}
        <button
          ref={lensBox}
          type="button"
          className={styles.lens}
          aria-label="Glass lens"
          aria-describedby={hintId}
          hidden={!lensOpen}
          onPointerDown={(event) => {
            if (event.button !== 0) return;
            event.preventDefault();
            event.currentTarget.setPointerCapture(event.pointerId);
            event.currentTarget.focus({ preventScroll: true });
            grabLens(stagePoint(event));
          }}
          onPointerMove={(event) => {
            if (lensHeld.current) moveLens(stagePoint(event));
          }}
          onPointerUp={dropLens}
          onPointerCancel={dropLens}
          onLostPointerCapture={dropLens}
          onKeyDown={onLensKey}
          data-part="lens"
        >
          <canvas ref={lensCanvas} className={styles.lensCanvas} aria-hidden="true" />
        </button>
      </div>

      <nav ref={nav} className={styles.nav} aria-label="Site" data-lens-box data-part="nav">
        {['Work', 'About', 'Shop', 'Contact'].map((item) => (
          <button
            key={item}
            type="button"
            className={styles.navItem}
            aria-current={activeNav === item ? 'page' : undefined}
            data-lens-box={activeNav === item || undefined}
            onClick={() => setActiveNav(item)}
          >
            <span data-lens-text>{item}</span>
          </button>
        ))}
      </nav>

      {panelOpen ? (
        <Panel
          id={panelId}
          ref={panel}
          params={params}
          onChange={(key, value) => {
            changeParam(key, value);
          }}
          lensOpen={lensOpen}
          onLensOpen={(open) => {
            setLensOpen(open);
            announce(open ? 'Lens on' : 'Lens off');
          }}
          versions={versions}
          version={version}
          onVersion={(index) => {
            setVersion(index);
            setParams(versions[index].params);
            onLensChange?.(versions[index].params);
            announce(`${versions[index].name} loaded`);
          }}
          onAddVersion={() => {
            const name = `Version ${versions.length + 1}`;
            setVersions((now) => [...now, { name, params }]);
            setVersion(versions.length);
            announce(`${name} saved`);
          }}
          onCopy={copy}
          onClose={closePanel}
          dragging={scrubbing}
          onDragging={setScrubbing}
        />
      ) : null}

      <span ref={pointer} className={styles.pointer} aria-hidden="true">
        <svg viewBox="0 0 20 20" className={styles.pointerGlyph}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <p id={hintId} className={styles.hidden}>
        Drag it, or move it with the arrow keys.
      </p>
      <p className={styles.hidden} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### sticker-lens/lens-renderer.ts

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

/*
 * The lens itself: a small canvas the size of the lens that draws, for every pixel of the disc,
 * the part of the scene its glass would bend towards it.
 *
 * The scene is a bitmap (scene.ts paints the page's words and the stickers into a canvas), here
 * a WebGL texture. Each pixel of the disc reads it at
 *
 *   centre + offset / magnification + outward bend
 *
 * where the bend comes from the slope of the glass at that point. The glass is a superellipse
 * dome: the higher the oblateness, the flatter its middle and the steeper its rim, so the middle
 * reads as a clean magnifier and the edge squeezes in what lies beyond the disc. The bend grows
 * with the thickness, the index of refraction (1 - 1/ior, as Snell's law gives for small angles)
 * and the refraction displacement, and only reaches full strength past the bevel start. Red,
 * green and blue are read at three slightly different bends (the aberration), so edges fringe
 * with colour where the glass is steep. Frost averages eight reads around each one. A rim of
 * light, a soft specular glint and a darker fresnel edge make it read as glass in either scheme.
 *
 * Without WebGL, a 2D canvas fallback draws the magnified scene clipped to the disc with the
 * same rim, without the refraction.
 */

export type LensParams = {
  radius: number;
  magnification: number;
  ior: number;
  aberration: number;
  thickness: number;
  bevelStart: number;
  oblateness: number;
  refraction: number;
  frost: number;
};

export type LensFrame = {
  /** The lens's middle, in px of the stage. */
  x: number;
  y: number;
  /** Its radius on screen, in px. */
  radius: number;
  /** The stage's size in px, which the scene bitmap covers. */
  stageW: number;
  stageH: number;
  params: LensParams;
  dark: boolean;
};

export type LensRenderer = {
  /** Takes a new picture of the scene. */
  upload: (scene: HTMLCanvasElement) => void;
  draw: (frame: LensFrame) => void;
  dispose: () => void;
  kind: 'webgl' | '2d';
};

const VERTEX = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
  vUv = aPos * 0.5 + 0.5;
  vUv.y = 1.0 - vUv.y;
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

const FRAGMENT = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uScene;
uniform vec2 uStage;
uniform vec2 uCenter;
uniform float uRadius;
uniform float uMag;
uniform float uIor;
uniform float uAb;
uniform float uThick;
uniform float uBevel;
uniform float uObl;
uniform float uRefr;
uniform float uFrost;
uniform float uDark;
uniform float uAa;

vec4 tap(vec2 px) {
  return texture2D(uScene, clamp(px / uStage, vec2(0.0), vec2(1.0)));
}

float channel(vec2 px, int c) {
  vec4 s = tap(px);
  if (uFrost > 0.001) {
    float b = uFrost * 12.0;
    s += tap(px + vec2(b, 0.0)) + tap(px - vec2(b, 0.0)) + tap(px + vec2(0.0, b)) + tap(px - vec2(0.0, b));
    s += tap(px + vec2(b, b) * 0.7) + tap(px - vec2(b, b) * 0.7) + tap(px + vec2(b, -b) * 0.7) + tap(px + vec2(-b, b) * 0.7);
    s /= 9.0;
  }
  return c == 0 ? s.r : c == 1 ? s.g : s.b;
}

void main() {
  vec2 p = vUv * 2.0 - 1.0;
  float r = length(p);
  float alpha = 1.0 - smoothstep(1.0 - uAa, 1.0, r);
  if (alpha <= 0.0) { gl_FragColor = vec4(0.0); return; }
  float rc = min(r, 0.995);
  vec2 dir = r > 0.0001 ? p / r : vec2(0.0);

  // The dome: h = (1 - r^k)^(1/k). Its slope, steep at the rim, is how hard the glass bends.
  float k = mix(2.0, 9.0, uObl);
  float rk = pow(rc, k);
  float slope = pow(rc, k - 1.0) * pow(max(1.0 - rk, 0.0001), 1.0 / k - 1.0);
  slope = min(slope, 7.0);
  slope *= mix(0.35, 1.0, smoothstep(uBevel - 0.35, 1.0, rc));
  float bend = slope * uThick * (1.0 - 1.0 / uIor) * uRefr;

  vec2 base = p * uRadius / uMag;
  vec2 off = dir * bend * uRadius;
  // Colours split along the slope: a few px where the glass is steep, none in the flat middle.
  vec2 split = dir * uAb * min(slope, 2.5) * uRadius * 1.1;
  float red = channel(uCenter + base + off - split, 0);
  float green = channel(uCenter + base + off, 1);
  float blue = channel(uCenter + base + off + split, 2);
  vec3 col = vec3(red, green, blue);

  // Fresnel: the very rim mirrors the room, light by day and dark at night.
  float fres = smoothstep(0.82, 1.0, rc);
  fres *= fres;
  col = mix(col, vec3(1.0 - uDark), fres * mix(0.38, 0.4, uDark));
  // A thin ring of light along the edge, brighter towards the top left.
  float ring = smoothstep(0.94, 0.985, r) * (1.0 - smoothstep(0.985, 1.0, r));
  col += ring * (0.12 + 0.25 * max(0.0, dot(dir, vec2(-0.6, -0.8))));
  // A soft glint, and a little shade low in the glass.
  vec2 g = p - vec2(-0.36, -0.5);
  col += exp(-dot(g, g) * 14.0) * 0.1;
  col *= 1.0 - 0.12 * smoothstep(0.2, 1.0, p.y) * fres;
  gl_FragColor = vec4(clamp(col, 0.0, 1.0) * alpha, alpha);
}`;

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    gl.deleteShader(shader);
    return null;
  }
  return shader;
}

function webgl(canvas: HTMLCanvasElement): LensRenderer | null {
  const gl = canvas.getContext('webgl', { premultipliedAlpha: true, antialias: false });
  if (!gl) return null;
  const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
  const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
  if (!vs || !fs) return null;
  const program = gl.createProgram();
  if (!program) return null;
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
  gl.useProgram(program);
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
  const position = gl.getAttribLocation(program, 'aPos');
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  const u = (name: string) => gl.getUniformLocation(program, name);
  const at = {
    stage: u('uStage'),
    center: u('uCenter'),
    radius: u('uRadius'),
    mag: u('uMag'),
    ior: u('uIor'),
    ab: u('uAb'),
    thick: u('uThick'),
    bevel: u('uBevel'),
    obl: u('uObl'),
    refr: u('uRefr'),
    frost: u('uFrost'),
    dark: u('uDark'),
    aa: u('uAa'),
  };
  let ready = false;
  return {
    kind: 'webgl',
    upload(scene) {
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, scene);
      ready = true;
    },
    draw(f) {
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      if (!ready) return;
      const p = f.params;
      gl.uniform2f(at.stage, f.stageW, f.stageH);
      gl.uniform2f(at.center, f.x, f.y);
      gl.uniform1f(at.radius, f.radius);
      gl.uniform1f(at.mag, p.magnification);
      gl.uniform1f(at.ior, p.ior);
      gl.uniform1f(at.ab, p.aberration);
      gl.uniform1f(at.thick, p.thickness);
      gl.uniform1f(at.bevel, p.bevelStart);
      gl.uniform1f(at.obl, p.oblateness);
      gl.uniform1f(at.refr, p.refraction);
      gl.uniform1f(at.frost, p.frost);
      gl.uniform1f(at.dark, f.dark ? 1 : 0);
      gl.uniform1f(at.aa, 2 / Math.max(1, canvas.width / 2));
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    },
    dispose() {
      gl.deleteTexture(texture);
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
    },
  };
}

/** No WebGL: the magnified scene in the disc, with the same rim of light, without refraction. */
function flat(canvas: HTMLCanvasElement): LensRenderer | null {
  const ctx = canvas.getContext('2d');
  if (!ctx) return null;
  let scene: HTMLCanvasElement | null = null;
  return {
    kind: '2d',
    upload(next) {
      scene = next;
    },
    draw(f) {
      const { width: w, height: h } = canvas;
      ctx.clearRect(0, 0, w, h);
      if (!scene) return;
      const sx = scene.width / f.stageW;
      const sy = scene.height / f.stageH;
      const half = f.radius / f.params.magnification;
      ctx.save();
      ctx.beginPath();
      ctx.arc(w / 2, h / 2, w / 2, 0, Math.PI * 2);
      ctx.clip();
      ctx.filter = f.params.frost > 0 ? `blur(${(f.params.frost * 6).toFixed(1)}px)` : 'none';
      ctx.drawImage(
        scene,
        (f.x - half) * sx,
        (f.y - half) * sy,
        half * 2 * sx,
        half * 2 * sy,
        0,
        0,
        w,
        h,
      );
      ctx.filter = 'none';
      const rim = ctx.createRadialGradient(w / 2, h / 2, w * 0.3, w / 2, h / 2, w / 2);
      const tone = f.dark ? '0 0 0' : '255 255 255';
      rim.addColorStop(0, `rgb(${tone} / 0)`);
      rim.addColorStop(1, `rgb(${tone} / 0.55)`);
      ctx.fillStyle = rim;
      ctx.fillRect(0, 0, w, h);
      ctx.restore();
      ctx.beginPath();
      ctx.arc(w / 2, h / 2, w / 2 - 1, 0, Math.PI * 2);
      ctx.strokeStyle = 'rgb(255 255 255 / 0.45)';
      ctx.lineWidth = 1.5;
      ctx.stroke();
    },
    dispose() {
      scene = null;
    },
  };
}

export function createLensRenderer(canvas: HTMLCanvasElement): LensRenderer | null {
  return webgl(canvas) ?? flat(canvas);
}
```

### sticker-lens/scene.ts

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

/*
 * The picture under the lens: the page painted into a canvas, which the lens then bends.
 *
 * Browsers cannot yet hand a canvas a picture of live HTML (the HTML-in-canvas proposal, a
 * canvas with `layoutsubtree` and `drawElementImage`, is still behind a flag), so the page is
 * painted again from what it is made of: the boxes marked data-lens-box (their background,
 * border and radius as computed), the words marked data-lens-text (in their computed font,
 * colour and spacing, on their own baseline), then the stickers, in their stacking order, as the
 * same SVG images the page shows. It is repainted only when something under the lens changes.
 */

export type ScenePiece = {
  image: HTMLImageElement;
  /** Middle, size (px of the stage) and turn (degrees). */
  x: number;
  y: number;
  w: number;
  h: number;
  r: number;
  /** How far it is lifted off the page while dragged, 0 to 1. */
  lift: number;
};

function radius(style: CSSStyleDeclaration, w: number, h: number) {
  const value = parseFloat(style.borderTopLeftRadius) || 0;
  return Math.min(value, w / 2, h / 2);
}

export function paintScene(
  canvas: HTMLCanvasElement,
  root: HTMLElement,
  quality: number,
  pieces: ScenePiece[],
  shadow: string,
) {
  const w = root.clientWidth;
  const h = root.clientHeight;
  const cw = Math.max(1, Math.round(w * quality));
  const ch = Math.max(1, Math.round(h * quality));
  if (canvas.width !== cw || canvas.height !== ch) {
    canvas.width = cw;
    canvas.height = ch;
  }
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const origin = root.getBoundingClientRect();
  const sx = cw / w;
  ctx.setTransform(sx, 0, 0, ch / h, 0, 0);
  ctx.fillStyle = getComputedStyle(root).backgroundColor;
  ctx.fillRect(0, 0, w, h);

  for (const box of root.querySelectorAll<HTMLElement>('[data-lens-box]')) {
    const rect = box.getBoundingClientRect();
    if (!rect.width) continue;
    const style = getComputedStyle(box);
    const x = rect.left - origin.left;
    const y = rect.top - origin.top;
    ctx.beginPath();
    ctx.roundRect(x, y, rect.width, rect.height, radius(style, rect.width, rect.height));
    ctx.fillStyle = style.backgroundColor;
    ctx.fill();
    const line = parseFloat(style.borderTopWidth);
    if (line > 0) {
      ctx.lineWidth = line;
      ctx.strokeStyle = style.borderTopColor;
      ctx.stroke();
    }
  }

  for (const element of root.querySelectorAll<HTMLElement>('[data-lens-text]')) {
    const rect = element.getBoundingClientRect();
    if (!rect.width) continue;
    const style = getComputedStyle(element);
    ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
    ctx.fillStyle = style.color;
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign = 'left';
    if ('letterSpacing' in ctx)
      ctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing;
    const raw = element.textContent ?? '';
    const text =
      style.textTransform === 'uppercase'
        ? raw.toUpperCase()
        : style.textTransform === 'lowercase'
          ? raw.toLowerCase()
          : raw;
    const metrics = ctx.measureText(text);
    // A single line: its content box is centred on the line box, so the baseline sits half the
    // ascent-minus-descent below the middle.
    const mid = rect.top - origin.top + rect.height / 2;
    const baseline = mid + (metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
    const left = rect.left - origin.left + (parseFloat(style.paddingLeft) || 0);
    ctx.fillText(text, left, baseline);
  }
  if ('letterSpacing' in ctx) ctx.letterSpacing = '0px';

  for (const piece of pieces) {
    if (!piece.image.complete || !piece.image.naturalWidth) continue;
    ctx.save();
    ctx.translate(piece.x, piece.y);
    ctx.rotate((piece.r * Math.PI) / 180);
    const grow = 1 + piece.lift * 0.05;
    ctx.scale(grow, grow);
    ctx.shadowColor = shadow;
    ctx.shadowBlur = (4 + piece.lift * 22) * sx;
    ctx.shadowOffsetY = (1 + piece.lift * 10) * sx;
    ctx.drawImage(piece.image, -piece.w / 2, -piece.h / 2, piece.w, piece.h);
    ctx.restore();
  }
}
```

### sticker-lens/stickers.ts

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

/*
 * The sticker pile: twelve original drawings, each an SVG document written as a string, so the
 * same picture can be the <img> the reader drags and the bitmap the lens refracts (the lens
 * draws the scene into a canvas, and an <img> of an SVG draws into a canvas at any size, crisp).
 *
 * Every drawing is in four inks only, handed in per colour scheme: `ink` (the black of the
 * print), `paper` (the white of the sticker), `mid` (a printed grey) and `soft` (a pale grey).
 * At night ink and paper swap, so a black label turns white and the other way round.
 *
 * Text inside an SVG image cannot reach the page's web fonts, so the drawings name system faces
 * (a heavy grotesque, a monospace, a script) with generic fallbacks.
 */

export type Inks = { ink: string; paper: string; mid: string; soft: string };

export type StickerArt = {
  id: string;
  /** What the sticker shows, for its accessible name. */
  label: string;
  /** Its size at the design's scale, in px. */
  w: number;
  h: number;
  /** Where it lies in the pile: px from the pile's middle, and degrees. */
  wide: { x: number; y: number; r: number };
  narrow: { x: number; y: number; r: number };
  draw: (c: Inks) => string;
};

const HEAVY = `'Arial Black','Helvetica Neue',Helvetica,Arial,sans-serif`;
const SANS = `'Helvetica Neue',Helvetica,Arial,sans-serif`;
const MONO = `'Courier New',Courier,ui-monospace,monospace`;
const SCRIPT = `'Snell Roundhand','Brush Script MT','Segoe Script',cursive`;
const HAND = `'Bradley Hand','Segoe Print','Comic Sans MS',cursive`;

const svg = (w: number, h: number, body: string) =>
  `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;

/** A postage-stamp edge: a rectangle with round bites along every side, as a mask. */
function perforation(id: string, w: number, h: number, bite: number, step: number) {
  let holes = '';
  const across = Math.round((w - bite * 2) / step);
  const down = Math.round((h - bite * 2) / step);
  for (let i = 0; i <= across; i++) {
    const x = bite + ((w - bite * 2) * i) / across;
    holes += `<circle cx="${x.toFixed(1)}" cy="0" r="${bite}"/><circle cx="${x.toFixed(1)}" cy="${h}" r="${bite}"/>`;
  }
  for (let i = 0; i <= down; i++) {
    const y = bite + ((h - bite * 2) * i) / down;
    holes += `<circle cx="0" cy="${y.toFixed(1)}" r="${bite}"/><circle cx="${w}" cy="${y.toFixed(1)}" r="${bite}"/>`;
  }
  return `<mask id="${id}"><rect width="${w}" height="${h}" fill="#fff"/><g fill="#000">${holes}</g></mask>`;
}

/** Rays from a point, for the poster's sunburst. */
function rays(cx: number, cy: number, inner: number, outer: number, count: number, color: string) {
  let lines = '';
  for (let i = 0; i < count; i++) {
    const a = (i / count) * Math.PI * 2;
    const long = i % 2 ? outer : outer * 0.82;
    lines += `M${(cx + Math.cos(a) * inner).toFixed(1)} ${(cy + Math.sin(a) * inner).toFixed(1)}L${(cx + Math.cos(a) * long).toFixed(1)} ${(cy + Math.sin(a) * long).toFixed(1)}`;
  }
  return `<path d="${lines}" stroke="${color}" stroke-width="0.9" fill="none"/>`;
}

/** A four-pointed sparkle. */
const sparkle = (x: number, y: number, s: number, fill: string, stroke: string) =>
  `<path d="M${x} ${y - s}C${x + s * 0.12} ${y - s * 0.12} ${x + s * 0.12} ${y - s * 0.12} ${x + s} ${y}C${x + s * 0.12} ${y + s * 0.12} ${x + s * 0.12} ${y + s * 0.12} ${x} ${y + s}C${x - s * 0.12} ${y + s * 0.12} ${x - s * 0.12} ${y + s * 0.12} ${x - s} ${y}C${x - s * 0.12} ${y - s * 0.12} ${x - s * 0.12} ${y - s * 0.12} ${x} ${y - s}Z" fill="${fill}" stroke="${stroke}" stroke-width="2.4" stroke-linejoin="round"/>`;

/** Pixels on a grid, from rows of '#' (ink) and '.' (paper); spaces stay empty. */
function pixels(rows: string[], size: number, x0: number, y0: number, c: Inks) {
  let ink = '';
  let paper = '';
  rows.forEach((row, j) =>
    [...row].forEach((cell, i) => {
      const rect = `M${x0 + i * size} ${y0 + j * size}h${size}v${size}h-${size}z`;
      if (cell === '#') ink += rect;
      else if (cell === '.') paper += rect;
    }),
  );
  return `<path d="${paper}" fill="${c.paper}"/><path d="${ink}" fill="${c.ink}"/>`;
}

export const STICKERS: StickerArt[] = [
  {
    id: 'stamp',
    label: 'A stamp: since 1931, Harbour Square',
    w: 197,
    h: 247,
    wide: { x: -477, y: 40, r: -6 },
    narrow: { x: -165, y: -123, r: -6 },
    draw: (c) =>
      svg(
        197,
        247,
        `<defs>${perforation('m', 197, 247, 6.5, 18)}<path id="arc" d="M44 112A55 55 0 0 1 153 112"/></defs>
        <rect width="197" height="247" fill="${c.ink}" mask="url(#m)"/>
        <rect x="14" y="14" width="169" height="219" fill="${c.paper}"/>
        <rect x="20" y="20" width="157" height="207" fill="none" stroke="${c.ink}" stroke-width="1.6"/>
        <text font-family="${HEAVY}" font-size="12.5" font-weight="900" letter-spacing="1.6" fill="${c.ink}"><textPath href="#arc" startOffset="50%" text-anchor="middle">THE OLD QUARTER</textPath></text>
        <path d="M98.5 88l3 6.6 7-2.2-2.3 7 6.7 3-6.7 3 2.3 7-7-2.2-3 6.6-3-6.6-7 2.2 2.3-7-6.7-3 6.7-3-2.3-7 7 2.2z" fill="${c.ink}"/>
        <text x="98.5" y="140" text-anchor="middle" font-family="${SANS}" font-size="10" font-weight="700" letter-spacing="2" fill="${c.ink}">· SINCE ·</text>
        <text x="98.5" y="178" text-anchor="middle" font-family="${HEAVY}" font-size="46" font-weight="900" letter-spacing="1" fill="${c.ink}" transform="skewX(-6) translate(18 0)">1931</text>
        <text x="98.5" y="203" text-anchor="middle" font-family="${HEAVY}" font-size="13" font-weight="900" letter-spacing="0.6" fill="${c.ink}">HARBOUR</text>
        <text x="98.5" y="218" text-anchor="middle" font-family="${HEAVY}" font-size="13" font-weight="900" letter-spacing="0.6" fill="${c.ink}">SQUARE</text>`,
      ),
  },
  {
    id: 'tag',
    label: 'A black tag: KT7, serial 40418823',
    w: 136,
    h: 98,
    wide: { x: -419, y: -64, r: -58 },
    narrow: { x: -118, y: -190, r: -58 },
    draw: (c) =>
      svg(
        136,
        98,
        `<path d="M14 3h108a11 11 0 0 1 11 11v70a11 11 0 0 1-11 11H14A11 11 0 0 1 3 84V14A11 11 0 0 1 14 3z" fill="${c.ink}" stroke="${c.paper}" stroke-width="3"/>
        <circle cx="117" cy="49" r="6" fill="${c.paper}"/>
        <text x="16" y="60" font-family="${HEAVY}" font-size="44" font-weight="900" letter-spacing="-1" fill="${c.paper}" transform="skewX(-8) translate(8 0)">KT7</text>
        <text x="18" y="82" font-family="${SANS}" font-size="11.5" font-weight="700" letter-spacing="1.3" fill="${c.paper}">40418823</text>`,
      ),
  },
  {
    id: 'keycap',
    label: 'A keycap with a return arrow',
    w: 182,
    h: 200,
    wide: { x: -288, y: -30, r: -8 },
    narrow: { x: 34, y: -157, r: -8 },
    draw: (c) =>
      svg(
        182,
        200,
        `<path d="M24 52h96a22 22 0 0 1 22 22v96a22 22 0 0 1-22 22H24A22 22 0 0 1 2 170V74a22 22 0 0 1 22-22z" fill="${c.paper}"/>
        <path d="M30 56h88a20 20 0 0 1 20 20v88a20 20 0 0 1-20 20H30a20 20 0 0 1-20-20V76a20 20 0 0 1 20-20z" fill="${c.soft}" stroke="${c.ink}" stroke-width="4"/>
        <path d="M62 6h96a22 22 0 0 1 22 22v96a22 22 0 0 1-22 22H62a22 22 0 0 1-22-22V28A22 22 0 0 1 62 6z" fill="${c.paper}"/>
        <path d="M66 10h88a19 19 0 0 1 19 19v88a19 19 0 0 1-19 19H66a19 19 0 0 1-19-19V29a19 19 0 0 1 19-19z" fill="${c.soft}" stroke="${c.ink}" stroke-width="4.5"/>
        <path d="M66 14h88a15 15 0 0 1 15 15v74a15 15 0 0 1-15 15H66a15 15 0 0 1-15-15V29a15 15 0 0 1 15-15z" fill="${c.paper}"/>
        <path d="M86 52h26l14 26h18M112 52h22" fill="none" stroke="${c.ink}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>`,
      ),
  },
  {
    id: 'ticket',
    label: 'A grey ticket: admit one',
    w: 232,
    h: 150,
    wide: { x: -192, y: 104, r: 22 },
    narrow: { x: -140, y: 41, r: 22 },
    draw: (c) =>
      svg(
        232,
        150,
        `<path d="M4 4h224v52a19 19 0 0 0 0 38v52H4V94a19 19 0 0 0 0-38z" fill="${c.soft}"/>
        <path d="M168 14v122" stroke="${c.mid}" stroke-width="1.5" stroke-dasharray="4 4"/>
        <text x="18" y="34" font-family="${MONO}" font-size="11" font-weight="700" fill="${c.mid}">ADMIT ONE</text>
        <text x="18" y="128" font-family="${MONO}" font-size="11" font-weight="700" fill="${c.mid}">ROW 4 · SEAT 12</text>
        <text x="180" y="82" font-family="${MONO}" font-size="16" font-weight="700" fill="${c.mid}">4,00</text>`,
      ),
  },
  {
    id: 'battery',
    label: 'A pixel badge: 2% battery, still going',
    w: 266,
    h: 196,
    wide: { x: -150, y: 84, r: 12 },
    narrow: { x: -96, y: 61, r: 12 },
    draw: (c) => {
      const p = 6;
      const tent = [
        '        ##        ',
        '       #..#       ',
        '      #....#      ',
        '     #..##..#     ',
        '    #..#..#..#    ',
        '   #...#..#...#   ',
        '  #....####....#  ',
        ' #...#......#...# ',
        '#....########....#',
      ];
      return svg(
        266,
        196,
        `${pixels(tent, p, 79, 8, c)}
        <path d="M14 62h236v6h6v6h6v48h-6v6h-6v6H14v-6H8v-6H2V74h6v-6h6z" fill="${c.ink}"/>
        <path d="M20 68h224v6h6v42h-6v6h-6v6H20v-6h-6V74h6z" fill="${c.paper}"/>
        <path d="M26 74h212v42H26z" fill="${c.ink}"/>
        <text x="132" y="104" text-anchor="middle" font-family="${MONO}" font-size="25" font-weight="900" letter-spacing="2" fill="${c.paper}">2% BATTERY</text>
        <text x="132" y="140" text-anchor="middle" font-family="${MONO}" font-size="17" font-weight="900" letter-spacing="1.5" fill="${c.ink}">STILL GOING</text>
        <path d="M30 152h204v6h6v6h-6v6H30v-6h-6v-6h6z" fill="${c.ink}"/>
        <path d="M36 158h192v6H36z" fill="${c.paper}"/>`,
      );
    },
  },
  {
    id: 'blob',
    label: 'A wobbly blob with rings',
    w: 150,
    h: 192,
    wide: { x: -118, y: -72, r: -10 },
    narrow: { x: 8, y: -30, r: -10 },
    draw: (c) => {
      const blob =
        'M75 6C98 6 104 30 120 40C140 52 146 74 134 92C124 108 140 126 132 148C122 176 96 188 72 184C46 180 22 168 16 140C10 116 26 104 20 84C14 62 24 40 42 26C52 16 62 6 75 6Z';
      return svg(
        150,
        192,
        `<path d="${blob}" fill="${c.paper}" stroke="${c.ink}" stroke-width="5"/>
        <g transform="translate(75 96) scale(0.72) translate(-75 -96)"><path d="${blob}" fill="${c.ink}"/></g>
        <g transform="translate(75 98) scale(0.5) translate(-75 -96)"><path d="${blob}" fill="${c.paper}"/></g>
        <g transform="translate(75 100) scale(0.28) translate(-75 -96)"><path d="${blob}" fill="${c.ink}"/></g>
        <circle cx="66" cy="94" r="5" fill="${c.paper}"/>`,
      );
    },
  },
  {
    id: 'sleepy',
    label: 'A card with a sleepy eye',
    w: 192,
    h: 152,
    wide: { x: -24, y: -26, r: -20 },
    narrow: { x: 70, y: 20, r: -20 },
    draw: (c) =>
      svg(
        192,
        152,
        `<rect x="3" y="3" width="186" height="146" rx="26" fill="${c.paper}"/>
        <rect x="9" y="9" width="174" height="134" rx="21" fill="${c.paper}" stroke="${c.ink}" stroke-width="6"/>
        <path d="M40 70C66 104 126 104 152 70" fill="none" stroke="${c.ink}" stroke-width="7" stroke-linecap="round"/>
        <path d="M58 88l-10 16M84 98l-4 18M110 98l4 18M136 88l10 16" stroke="${c.ink}" stroke-width="5" stroke-linecap="round"/>
        <path d="M128 30c10 0 16 6 16 14" fill="none" stroke="${c.ink}" stroke-width="5" stroke-linecap="round"/>`,
      ),
  },
  {
    id: 'dripper',
    label: 'A pour-over coffee dripper on a cup',
    w: 196,
    h: 236,
    wide: { x: 58, y: -56, r: 0 },
    narrow: { x: 150, y: 13, r: 4 },
    draw: (c) =>
      svg(
        196,
        236,
        `<path d="M10 22h150l-8 14H18z" fill="${c.paper}" stroke="${c.paper}" stroke-width="12" stroke-linejoin="round"/>
        <path d="M24 36L64 126h48l40-90" fill="${c.paper}" stroke="${c.paper}" stroke-width="12" stroke-linejoin="round"/>
        <path d="M150 52c30 0 34 46 4 52" fill="none" stroke="${c.paper}" stroke-width="16"/>
        <path d="M48 134c-26 18-30 70-8 90h110c22-20 18-72-8-90z" fill="${c.paper}" stroke="${c.paper}" stroke-width="12" stroke-linejoin="round"/>
        <path d="M10 22h150l-8 14H18z" fill="${c.mid}" stroke="${c.ink}" stroke-width="3" stroke-linejoin="round"/>
        <path d="M24 36L64 126h48l40-90z" fill="${c.mid}" stroke="${c.ink}" stroke-width="3" stroke-linejoin="round"/>
        <path d="M150 52c30 0 34 46 4 52" fill="none" stroke="${c.ink}" stroke-width="3"/>
        <path d="M44 52l34 66M66 46l20 72M90 46l4 72M114 46l-12 72M134 50l-26 66" stroke="${c.ink}" stroke-width="2" stroke-linecap="round" opacity="0.8"/>
        <path d="M58 126h60v10H58z" fill="${c.ink}"/>
        <path d="M48 136c-26 18-30 70-8 88h110c22-18 18-70-8-88z" fill="${c.mid}" stroke="${c.ink}" stroke-width="3" stroke-linejoin="round"/>
        <path d="M44 168c30 10 74 10 102 0" fill="none" stroke="${c.ink}" stroke-width="2.4"/>
        <path d="M62 150c-8 14-10 40-4 60" fill="none" stroke="${c.paper}" stroke-width="4" stroke-linecap="round" opacity="0.7"/>`,
      ),
  },
  {
    id: 'chill',
    label: 'Handwritten: v2.0 and chill',
    w: 176,
    h: 128,
    wide: { x: 104, y: 72, r: -18 },
    narrow: { x: 128, y: 146, r: -18 },
    draw: (c) => {
      const word = (x: number, y: number, size: number, text: string) =>
        `<text x="${x}" y="${y}" font-family="${HAND}" font-size="${size}" font-weight="700" fill="${c.paper}" stroke="${c.paper}" stroke-width="12" stroke-linejoin="round">${text}</text><text x="${x}" y="${y}" font-family="${HAND}" font-size="${size}" font-weight="700" fill="${c.paper}" stroke="${c.ink}" stroke-width="2.2" stroke-linejoin="round">${text}</text>`;
      return svg(
        176,
        128,
        `${word(14, 42, 34, 'v2.0')}${word(62, 78, 30, 'and')}${word(34, 118, 40, 'chill')}`,
      );
    },
  },
  {
    id: 'chip',
    label: 'An interface chip: export layer at 2x',
    w: 186,
    h: 118,
    wide: { x: 182, y: -30, r: 14 },
    narrow: { x: -40, y: 146, r: 10 },
    draw: (c) =>
      svg(
        186,
        118,
        `<rect x="2" y="2" width="182" height="114" rx="20" fill="${c.paper}" stroke="${c.mid}" stroke-width="1.5"/>
        <text x="22" y="40" font-family="${SANS}" font-size="17" fill="${c.ink}">2x</text>
        <text x="96" y="40" font-family="${SANS}" font-size="17" fill="${c.mid}">Suffix</text>
        <rect x="12" y="58" width="162" height="44" rx="12" fill="${c.paper}" stroke="${c.mid}" stroke-width="1.5"/>
        <path d="M27 72h12v12H27zM24 76h18M24 80h18M31 69v18M35 69v18" fill="none" stroke="${c.ink}" stroke-width="1.8"/>
        <text x="52" y="87" font-family="${SANS}" font-size="17" fill="${c.ink}">Export layer</text>`,
      ),
  },
  {
    id: 'poster',
    label: 'A poster stamp: Ven ta na',
    w: 208,
    h: 256,
    wide: { x: 296, y: -24, r: -4 },
    narrow: { x: 172, y: -130, r: -4 },
    draw: (c) =>
      svg(
        208,
        256,
        `<defs>${perforation('m', 208, 256, 6.5, 18)}<radialGradient id="g"><stop offset="0.2" stop-color="${c.mid}"/><stop offset="1" stop-color="${c.paper}" stop-opacity="0"/></radialGradient></defs>
        <rect width="208" height="256" fill="${c.ink}" mask="url(#m)"/>
        <rect x="13" y="13" width="182" height="230" fill="${c.paper}"/>
        <rect x="19" y="19" width="170" height="218" fill="none" stroke="${c.ink}" stroke-width="1.4"/>
        <circle cx="104" cy="132" r="70" fill="url(#g)" opacity="0.7"/>
        ${rays(104, 132, 34, 92, 72, c.ink)}
        <text x="104" y="48" text-anchor="middle" font-family="${SANS}" font-size="7.5" font-weight="700" letter-spacing="1" fill="${c.ink}">SERIES Nº 512</text>
        <g font-family="${HEAVY}" font-size="40" font-weight="900" letter-spacing="-1.5" fill="${c.ink}" stroke="${c.paper}" stroke-width="3" paint-order="stroke">
        <text x="104" y="112" text-anchor="middle">VEN</text><text x="104" y="148" text-anchor="middle">TA</text><text x="104" y="184" text-anchor="middle">NA</text></g>
        <text x="104" y="226" text-anchor="middle" font-family="${SANS}" font-size="7.5" font-weight="700" letter-spacing="1" fill="${c.ink}">1962</text>`,
      ),
  },
  {
    id: 'label',
    label: 'A black label: Lorem ipsum, a stand-in sticker',
    w: 258,
    h: 136,
    wide: { x: 376, y: 14, r: 14 },
    narrow: { x: 128, y: 203, r: 8 },
    draw: (c) =>
      svg(
        258,
        136,
        `<rect x="3" y="3" width="252" height="130" rx="22" fill="${c.ink}" stroke="${c.paper}" stroke-width="4"/>
        <text x="22" y="64" font-family="${SCRIPT}" font-style="italic" font-size="52" fill="${c.paper}" stroke="${c.ink}" stroke-width="3" paint-order="stroke">Lorem</text>
        <text x="98" y="88" font-family="${SANS}" font-size="44" fill="${c.paper}" stroke="${c.ink}" stroke-width="3" paint-order="stroke">ipsum</text>
        ${sparkle(212, 30, 16, c.paper, c.ink)}${sparkle(30, 92, 11, c.paper, c.ink)}
        <g font-family="${SANS}" font-size="8.6" font-weight="700" letter-spacing="1.2" fill="${c.paper}" text-anchor="middle">
        <text x="129" y="106">A STAND-IN STICKER</text><text x="129" y="120">UNTIL A BETTER ONE SHOWS UP</text></g>`,
      ),
  },
];

/** The lens's resting place in each pile, px from the pile's middle. */
export const LENS_HOME = { wide: { x: -18, y: -49 }, narrow: { x: 0, y: -10 } };

/** The piles' sizes at the design's scale, for fitting them into the stage. */
export const PILE = { wide: { w: 1180, h: 470 }, narrow: { w: 560, h: 540 } };

export const toDataUrl = (markup: string) =>
  `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
```

### sticker-lens/panel.tsx

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

'use client';

import type { CSSProperties, KeyboardEvent, Ref } from 'react';
import type { LensParams } from './lens-renderer';
import styles from './sticker-lens.module.css';

/*
 * The settings panel: the lens's tuning, one row per value. Each row is a scrub bar: a native
 * range input stretched invisibly over the whole row, so pointer, touch, keyboard and screen
 * readers all work as they do on any slider, while the row draws its own fill, ticks and value.
 * Versions are presets; the plus button keeps the current tuning as a new one, and the copy
 * button puts it on the clipboard as JSON.
 */

export type Version = { name: string; params: LensParams };

export const ROWS: {
  key: keyof LensParams;
  label: string;
  min: number;
  max: number;
  step: number;
  digits: number;
}[] = [
  { key: 'radius', label: 'Radius', min: 20, max: 240, step: 1, digits: 0 },
  { key: 'magnification', label: 'Magnification', min: 1, max: 4, step: 0.01, digits: 2 },
  { key: 'ior', label: 'Ior', min: 1, max: 2.5, step: 0.001, digits: 3 },
  { key: 'aberration', label: 'Aberration', min: 0, max: 0.03, step: 0.0001, digits: 4 },
  { key: 'thickness', label: 'Thickness', min: 0.1, max: 1.5, step: 0.01, digits: 2 },
  { key: 'bevelStart', label: 'Bevel Start', min: 0.45, max: 1, step: 0.001, digits: 3 },
  { key: 'oblateness', label: 'Oblateness', min: 0.2, max: 1, step: 0.01, digits: 2 },
  {
    key: 'refraction',
    label: 'Refraction Displacement',
    min: 0,
    max: 1.5,
    step: 0.01,
    digits: 2,
  },
  { key: 'frost', label: 'Frost', min: 0, max: 1, step: 0.01, digits: 2 },
];

const TICKS = Array.from({ length: 9 }, (_, i) => i);

const ICON = {
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.5,
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const;

export function SlidersIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true" {...ICON}>
      <path d="M2.5 4h4.5M10.5 4h3M2.5 8h1.5M7.5 8h6M2.5 12h6.5M12.5 12h1" />
      <circle cx="8.75" cy="4" r="1.5" />
      <circle cx="5.75" cy="8" r="1.5" />
      <circle cx="10.75" cy="12" r="1.5" />
    </svg>
  );
}

export function Panel({
  id,
  ref,
  params,
  onChange,
  lensOpen,
  onLensOpen,
  versions,
  version,
  onVersion,
  onAddVersion,
  onCopy,
  onClose,
  dragging,
  onDragging,
}: {
  id: string;
  ref?: Ref<HTMLDivElement>;
  params: LensParams;
  onChange: (key: keyof LensParams, value: number) => void;
  lensOpen: boolean;
  onLensOpen: (open: boolean) => void;
  versions: Version[];
  version: number;
  onVersion: (index: number) => void;
  onAddVersion: () => void;
  onCopy: () => void;
  onClose: () => void;
  /** The row being scrubbed, which shows its ticks. */
  dragging: keyof LensParams | null;
  onDragging: (key: keyof LensParams | null) => void;
}) {
  const onKeyDown = (event: KeyboardEvent) => {
    if (event.key === 'Escape') {
      event.stopPropagation();
      onClose();
    }
  };
  return (
    <div
      id={id}
      ref={ref}
      className={styles.panel}
      role="dialog"
      aria-label="Shop Lens settings"
      data-part="panel"
      onKeyDown={onKeyDown}
    >
      <div className={styles.panelHead}>
        <h3 className={styles.panelTitle}>Shop Lens</h3>
        <button
          type="button"
          className={styles.panelClose}
          aria-label="Close the settings"
          onClick={onClose}
          data-demo="close"
        >
          <SlidersIcon className={styles.icon} />
        </button>
      </div>
      <div className={styles.panelTools} data-part="versions">
        <button
          type="button"
          className={styles.tool}
          aria-label="Keep these settings as a new version"
          onClick={onAddVersion}
        >
          <svg viewBox="0 0 16 16" className={styles.icon} aria-hidden="true" {...ICON}>
            <path d="M2.5 4h8M2.5 8h8M2.5 12h4.5M12 9.5v5M9.5 12h5" />
          </svg>
        </button>
        <label className={styles.select}>
          <span className={styles.hidden}>Version</span>
          <select value={version} onChange={(event) => onVersion(Number(event.target.value))}>
            {versions.map((entry, index) => (
              <option key={entry.name} value={index}>
                {entry.name}
              </option>
            ))}
          </select>
          <svg viewBox="0 0 16 16" className={styles.chevron} aria-hidden="true" {...ICON}>
            <path d="M4.5 6.5L8 10l3.5-3.5" />
          </svg>
        </label>
        <button
          type="button"
          className={styles.tool}
          aria-label="Copy these settings"
          onClick={onCopy}
        >
          <svg viewBox="0 0 16 16" className={styles.icon} aria-hidden="true" {...ICON}>
            <path d="M5.5 3H4.25A1.25 1.25 0 0 0 3 4.25v8.5A1.25 1.25 0 0 0 4.25 14h7.5A1.25 1.25 0 0 0 13 12.75V4.25A1.25 1.25 0 0 0 11.75 3H10.5" />
            <rect x="5.5" y="2" width="5" height="2.5" rx="0.8" />
            <path d="M8 7.5v4M6 9.5h4" />
          </svg>
        </button>
      </div>
      <div className={styles.rows}>
        <div className={styles.toggleRow} role="group" aria-label="Lens" data-part="lens-switch">
          <span className={styles.rowLabel}>Open</span>
          <span className={styles.segments}>
            <button
              type="button"
              className={styles.segment}
              aria-pressed={!lensOpen}
              onClick={() => onLensOpen(false)}
            >
              Off
            </button>
            <button
              type="button"
              className={styles.segment}
              aria-pressed={lensOpen}
              onClick={() => onLensOpen(true)}
            >
              On
            </button>
          </span>
        </div>
        {ROWS.map((row) => {
          const value = params[row.key];
          const fill = (value - row.min) / (row.max - row.min);
          return (
            <label
              key={row.key}
              className={styles.row}
              style={{ '--fill': `${(fill * 100).toFixed(2)}%` } as CSSProperties}
              data-dragging={dragging === row.key || undefined}
              data-row={row.key}
              data-part="row"
            >
              <span className={styles.rowFill} aria-hidden="true" />
              <span className={styles.ticks} aria-hidden="true">
                {TICKS.map((tick) => (
                  <span key={tick} />
                ))}
              </span>
              <span className={styles.rowLabel}>{row.label}</span>
              <span className={styles.rowValue} aria-hidden="true">
                {value.toFixed(row.digits)}
              </span>
              <input
                type="range"
                min={row.min}
                max={row.max}
                step={row.step}
                value={value}
                aria-valuetext={value.toFixed(row.digits)}
                onChange={(event) => onChange(row.key, Number(event.target.value))}
                onPointerDown={() => onDragging(row.key)}
                onPointerUp={() => onDragging(null)}
                onPointerCancel={() => onDragging(null)}
                onBlur={() => onDragging(null)}
              />
            </label>
          );
        })}
      </div>
    </div>
  );
}
```

### sticker-lens/use-autoplay.ts

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

'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 };
}
```

### sticker-lens/sticker-lens.module.css

```css
/* Sticker lens (sticker-lens.tsx): a quiet shop page, pale grey by day and black at night, with
   the sticker pile in its middle, the glass lens on top, a nav pill at the bottom and the
   settings panel at the top right. Sizes are the design's px; the pile scales itself to the
   stage from script.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the page takes the
   color-scheme it inherits, or the one its colorScheme prop or its own header buttons set
   (data-color-scheme). The sticker inks are read from script and drawn into the SVG art.

   Memory. The stage is one layout boundary sized from outside; stickers and the lens move by
   transforms on layers of their own, so dragging repaints nothing else. */

.root {
  --page: #f6f6f6;
  --text: #1b1b1b;
  --title: #4b4b4b;
  --muted: #4f4f4f;
  --chip: #fff;
  --chip-line: rgb(0 0 0 / 0.05);
  --chip-active: #eef0ee;
  --chip-shadow: rgb(0 0 0 / 0.05);
  --solid: #1b1b1b;
  --solid-ink: #fff;
  --sticker-ink: #121212;
  --sticker-paper: #f4f5f3;
  --sticker-mid: #b9bcb9;
  --sticker-soft: #e1e3e1;
  --sticker-shadow: rgb(0 0 0 / 0.12);
  --lens-shadow: rgb(0 0 0 / 0.16);
  --panel: #fff;
  --panel-line: rgb(0 0 0 / 0.08);
  --row: #f1f1f1;
  --row-fill: #dedede;
  --row-text: #2c2c2c;
  --row-value: #3b3b3b;
  --segment: #fff;
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  position: relative;
  container-type: inline-size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 4 / 3;
  min-height: 600px;
  max-height: 1080px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--page);
  color: var(--text);
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    sans-serif;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --page: light-dark(#f6f6f6, #000);
    --text: light-dark(#1b1b1b, #f2f2f2);
    --title: light-dark(#4b4b4b, #f0f0f0);
    --muted: light-dark(#4f4f4f, #d4d4d4);
    --chip: light-dark(#fff, #0c0c0c);
    --chip-line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.1));
    --chip-active: light-dark(#eef0ee, #262626);
    --chip-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.5));
    --solid: light-dark(#1b1b1b, #262626);
    --solid-ink: light-dark(#fff, #e8e8e8);
    --sticker-ink: light-dark(#121212, #ebeae4);
    --sticker-paper: light-dark(#f4f5f3, #141414);
    --sticker-mid: light-dark(#b9bcb9, #5a5a5a);
    --sticker-soft: light-dark(#e1e3e1, #2a2a2a);
    --sticker-shadow: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.6));
    --lens-shadow: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.7));
    --panel: light-dark(#fff, #1c1c1c);
    --panel-line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --row: light-dark(#f1f1f1, #262626);
    --row-fill: light-dark(#dedede, #3d3d3d);
    --row-text: light-dark(#2c2c2c, #d4d4d4);
    --row-value: light-dark(#3b3b3b, #d4d4d4);
    --segment: light-dark(#fff, #3a3a3a);
  }
}
.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;
}
@container (max-width: 639px) {
  .root .hero {
    top: 84px;
  }
}
@media (max-width: 639px) {
  .root {
    min-height: 680px;
  }
}

:where(.root) button {
  font: inherit;
  color: inherit;
  margin: 0;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.root button:focus-visible,
.root select:focus-visible,
.row:has(input:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.probe {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* ---------- Header ---------- */

.header {
  position: absolute;
  inset: 0 0 auto;
  height: 64px;
  z-index: 3;
  pointer-events: none;
}
.brand {
  position: absolute;
  left: 63px;
  top: 32px;
  display: flex;
  align-items: center;
  gap: 12px;
  transform: translateY(-50%);
  color: var(--muted);
}
.brandMark {
  width: 20px;
  height: 20px;
}
.brandName {
  font-size: 16px;
  line-height: 20px;
  letter-spacing: -0.005em;
  white-space: nowrap;
}
.controls {
  position: absolute;
  top: 12px;
  right: 6px;
  display: flex;
  align-items: center;
  gap: 8px;
  pointer-events: auto;
}
.themes {
  display: flex;
  gap: 12px;
  height: 41px;
  padding: 4px;
  border-radius: 999px;
  background: var(--chip);
  box-shadow:
    0 0 0 1px var(--chip-line),
    0 2px 6px var(--chip-shadow);
}
.theme {
  display: grid;
  place-items: center;
  width: 33px;
  height: 33px;
  border-radius: 50%;
  color: var(--muted);
  transition: background-color 0.18s ease;
}
.theme[aria-pressed='true'] {
  background: var(--chip-active);
  color: var(--text);
}
@media (hover: hover) {
  .theme:hover {
    background: var(--chip-active);
  }
}
.icon {
  width: 16px;
  height: 16px;
}
.theme .icon {
  width: 18px;
  height: 18px;
}
.settings {
  display: grid;
  place-items: center;
  width: 43px;
  height: 43px;
  border-radius: 50%;
  background: var(--solid);
  color: var(--solid-ink);
  box-shadow: 0 4px 12px var(--chip-shadow);
  transition: transform 0.15s ease;
}
.settings:active {
  transform: scale(0.94);
}

/* ---------- Hero ---------- */

.hero {
  position: absolute;
  left: 16px;
  right: 16px;
  top: 19.7%;
  z-index: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  pointer-events: none;
}
.hero > * {
  margin: 0;
  white-space: nowrap;
}
.eyebrow {
  font-size: 14px;
  line-height: 18px;
  text-transform: uppercase;
  color: var(--text);
}
.title {
  margin-top: 9px;
  font-size: 64px;
  line-height: 65px;
  font-weight: 400;
  letter-spacing: -0.03em;
  color: var(--title);
}
.lead {
  margin-top: 12px;
  font-size: 15.5px;
  line-height: 20px;
  color: var(--text);
}
@container (max-width: 639px) {
  .title {
    font-size: 44px;
    line-height: 48px;
  }
  .brandName {
    font-size: 15px;
  }
}
@container (max-width: 400px) {
  .brand {
    left: 18px;
  }
  .brandName {
    display: none;
  }
}

/* ---------- Pile ---------- */

.pile {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}
.reset {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 0;
  height: 32px;
  padding: 0 14px;
  border-radius: 999px;
  background: var(--chip);
  border: 1px solid var(--chip-line);
  font-size: 13px;
  white-space: nowrap;
  pointer-events: auto;
}
.sticker {
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  transform-origin: 50% 50%;
  pointer-events: auto;
  cursor: grab;
  touch-action: none;
  will-change: transform;
}
.sticker:active {
  cursor: grabbing;
}
.art {
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  filter: drop-shadow(0 1px 3px var(--sticker-shadow));
}
.lens {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1000;
  border-radius: 50%;
  pointer-events: auto;
  cursor: grab;
  touch-action: none;
  will-change: transform;
  box-shadow:
    0 22px 40px -8px var(--lens-shadow),
    0 6px 14px -4px var(--lens-shadow);
}
.lens[hidden] {
  display: none;
}
.lens:active {
  cursor: grabbing;
}
.lensCanvas {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
}

/* ---------- Nav ---------- */

.nav {
  position: absolute;
  left: 50%;
  bottom: 27px;
  z-index: 2;
  display: flex;
  gap: 8px;
  padding: 1.5px;
  border-radius: 999px;
  border: 1px solid var(--chip-line);
  background: var(--chip);
  transform: translateX(-50%);
}
.navItem {
  height: 32px;
  padding: 0 18px;
  border-radius: 999px;
  font-size: 14px;
  color: var(--muted);
  white-space: nowrap;
  transition: background-color 0.18s ease;
}
.navItem[aria-current='page'] {
  background: var(--chip-active);
}
@container (max-width: 400px) {
  .nav {
    gap: 2px;
  }
  .navItem {
    padding: 0 13px;
  }
}

/* ---------- Panel ---------- */

.panel {
  position: absolute;
  top: 12px;
  right: 6px;
  z-index: 1100;
  display: flex;
  flex-direction: column;
  width: 285px;
  max-width: calc(100% - 12px);
  max-height: min(483px, calc(100% - 24px));
  padding: 0 13px;
  border-radius: 16px;
  background: var(--panel);
  box-shadow:
    0 0 0 1px var(--panel-line),
    0 16px 40px rgb(0 0 0 / 0.18);
  color: var(--row-text);
  font-size: 13px;
  animation: panel-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
  transform-origin: 100% 0;
}
@keyframes panel-in {
  from {
    opacity: 0;
    transform: scale(0.96) translateY(-4px);
  }
}
.panelHead {
  box-sizing: border-box;
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  height: 42.5px;
  padding-top: 3.5px;
}
.panelTitle {
  margin: 0;
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
}
.panelClose {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin-right: -6px;
  border-radius: 8px;
  color: var(--row-text);
}
.panelTools {
  display: flex;
  flex: none;
  gap: 7px;
  padding-bottom: 12.5px;
  border-bottom: 1px solid var(--panel-line);
}
.tool {
  display: grid;
  place-items: center;
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  background: var(--row);
  color: var(--row-text);
}
.select {
  position: relative;
  flex: 1;
  min-width: 0;
}
.select select {
  appearance: none;
  width: 100%;
  height: 36px;
  padding: 0 32px 0 10px;
  border: 0;
  border-radius: 8px;
  background: var(--row);
  color: var(--row-text);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
}
.chevron {
  position: absolute;
  right: 10px;
  top: 10px;
  width: 16px;
  height: 16px;
  color: var(--row-text);
  opacity: 0.5;
  pointer-events: none;
}
.rows {
  display: flex;
  flex-direction: column;
  gap: 7px;
  padding: 13px 0 13px;
  overflow-y: auto;
  scrollbar-width: none;
  overscroll-behavior: contain;
}
.toggleRow,
.row {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  height: 36px;
  padding: 0 10.5px;
  border-radius: 6px;
  background: var(--row);
  overflow: hidden;
}
.toggleRow {
  padding: 0 2px 0 12.5px;
}
.segments {
  display: flex;
  align-items: center;
  height: 32px;
}
.segment {
  height: 32px;
  min-width: 34px;
  padding: 0 8px;
  border-radius: 5px;
  color: var(--row-text);
  opacity: 0.8;
}
.segment[aria-pressed='true'] {
  background: var(--segment);
  opacity: 1;
  font-weight: 500;
}
.rowLabel {
  position: relative;
  font-size: 13.5px;
  font-weight: 500;
  white-space: nowrap;
  pointer-events: none;
}
.rowValue {
  position: relative;
  font-family: JetBrains, ui-monospace, 'SF Mono', Menlo, monospace;
  font-size: 13px;
  color: var(--row-value);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}
.rowFill {
  position: absolute;
  inset: 0 auto 0 0;
  width: var(--fill);
  background: var(--row-fill);
  pointer-events: none;
}
.rowFill::after {
  content: '';
  position: absolute;
  right: 0;
  top: 9px;
  bottom: 9px;
  width: 2px;
  border-radius: 1px;
  background: var(--row-text);
  opacity: 0;
  transition: opacity 0.15s ease;
}
.row:hover .rowFill::after,
.row[data-dragging] .rowFill::after {
  opacity: 0.45;
}
.ticks {
  position: absolute;
  inset: 0 10.5px;
  display: flex;
  align-items: center;
  justify-content: space-evenly;
  opacity: 0;
  transition: opacity 0.15s ease;
  pointer-events: none;
}
.ticks > span {
  width: 1px;
  height: 7px;
  background: var(--row-text);
  opacity: 0.3;
}
.row[data-dragging] .ticks {
  opacity: 1;
}
.row input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
  touch-action: pan-y;
}

/* ---------- Demo pointer ---------- */

.pointer {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1200;
  width: 22px;
  height: 22px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.3));
  will-change: transform;
}
.pointerGlyph {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  transition: transform 0.12s ease;
  transform-origin: 15% 10%;
}
.pointer[data-pressed='true'] .pointerGlyph {
  transform: scale(0.86);
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .panel {
    animation: none;
  }
  .theme,
  .navItem,
  .settings,
  .pointer,
  .pointerGlyph {
    transition: none;
  }
}
```