# Hand desktop

> A playful desk you work with your hands: pinch to grab folders and hatch pixel critters, wave, or touch both hands to start a dance.

A white hand skeleton floats over a painted sky of rolling hills. Pinch a folder or a window bar to carry it, pinch the sky to hatch a pixel critter that drops onto a folder or the dock and wanders along it, wave an open hand and the critters wave back, and touch both index fingers for a dance party. The pointer is a virtual hand by default; Use camera swaps in your real hands, tracked on the device.

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

Category: Media.

## Anatomy

1. **Desk**: The painted sky and hills at 16 / 10, between 440 and 720 px tall, with one canvas on top that draws the hand skeletons and the critters.
2. **Hint pill**: `hint` while nothing happens; what a gesture did, or what to do next, while it does.
3. **Folder**: A button with a folder icon and a label: pinch it to carry it, arrow keys move it, Enter or a double click opens its window.
4. **Window**: The open folder: a bar you pinch to carry it and a close button. The guide has a button for each gesture.
5. **Clock**: The time now, in large type on the sky; its note turns to the dance while both hands touch.
6. **Dock**: Eight app tiles, then a tile that hatches a critter, then the guide and the trash that sends the critters home.
7. **Camera status**: A chip with a dot that says where the hand comes from, and the Use camera button (a toggle).

## When to use

- A playful landing, a portfolio hero or an event booth where the point is to try hand gestures, with a pointer that works the same way when there is no camera.
- Showing what on-device hand tracking can do (pinch, wave, two hands together) without sending a frame anywhere.
- A toy desk to drag a few folders and windows around, with small creatures that react to what the hands do.
- A kiosk or a stage screen where people stand back from the keyboard and use their hands in the air.

## When not to use

- A real file manager or desktop people work in. Use a list or grid of files with a proper context menu and keyboard selection.
- Anything that must be fast and exact: gestures in the air are slow and tiring. Use plain buttons and drag and drop with a mouse.
- A page where asking for the camera would surprise or worry the reader. Pass `camera={false}`, or use a static illustration.
- A small card or a dense layout: the desk needs at least 440 px of height to read. Use a still image of it or a short video.
- Teaching sign language or anything where a misread gesture has a cost: the tracker is tuned for fun, not accuracy.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/hand-desktop.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 { HandDesktop } from './hand-desktop';

export function Playground() {
  return <HandDesktop critters={4} onGesture={(gesture) => console.log(gesture)} />;
}
```

## States

- **Resting**: The guide window open, three critters on the dock, the pointer hand idle.
- **Window shut**: `open: null`: only the folders, the clock and the dock. A desk under 560 px starts this way.
- **Folder open**: `open: 'sketches'`: a window of three files.
- **Pets**: `open: 'pets'`: every critter that can hatch, by name.
- **Hand over a folder**: An open hand on the sketches folder: its icon gets a pale backing.
- **Holding a folder**: The hand pinches the folder: the icon lifts, its label turns blue and the pill says how to drop it.
- **Hatching**: A pinch on the sky: a critter pops out, flutters and falls onto the first surface below.
- **Dance party**: Two hands with index tips touching: the critters dance and the clock’s note changes, for as long as they touch.
- **Camera loading**: `camera: 'loading'`: the chip waits in amber and the button reads Cancel.
- **Hand lost**: `camera: 'lost'`: tracking, but no hand in view; the camera preview stays in its corner.
- **Camera blocked**: `camera: 'denied'`: the chip goes red and the pointer is the hand again.
- **Pointer only**: `camera={false}` and `critters={0}`: no camera button and no critters until one hatches.
- **Day**: `colorScheme="light"`: a blue sky over green hills.
- **Dusk**: `colorScheme="dark"`: the sky turns to dusk with stars, the window dark.
- **Narrow**: A 375 px desk: the window shut, the hint wrapped at the top left, a camera icon without words and six app tiles fewer in the dock.

## How it works

- Every hand is 21 points in the desk’s pixels, wherever it comes from: the pointer’s virtual hand, the demo’s scripted hands or the camera’s tracked ones. One piece of code reads all of them, so the demo, the gallery and the camera behave alike.
- The virtual hand blends three poses (open, pinching, pointing) and holds the gap between thumb and index tip under the pointer; pressing closes the pinch. Two touches are two hands.
- Gestures are measured in palm lengths: a pinch closes under 0.28 and opens over 0.45, so jitter never fires twice; an open hand that swings left and right three times waves; two index tips within half a palm start the dance until they part by a whole palm.
- A pinch grabs the critter, window bar or folder under it, or hatches a critter there. Critters pop, flutter, then fall onto the first surface below (a folder, the window, the clock, the dock or the grass), ride along when it moves and walk the dock and the grass.
- Use camera fetches the hand tracker only then (its module, WebAssembly runtime and model, from a CDN by default) and asks for the camera after it. Landmarks are mirrored, the middle 80 % of the frame spans the desk, and a One Euro filter steadies each point. Frames never leave the browser; Stop camera or leaving ends every track.
- One loop runs while the desk is on screen and its tab is visible: it reads the hands, moves the held parts by transforms and draws the critters (12 × 12 sprites stamped from cached canvases) and the skeletons on one canvas at the device pixel ratio.
- The sky, the hills and the folders are SVG with light-dark() tokens, so the desk turns to dusk with stars on a dark page or with the colorScheme prop.

## API reference

- **autoplay** (`boolean`, default `true`): While on screen and untouched, a scripted hand grabs a folder, hatches critters, waves and calls in a second hand for the dance. Any real input, or the camera, hands it to the reader.
- **hint** (`string`, default `'Pinch anywhere to hatch a critter!'`): What the pill at the top says when nothing is happening.
- **critters** (`number`, default `3`): How many critters already walk on the dock (up to 9).
- **camera** (`boolean`, default `true`): Shows the Use camera button. Without it, the desk is pointer-only.
- **sources** (`{ module?: string; wasm?: string; model?: string }`): Where the hand tracker’s ES module, WebAssembly folder and model file come from, to host them yourself. A public CDN by default; nothing loads until the reader presses Use camera.
- **onGesture** (`(gesture: 'hatch' | 'grab' | 'drop' | 'wave' | 'dance') => void`): Called when a gesture does something.
- **colorScheme** (`'light' | 'dark' | 'system'`): Day or dusk colours, or the reader’s; without it, the page’s color-scheme.
- **defaultState** (`{ open?: 'guide' | 'sketches' | 'notes' | 'pets' | null; hand?: 'open' | 'grab' | 'hatch' | 'dance'; camera?: 'loading' | 'asking' | 'lost' | 'denied' | 'missing' | 'unsupported' | 'failed' }`): Opens in a state, for stills and docs, with the demo off: the window open at first, a still hand (open over a folder, holding it, hatching a critter, or two hands dancing) and what the camera chip says. No camera is asked for.
- **className** (`string`): Classes for the outer element.

## Accessibility

- Everything a gesture does has a button too: folders are buttons (arrow keys move them, Shift for bigger steps, Enter opens them), the guide window has Hatch, Wave and Dance, and the dock can hatch a critter, open the guide or send the critters home.
- A polite live region says what happened (a critter hatched, a folder moved, the dance started) and every camera state: loading, allow the camera, tracking, hand lost, blocked or unavailable.
- The camera is never requested on its own: only after an explicit press of Use camera, which is a toggle button (aria-pressed). Frames are processed on the device and nothing is uploaded; the feed shows only to the reader, mirrored and dimmed.
- The skeleton, the critters, the wallpaper and the hint pill are decorative and hidden from assistive technology; the real cursor comes back over the dock, the window body and the status buttons.
- Under reduced motion there is no demo, critters stand still and land at once, and the clock does not wiggle.

## Source

### hand-desktop/hand-desktop.tsx

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

'use client';

/*
 * Hand desktop: a playful desk you work with your hands. A white hand skeleton floats over a
 * painted sky; pinch on a folder or a window's bar to grab it and open your fingers to drop it;
 * pinch on the sky to hatch a pixel critter, which drops onto a folder, the clock or the dock and
 * wanders along it; wave an open hand and the critters wave back; touch both index fingers
 * together for a dance party.
 *
 * - The hand is your pointer by default: a virtual 21-point hand, the pinch under the pointer,
 *   that closes while you press. Two fingers on a touch screen are two hands. The autoplay demo
 *   and the gallery use the same hand, scripted.
 * - "Use camera" swaps in your real hands: only then is the hand tracker fetched (its module,
 *   runtime and model, from a CDN by default), and only then is the camera asked for. It runs on
 *   the device: no frame leaves the browser. Your feed shows mirrored and dimmed in a corner, only
 *   while the camera is on; "Stop camera", or leaving, ends it.
 * - engine.ts runs one loop while the desk is on screen: it reads every hand's gestures the same
 *   way (hand-model.ts), moves what is held, runs the critters and draws them and the skeletons
 *   on one canvas. The HTML parts only move by transforms.
 * - Folders are buttons (arrow keys move them, Enter opens), and the guide window and the dock have
 *   buttons for each gesture, so everything works without a hand at all.
 * - Light or dark: the colours are light-dark() tokens, so the sky turns to dusk with the page's
 *   color-scheme or the colorScheme prop.
 */

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { CritterArt, DockIcon, FolderIcon, GestureGlyph, Wallpaper, type DockGlyph } from './art';
import { CRITTERS } from './critters';
import { Desk, type Gesture } from './engine';
import {
  DEFAULT_SOURCES,
  TrackerError,
  startTracker,
  type Tracker,
  type TrackerSources,
} from './tracker';
import { useAutoplay } from './use-autoplay';
import styles from './hand-desktop.module.css';

export type { Gesture } from './engine';
export type { TrackerSources } from './tracker';

/** A state to open in, for stills and docs: it also keeps the demo off. */
export type HandDesktopState = {
  /** The window open at first: a folder's id, or null for none. */
  open?: 'guide' | 'sketches' | 'notes' | 'pets' | null;
  /**
   * A still hand: open over the sketches folder, pinching it (held), pinching the sky (a critter
   * hatches) or two hands with index tips touching (the dance party).
   */
  hand?: 'open' | 'grab' | 'hatch' | 'dance';
  /** What the camera chip and button say; nothing is fetched or asked for. */
  camera?: 'loading' | 'asking' | 'lost' | 'denied' | 'missing' | 'unsupported' | 'failed';
};

type CameraState =
  | 'off'
  | 'loading'
  | 'asking'
  | 'tracking'
  | 'lost'
  | 'denied'
  | 'missing'
  | 'unsupported'
  | 'failed';

/** The folders on the desk, and where they start, in shares of the room left for them. */
const FOLDERS = [
  { id: 'guide', label: 'how to play', fx: 0.05, fy: 0.08 },
  { id: 'sketches', label: 'sketches', fx: 0.33, fy: 0.16 },
  { id: 'notes', label: 'field notes', fx: 0.05, fy: 0.5 },
  { id: 'pets', label: 'pets', fx: 0.5, fy: 0.44 },
];

const FILES: Record<string, string[]> = {
  sketches: ['cloud study', 'dock, take 2', 'hill colours'],
  notes: ['day one', 'gesture ideas', 'wish list'],
};

const DOCK: DockGlyph[] = ['home', 'grid', 'compass', 'chat', 'mail', 'map', 'gallery', 'video'];

const STATUS: Record<CameraState, string> = {
  off: 'Pointer hand',
  loading: 'Loading hand model…',
  asking: 'Allow the camera…',
  tracking: 'Tracking',
  lost: 'Show a hand to the camera',
  denied: 'Camera blocked · pointer hand',
  missing: 'No camera · pointer hand',
  unsupported: 'No camera here · pointer hand',
  failed: 'Model failed · pointer hand',
};

function useClock() {
  const [now, setNow] = useState<Date | null>(null);
  useEffect(() => {
    const tick = () => setNow(new Date());
    tick();
    const timer = window.setInterval(tick, 15000);
    return () => window.clearInterval(timer);
  }, []);
  return now;
}

export function HandDesktop({
  autoplay = true,
  hint = 'Pinch anywhere to hatch a critter!',
  critters = 3,
  camera: cameraButton = true,
  sources,
  onGesture,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** While on screen and untouched, a scripted hand grabs a folder, hatches critters, waves and calls in a second hand. */
  autoplay?: boolean;
  /** What the pill at the top says when nothing is happening. */
  hint?: string;
  /** How many critters are already walking on the dock. */
  critters?: number;
  /** Shows the "Use camera" button. Without it the desk is pointer-only. */
  camera?: boolean;
  /** Where the hand tracker's module, runtime and model come from (a CDN by default). */
  sources?: Partial<TrackerSources>;
  /** Called when a gesture does something: hatch, grab, drop, wave or dance. */
  onGesture?: (gesture: Gesture) => void;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in a state (a window, a still hand, a camera status), with the demo off. */
  defaultState?: HandDesktopState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  const desk = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const dock = useRef<HTMLElement>(null);
  const clockBox = useRef<HTMLElement>(null);
  const pip = useRef<HTMLDivElement>(null);
  const engine = useRef<Desk | null>(null);
  const tracker = useRef<Tracker | null>(null);
  const abort = useRef<AbortController | null>(null);
  const [hintText, setHintText] = useState(hint);
  const [message, setMessage] = useState('');
  const [dancing, setDancing] = useState(false);
  const [open, setOpen] = useState<string | null>(
    defaultState?.open !== undefined ? defaultState.open : 'guide',
  );
  const [camera, setCamera] = useState<CameraState>(defaultState?.camera ?? 'off');
  const [seen, setSeen] = useState(0);
  const now = useClock();
  const helpId = useId();

  // The folders and the window join the engine through their refs; one callback per part, so a
  // render never drops what a hand holds.
  type Part = { kind: 'folder' | 'window'; label: string; el: HTMLElement; fx: number; fy: number };
  const parts = useRef(new Map<string, Part>());
  const bindings = useRef(new Map<string, (el: HTMLElement | null) => void | (() => void)>());
  const bind = (id: string, kind: Part['kind'], label: string, fx: number, fy: number) => {
    const key = `${id}:${label}`;
    let binding = bindings.current.get(key);
    if (!binding) {
      binding = (el) => {
        if (!el) return;
        parts.current.set(id, { kind, label, el, fx, fy });
        engine.current?.add(id, kind, label, el, fx, fy);
        return () => {
          parts.current.delete(id);
          engine.current?.remove(id);
        };
      };
      bindings.current.set(key, binding);
    }
    return binding;
  };

  const announce = (text: string) => setMessage((was) => (was === text ? `${text} ` : text));
  const gesture = useEffectEvent((name: Gesture) => onGesture?.(name));
  const seenChange = useEffectEvent((count: number) => setSeen(count));

  // The engine lives as long as the desk (set up before the first paint, so the folders never
  // flash at the corner).
  useLayoutEffect(() => {
    const element = desk.current;
    const surface = canvas.current;
    if (!element || !surface) return;
    const desktop = new Desk(element, surface, {
      hint: setHintText,
      announce,
      dance: setDancing,
      seen: (count) => seenChange(count),
      gesture: (name) => gesture(name),
    });
    engine.current = desktop;
    desktop.setParts(dock.current, clockBox.current, pip.current);
    for (const [id, part] of parts.current)
      desktop.add(id, part.kind, part.label, part.el, part.fx, part.fy);
    // A narrow desk starts with its window shut, so the folders show.
    if (element.offsetWidth < 560 && defaultState?.open === undefined) setOpen(null);
    return () => {
      desktop.destroy();
      engine.current = null;
    };
  }, []);

  useEffect(() => {
    engine.current?.setBaseHint(hint);
  }, [hint]);

  // The critters already on the dock, once it is laid out.
  useEffect(() => {
    const desktop = engine.current;
    if (!desktop || critters <= 0) return;
    const frame = requestAnimationFrame(() => desktop.seed(Math.min(critters, 9)));
    return () => {
      cancelAnimationFrame(frame);
      desktop.clear();
    };
  }, [critters]);

  // A still hand, for a state opened in (once the critters and the folders are laid out).
  const stillHand = defaultState?.hand;
  useEffect(() => {
    const desktop = engine.current;
    if (!desktop || !stillHand) return;
    const frame = requestAnimationFrame(() => {
      if (stillHand === 'open') desktop.hold('a', false, 'sketches');
      else if (stillHand === 'grab') desktop.hold('a', false, 'sketches', { pinch: 1 });
      else if (stillHand === 'hatch') desktop.hold('a', false, { x: 0.56, y: 0.3 }, { pinch: 1 });
      else {
        desktop.hold('a', false, { x: 0.503, y: 0.5 }, { point: 1 });
        desktop.hold('b', true, { x: 0.497, y: 0.5 }, { point: 1 });
      }
    });
    return () => cancelAnimationFrame(frame);
  }, [stillHand]);

  // The loop runs while the desk is on screen and its tab is visible.
  useEffect(() => {
    const element = root.current;
    const desktop = engine.current;
    if (!element || !desktop) return;
    let onScreen = false;
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const update = () => {
      desktop.reduced = motion.matches;
      if (onScreen && !document.hidden) desktop.start();
      else desktop.stop();
    };
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    motion.addEventListener('change', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', update);
      desktop.stop();
    };
  }, [root]);

  // The demo: only while nobody uses the desk and the camera is off.
  const cameraOn =
    camera === 'loading' || camera === 'asking' || camera === 'tracking' || camera === 'lost';
  const demo = autoplay && !defaultState && playing && !cameraOn;
  useEffect(() => {
    const desktop = engine.current;
    if (!demo || !desktop) return;
    desktop.playDemo('sketches');
    return () => desktop.stopDemo();
  }, [demo]);

  // ——— The camera ———

  const stopCamera = () => {
    abort.current?.abort();
    abort.current = null;
    tracker.current?.stop();
    tracker.current?.video.remove();
    tracker.current = null;
    engine.current?.setTracker(null);
  };

  const startCamera = async () => {
    stopCamera();
    const controller = new AbortController();
    abort.current = controller;
    try {
      const started = await startTracker(
        { ...DEFAULT_SOURCES, ...sources },
        (phase) => {
          if (!controller.signal.aborted) {
            setCamera(phase);
            announce(
              phase === 'loading'
                ? 'Loading the hand model.'
                : 'Allow the camera to track your hands.',
            );
          }
        },
        controller.signal,
      );
      if (controller.signal.aborted) {
        started.stop();
        return;
      }
      tracker.current = started;
      started.video.className = styles.feed;
      started.video.setAttribute('aria-hidden', 'true');
      pip.current?.prepend(started.video);
      setSeen(0);
      setCamera('tracking');
      engine.current?.setTracker(started);
      announce('Camera on. Your hands are tracked on this device; nothing is uploaded.');
    } catch (error) {
      if (controller.signal.aborted) return;
      const kind = error instanceof TrackerError ? error.kind : 'load';
      const state: CameraState =
        kind === 'denied'
          ? 'denied'
          : kind === 'missing'
            ? 'missing'
            : kind === 'unsupported'
              ? 'unsupported'
              : 'failed';
      setCamera(state);
      announce(`${STATUS[state]}.`);
    }
  };

  // Leaving ends the camera.
  useEffect(() => () => stopCamera(), []);

  // No hand for a moment while tracking: say so.
  useEffect(() => {
    if (camera !== 'tracking' && camera !== 'lost') return;
    if (seen > 0) {
      setCamera('tracking');
      return;
    }
    const timer = window.setTimeout(() => setCamera('lost'), 700);
    return () => window.clearTimeout(timer);
  }, [seen, camera]);

  const toggleCamera = () => {
    if (cameraOn) {
      stopCamera();
      setCamera('off');
      announce('Camera off. The pointer is your hand again.');
    } else void startCamera();
  };

  // ——— The reader's pointer ———

  const toDesk = (event: ReactPointerEvent) => {
    const element = desk.current;
    if (!element) return { x: 0, y: 0 };
    const rect = element.getBoundingClientRect();
    const scale = rect.width / element.offsetWidth || 1;
    return { x: (event.clientX - rect.left) / scale, y: (event.clientY - rect.top) / scale };
  };
  const ignored = (event: ReactPointerEvent) =>
    !!(event.target as HTMLElement).closest?.('[data-hand-ignore]');

  const onPointer = (event: ReactPointerEvent, state: 'move' | 'down' | 'up' | 'leave') => {
    const desktop = engine.current;
    if (!desktop) return;
    const touch = event.pointerType !== 'mouse';
    const { x, y } = toDesk(event);
    if (state === 'down') {
      if (event.button !== 0 || ignored(event)) return;
      take();
      event.preventDefault();
      desk.current?.setPointerCapture(event.pointerId);
    }
    if (state === 'move' && ignored(event) && !event.buttons) state = 'leave';
    desktop.pointer(event.pointerId, x, y, state, touch);
  };

  const onFolderKey = (event: ReactKeyboardEvent, id: string) => {
    const step = event.shiftKey ? 0.1 : 0.03;
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [-step, 0],
      ArrowRight: [step, 0],
      ArrowUp: [0, -step],
      ArrowDown: [0, step],
    };
    if (moves[event.key]) {
      event.preventDefault();
      engine.current?.nudge(id, ...moves[event.key]);
    } else if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      setOpen(id);
    }
  };

  const hours = now ? now.getHours() : 3;
  const minutes = now ? String(now.getMinutes()).padStart(2, '0') : '45';
  const folder = FOLDERS.find((f) => f.id === open);
  const statusText =
    camera === 'tracking' ? `Tracking · ${seen} ${seen === 1 ? 'hand' : 'hands'}` : STATUS[camera];
  const tone =
    camera === 'tracking'
      ? 'good'
      : camera === 'lost' || camera === 'asking' || camera === 'loading'
        ? 'wait'
        : camera === 'off'
          ? 'idle'
          : 'bad';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="root"
      data-color-scheme={colorScheme}
      data-dance={dancing || undefined}
      data-camera={camera}
      onKeyDownCapture={take}
    >
      <div
        ref={desk}
        className={styles.desk}
        data-part="desk"
        onPointerMove={(event) => {
          if (event.pointerType === 'mouse') take();
          onPointer(event, 'move');
        }}
        onPointerDown={(event) => onPointer(event, 'down')}
        onPointerUp={(event) => onPointer(event, 'up')}
        onPointerCancel={(event) => onPointer(event, 'leave')}
        onPointerLeave={(event) => onPointer(event, 'leave')}
      >
        <Wallpaper />

        <div
          ref={pip}
          className={styles.pip}
          data-part="camera-preview"
          hidden={!cameraOn || camera === 'loading'}
        >
          <span className={styles.pipLabel}>Camera · on this device</span>
        </div>

        {FOLDERS.map((item) => (
          <button
            key={item.id}
            type="button"
            className={styles.folder}
            data-part="folder"
            aria-label={`${item.label} folder`}
            aria-describedby={helpId}
            ref={bind(item.id, 'folder', item.label, item.fx, item.fy)}
            onDoubleClick={() => setOpen(item.id)}
            onKeyDown={(event) => onFolderKey(event, item.id)}
          >
            <span className={styles.folderIcon} data-top="">
              <FolderIcon />
            </span>
            <span className={styles.folderLabel}>{item.label}</span>
          </button>
        ))}
        <p id={helpId} className={styles.hidden}>
          Arrow keys move it; Enter opens it.
        </p>

        {folder && (
          <section
            className={styles.window}
            data-part="window"
            aria-label={folder.label}
            ref={bind('window', 'window', folder.label, 0.95, 0.2)}
          >
            <header className={styles.titleBar} data-part="title-bar" data-top="">
              <span className={styles.title}>{folder.label}</span>
              <button
                type="button"
                className={styles.close}
                data-hand-ignore=""
                aria-label={`Close ${folder.label}`}
                onClick={() => setOpen(null)}
              >
                <svg viewBox="0 0 12 12" aria-hidden="true" focusable="false">
                  <path d="m3 3 6 6M9 3 3 9" />
                </svg>
              </button>
            </header>
            <div className={styles.windowBody} data-hand-ignore="">
              {folder.id === 'guide' && (
                <>
                  <ul className={styles.guide}>
                    <li data-part="gesture-row">
                      <GestureGlyph name="pinch" />
                      <span>
                        <strong>Pinch</strong> a folder or a bar to grab it, or the sky to hatch a
                        critter.
                      </span>
                      <button
                        type="button"
                        className={styles.try}
                        data-part="try-button"
                        onClick={() => engine.current?.spawn()}
                      >
                        Hatch
                      </button>
                    </li>
                    <li>
                      <GestureGlyph name="wave" />
                      <span>
                        <strong>Wave</strong> an open hand: the critters wave back.
                      </span>
                      <button
                        type="button"
                        className={styles.try}
                        onClick={() => engine.current?.wave()}
                      >
                        Wave
                      </button>
                    </li>
                    <li>
                      <GestureGlyph name="both" />
                      <span>
                        <strong>Both hands</strong>, index tips touching: dance party.
                      </span>
                      <button
                        type="button"
                        className={styles.try}
                        onClick={() => engine.current?.dance()}
                      >
                        Dance
                      </button>
                    </li>
                  </ul>
                  <p className={styles.note}>
                    The pointer is your hand: press to pinch, shake to wave. The camera runs on this
                    device; nothing is uploaded.
                  </p>
                </>
              )}
              {folder.id === 'pets' && (
                <ul className={styles.pets}>
                  {CRITTERS.map((sprite) => (
                    <li key={sprite.name}>
                      <CritterArt sprite={sprite} className={styles.petArt} />
                      <span>{sprite.name}</span>
                    </li>
                  ))}
                </ul>
              )}
              {FILES[folder.id] && (
                <ul className={styles.files}>
                  {FILES[folder.id].map((name) => (
                    <li key={name}>
                      <span className={styles.file} aria-hidden="true" />
                      <span>{name}</span>
                    </li>
                  ))}
                </ul>
              )}
            </div>
          </section>
        )}

        <section ref={clockBox} className={styles.clock} data-part="clock" aria-label="Clock">
          <p className={styles.clockLabel}>Time now</p>
          <p className={styles.time}>
            <time dateTime={`${String(hours).padStart(2, '0')}:${minutes}`}>
              {hours}
              <small>h</small>
              {minutes}
              <small>m</small>
            </time>
          </p>
          <p className={styles.clockNote}>
            {dancing ? (
              <>
                ♪ Dance party on
                <br />
                Keep those hands together
              </>
            ) : (
              <>
                ↑ Time for some dancing
                <br />
                Stand up and shake it
              </>
            )}
          </p>
        </section>

        <nav
          ref={dock}
          className={styles.dock}
          data-part="dock"
          aria-label="Dock"
          data-hand-ignore=""
        >
          {DOCK.map((glyph) => (
            <span
              key={glyph}
              className={styles.app}
              data-part="dock-app"
              data-app={glyph}
              aria-hidden="true"
            >
              <DockIcon glyph={glyph} />
            </span>
          ))}
          <span className={styles.divider} aria-hidden="true" />
          <button
            type="button"
            className={styles.app}
            aria-label="Hatch a critter"
            onClick={() => engine.current?.spawn()}
          >
            <span className={styles.critterTile}>
              <CritterArt sprite={CRITTERS[0]} className={styles.tileArt} />
            </span>
          </button>
          <span className={`${styles.app} ${styles.slot}`} aria-hidden="true" />
          <span className={styles.divider} aria-hidden="true" />
          <button
            type="button"
            className={styles.app}
            aria-label="Open how to play"
            onClick={() => setOpen('guide')}
          >
            <DockIcon glyph="folder" />
          </button>
          <button
            type="button"
            className={styles.app}
            aria-label="Send the critters home"
            onClick={() => engine.current?.clear()}
          >
            <DockIcon glyph="trash" />
          </button>
        </nav>

        <p className={styles.hint} data-part="hint" aria-hidden="true">
          <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
            <circle cx="8" cy="8" r="6.5" />
            <circle cx="8" cy="8" r="2.4" />
          </svg>
          {hintText}
        </p>

        <canvas ref={canvas} className={styles.overlay} data-part="overlay" aria-hidden="true" />

        <div className={styles.status} data-part="status" data-hand-ignore="">
          <span className={styles.chip} data-part="status-chip" data-tone={tone}>
            <span className={styles.dot} aria-hidden="true" />
            {statusText}
          </span>
          {cameraButton && (
            <button
              type="button"
              className={styles.cameraButton}
              data-part="camera-button"
              onClick={toggleCamera}
              aria-pressed={cameraOn}
            >
              <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
                <rect x="1.5" y="4" width="9" height="8" rx="2" />
                <path d="m10.5 7 4-2.2v6.4l-4-2.2Z" />
              </svg>
              <span className={styles.cameraLabel}>
                {cameraOn
                  ? camera === 'loading' || camera === 'asking'
                    ? 'Cancel'
                    : 'Stop camera'
                  : 'Use camera'}
              </span>
            </button>
          )}
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### hand-desktop/engine.ts

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

/*
 * The desk's engine: one loop, once a frame, while the desk is on screen. It gathers the hands
 * (the reader's pointer hand, the demo's scripted hands, the camera's tracked hands), reads their
 * gestures, moves what they hold, runs the critters, and draws the critters and the skeletons on
 * one canvas over the desk. The folders, the window, the clock and the dock are HTML that it only
 * moves, by transforms.
 *
 * Every hand, wherever it comes from, is 21 points in the desk's px, so one piece of code reads
 * the gestures for all of them: a pinch grabs what is under it or hatches a critter, an open hand
 * waving left and right greets the critters, two index tips touching start a dance.
 */

import {
  BONES,
  INDEX_TIP,
  OneEuro,
  PINCH_OFF,
  PINCH_ON,
  TOGETHER_OFF,
  TOGETHER_ON,
  WRIST,
  cursorOf,
  isOpen,
  palm,
  pinchGap,
  poseHand,
  tipsGap,
  type Landmarks,
  type Point,
} from './hand-model';
import { CRITTERS, stamp, type Sprite } from './critters';
import type { SeenHand, Tracker } from './tracker';

export type Events = {
  /** The hint pill's words. */
  hint: (text: string) => void;
  /** Something for screen readers. */
  announce: (text: string) => void;
  /** The dance party starts or ends. */
  dance: (on: boolean) => void;
  /** How many hands the camera sees (only while it is on). */
  seen: (count: number) => void;
  /** A gesture did something. */
  gesture: (name: Gesture) => void;
};

export type Gesture = 'hatch' | 'grab' | 'drop' | 'wave' | 'dance';

type Kind = 'folder' | 'window';
type Movable = {
  id: string;
  kind: Kind;
  label: string;
  el: HTMLElement;
  /** Top-left corner, in shares of the room left for it (0 … 1 across, 0 … 1 down). */
  fx: number;
  fy: number;
  /** Its box in desk px, refreshed every frame from the shares. */
  x: number;
  y: number;
  w: number;
  h: number;
  /** For a folder, the icon's height (critters sit on it); for a window, its title bar's. */
  top: number;
  z: number;
};

type Surface = { id: string; x0: number; x1: number; y: number; dx: number; dy: number };

type Critter = {
  id: number;
  sprite: Sprite;
  x: number;
  y: number;
  vy: number;
  dir: 1 | -1;
  on: string | null;
  /** Its place on what it stands on (x offset from the surface's left), so it rides along. */
  along: number;
  state: 'pop' | 'fall' | 'walk' | 'idle' | 'held';
  timer: number;
  born: number;
  step: number;
  hop: number;
  hopFrom: number;
  heart: number;
  gone: number;
};

type Particle = { x: number; y: number; vx: number; vy: number; life: number; colour: string };

/** A virtual hand: where it is and where it is going, how pinched and how pointing. */
type Puppet = {
  x: number;
  y: number;
  tx: number;
  ty: number;
  vx: number;
  pinch: number;
  pinchTo: number;
  point: number;
  pointTo: number;
  angle: number;
  left: boolean;
  present: boolean;
  tween: {
    fx: number;
    fy: number;
    tx: number;
    ty: number;
    start: number;
    ms: number;
    done: () => void;
  } | null;
};

type Grab =
  { kind: 'movable'; id: string; dx: number; dy: number } | { kind: 'critter'; id: number };

type Hand = {
  id: string;
  source: 'pointer' | 'demo' | 'camera';
  points: Landmarks;
  /** For a camera hand, its points in shares of the frame (for the camera preview). */
  raw: Landmarks | null;
  alpha: number;
  present: boolean;
  seenAt: number;
  pinched: boolean;
  grab: Grab | null;
  hover: string | null;
  wave: { extreme: number; dir: number; turns: number[]; at: number };
  filters: OneEuro[] | null;
};

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

/** How many critters at once; the oldest goes in a puff of pixels. */
const MAX_CRITTERS = 9;

export class Desk {
  private width = 0;
  private height = 0;
  private dpr = 1;
  private frame = 0;
  private last = 0;
  private running = false;
  private movables = new Map<string, Movable>();
  private zTop = 10;
  private dock: HTMLElement | null = null;
  private clock: HTMLElement | null = null;
  private pip: HTMLElement | null = null;
  private critters: Critter[] = [];
  private particles: Particle[] = [];
  private nextCritter = 1;
  private hands = new Map<string, Hand>();
  private puppets = new Map<string, Puppet>();
  private tracker: Tracker | null = null;
  private seenCount = -1;
  private dancing = false;
  private danceUntil = 0;
  private together = false;
  private hintText = '';
  private message: { text: string; until: number } | null = null;
  private baseHint = '';
  reduced = false;
  private observer: ResizeObserver;
  private demoAlive = false;
  private demoWaits = new Set<() => void>();

  constructor(
    private desk: HTMLElement,
    private canvas: HTMLCanvasElement,
    private events: Events,
  ) {
    this.observer = new ResizeObserver(() => this.measure());
    this.observer.observe(desk);
    this.measure();
  }

  destroy() {
    this.stop();
    this.stopDemo();
    this.observer.disconnect();
    this.tracker = null;
  }

  // ——— Setup ———

  private measure() {
    this.width = this.desk.offsetWidth;
    this.height = this.desk.offsetHeight;
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    this.canvas.width = Math.round(this.width * this.dpr);
    this.canvas.height = Math.round(this.height * this.dpr);
    for (const item of this.movables.values()) this.place(item);
    if (!this.running) this.draw(performance.now());
  }

  setParts(dock: HTMLElement | null, clock: HTMLElement | null, pip: HTMLElement | null) {
    this.dock = dock;
    this.clock = clock;
    this.pip = pip;
  }

  /** A part's box in desk px (its own transforms included). */
  private boxOf(el: HTMLElement | null) {
    if (!el || !el.offsetWidth) return null;
    const desk = this.desk.getBoundingClientRect();
    const rect = el.getBoundingClientRect();
    const scale = desk.width / this.desk.offsetWidth || 1;
    return {
      x: (rect.left - desk.left) / scale,
      y: (rect.top - desk.top) / scale,
      w: rect.width / scale,
      h: rect.height / scale,
    };
  }

  setBaseHint(text: string) {
    this.baseHint = text;
    this.updateHint(performance.now());
  }

  /** A folder or a window joins the desk, at a place given in shares of the room left for it. */
  add(id: string, kind: Kind, label: string, el: HTMLElement, fx: number, fy: number) {
    const known = this.movables.get(id) ?? this.saved.get(id);
    const item: Movable = {
      id,
      kind,
      label,
      el,
      fx: known?.fx ?? fx,
      fy: known?.fy ?? fy,
      x: 0,
      y: 0,
      w: 0,
      h: 0,
      top: 0,
      z: kind === 'window' ? ++this.zTop : 1,
    };
    this.movables.set(id, item);
    this.place(item);
  }

  remove(id: string) {
    const item = this.movables.get(id);
    if (!item) return;
    // A window forgets nothing of where it was: it opens there again.
    this.saved.set(id, { fx: item.fx, fy: item.fy });
    this.movables.delete(id);
    for (const hand of this.hands.values())
      if (hand.grab?.kind === 'movable' && hand.grab.id === id) hand.grab = null;
  }
  private saved = new Map<string, { fx: number; fy: number }>();

  /** On a narrow desk the hint pill has the top row to itself: the folders stay below it. */
  private get inset() {
    return this.width < 460 ? 44 : 0;
  }

  private place(item: Movable) {
    item.w = item.el.offsetWidth;
    item.h = item.el.offsetHeight;
    const part = item.el.querySelector<HTMLElement>('[data-top]');
    item.top = part ? part.offsetHeight : item.h;
    const roomX = Math.max(1, this.width - item.w);
    const roomY = Math.max(1, this.height - item.h - this.inset);
    item.x = clamp(item.fx, 0, 1) * roomX;
    item.y = this.inset + clamp(item.fy, 0, 1) * roomY;
    item.el.style.transform = `translate(${item.x.toFixed(1)}px, ${item.y.toFixed(1)}px)`;
    item.el.style.zIndex = String(item.z);
  }

  private moveTo(item: Movable, x: number, y: number) {
    const roomX = Math.max(1, this.width - item.w);
    const roomY = Math.max(1, this.height - item.h - this.inset);
    item.fx = clamp(x / roomX, 0, 1);
    item.fy = clamp((y - this.inset) / roomY, 0, 1);
    this.place(item);
  }

  /** Keyboard: moves a folder or window by a share of the desk. */
  nudge(id: string, dx: number, dy: number) {
    const item = this.movables.get(id);
    if (!item) return;
    this.moveTo(item, item.x + dx * this.width, item.y + dy * this.height);
    if (!this.running) this.draw(performance.now());
  }

  raise(id: string) {
    const item = this.movables.get(id);
    if (!item) return;
    item.z = ++this.zTop;
    this.place(item);
  }

  /** The middle of a folder's icon, in shares of the desk (for the demo). */
  centreOf(id: string): Point | null {
    const item = this.movables.get(id);
    if (!item) return null;
    return { x: (item.x + item.w / 2) / this.width, y: (item.y + item.top / 2) / this.height };
  }

  // ——— The loop ———

  start() {
    if (this.running) return;
    this.running = true;
    this.last = performance.now();
    const tick = (now: number) => {
      if (!this.running) return;
      this.update(now);
      this.draw(now);
      this.frame = requestAnimationFrame(tick);
    };
    this.frame = requestAnimationFrame(tick);
  }

  stop() {
    this.running = false;
    cancelAnimationFrame(this.frame);
  }

  private update(now: number) {
    const dt = clamp((now - this.last) / 1000, 0, 0.05);
    this.last = now;
    for (const item of this.movables.values()) {
      // Sizes change with the desk (and the window's content); read them each frame, cheaply.
      if (item.el.offsetWidth !== item.w || item.el.offsetHeight !== item.h) this.place(item);
    }
    this.updatePuppets(now, dt);
    this.updateCamera(now);
    for (const hand of this.hands.values()) {
      const fade = hand.present ? 1 : 0;
      hand.alpha += (fade - hand.alpha) * Math.min(1, dt * (hand.present ? 14 : 9));
      if (hand.present) this.read(hand, now);
    }
    for (const [id, hand] of this.hands)
      if (!hand.present && hand.alpha < 0.02) {
        this.release(hand, now);
        this.hands.delete(id);
      }
    this.syncHover();
    this.readTogether(now);
    this.updateCritters(now, dt);
    this.updateHint(now);
  }

  // ——— Virtual hands ———

  /** A virtual hand's size: its palm, in px. */
  private get palmSize() {
    return clamp(Math.min(this.width * 0.1, this.height * 0.16), 36, 112);
  }

  private puppet(id: string, left: boolean) {
    let puppet = this.puppets.get(id);
    if (!puppet) {
      puppet = {
        x: 0,
        y: 0,
        tx: 0,
        ty: 0,
        vx: 0,
        pinch: 0,
        pinchTo: 0,
        point: 0,
        pointTo: 0,
        angle: left ? 0.28 : -0.28,
        left,
        present: false,
        tween: null,
      };
      this.puppets.set(id, puppet);
    }
    return puppet;
  }

  private updatePuppets(now: number, dt: number) {
    for (const [id, puppet] of this.puppets) {
      const fromX = puppet.x;
      if (puppet.tween) {
        const { fx, fy, tx, ty, start, ms } = puppet.tween;
        const t = clamp((now - start) / ms, 0, 1);
        const k = ease(t);
        // A slight arc, as a hand moves.
        const lift = Math.sin(Math.PI * k) * Math.min(40, Math.hypot(tx - fx, ty - fy) * 0.08);
        puppet.x = fx + (tx - fx) * k;
        puppet.y = fy + (ty - fy) * k - lift;
        if (t >= 1) {
          const done = puppet.tween.done;
          puppet.tween = null;
          done();
        }
      } else {
        const k = 1 - Math.exp(-dt * 34);
        puppet.x += (puppet.tx - puppet.x) * k;
        puppet.y += (puppet.ty - puppet.y) * k;
      }
      const speed = dt > 0 ? (puppet.x - fromX) / dt : 0;
      puppet.vx += (speed - puppet.vx) * Math.min(1, dt * 8);
      const rate = Math.min(1, dt * (this.reduced ? 60 : 16));
      puppet.pinch += (puppet.pinchTo - puppet.pinch) * rate;
      puppet.point += (puppet.pointTo - puppet.point) * rate;
      const base = puppet.left ? 0.28 : -0.28;
      const lean = clamp(puppet.vx * 0.00035, -0.3, 0.3);
      puppet.angle += (base + lean - puppet.angle) * Math.min(1, dt * 10);
      const hand = this.handFor(id, id.startsWith('demo') ? 'demo' : 'pointer');
      hand.present = puppet.present;
      if (puppet.present || hand.alpha > 0.02)
        hand.points = poseHand({
          x: puppet.x,
          y: puppet.y,
          pinch: puppet.pinch,
          point: puppet.point,
          angle: puppet.angle,
          size: this.palmSize,
          left: puppet.left,
        });
      if (!puppet.present && hand.alpha <= 0.02 && !puppet.tween) {
        this.puppets.delete(id);
      }
    }
  }

  private handFor(id: string, source: Hand['source']) {
    let hand = this.hands.get(id);
    if (!hand) {
      hand = {
        id,
        source,
        points: [],
        raw: null,
        alpha: 0,
        present: false,
        seenAt: 0,
        pinched: false,
        grab: null,
        hover: null,
        wave: { extreme: NaN, dir: 0, turns: [], at: 0 },
        filters: null,
      };
      this.hands.set(id, hand);
    }
    return hand;
  }

  /** The reader's pointer, in desk px. */
  pointer(
    id: number,
    x: number,
    y: number,
    state: 'move' | 'down' | 'up' | 'leave',
    touch: boolean,
  ) {
    const key = `pointer-${id}`;
    // A second touch is a left hand, so two fingers can be two hands.
    const left =
      touch && [...this.puppets.keys()].some((k) => k.startsWith('pointer') && k !== key);
    const puppet = this.puppet(key, left);
    if (!puppet.present) {
      puppet.x = x;
      puppet.y = y;
    }
    puppet.tx = x;
    puppet.ty = y;
    if (state === 'down') {
      puppet.present = true;
      puppet.pinchTo = 1;
    } else if (state === 'up') {
      puppet.pinchTo = 0;
      if (touch) puppet.present = false;
    } else if (state === 'leave') {
      puppet.pinchTo = 0;
      puppet.present = false;
    } else if (!touch) puppet.present = true;
    if (!this.running) this.draw(performance.now());
  }

  // ——— Camera hands ———

  setTracker(tracker: Tracker | null) {
    this.tracker = tracker;
    this.seenCount = -1;
    if (!tracker)
      for (const hand of this.hands.values()) if (hand.source === 'camera') hand.present = false;
  }

  private updateCamera(now: number) {
    const tracker = this.tracker;
    if (!tracker) return;
    let seen: SeenHand[] | null = null;
    try {
      seen = tracker.detect(now);
    } catch {
      seen = null;
    }
    if (seen) {
      const taken = new Set<string>();
      for (const one of seen) {
        let id = `camera-${one.side}`;
        if (taken.has(id)) id += '-2';
        taken.add(id);
        const hand = this.handFor(id, 'camera');
        hand.present = true;
        hand.seenAt = now;
        hand.raw = one.points;
        if (!hand.filters) hand.filters = one.points.flatMap(() => [new OneEuro(), new OneEuro()]);
        const filters = hand.filters;
        // The middle 80% of the frame spans the whole desk, so a hand reaches its edges.
        hand.points = one.points.map((point, i) => ({
          x: filters[i * 2].filter(((point.x - 0.1) / 0.8) * this.width, now),
          y: filters[i * 2 + 1].filter(((point.y - 0.1) / 0.8) * this.height, now),
        }));
      }
      if (seen.length !== this.seenCount) {
        this.seenCount = seen.length;
        this.events.seen(seen.length);
      }
    }
    for (const hand of this.hands.values())
      if (hand.source === 'camera' && hand.present && now - hand.seenAt > 160) {
        hand.present = false;
        hand.filters = null;
      }
  }

  // ——— Gestures ———

  private read(hand: Hand, now: number) {
    if (hand.points.length < 21) return;
    const at = cursorOf(hand.points);
    const gap = pinchGap(hand.points);
    if (!hand.pinched && gap < PINCH_ON) {
      hand.pinched = true;
      this.pinchStart(hand, at, now);
    } else if (hand.pinched && gap > PINCH_OFF) {
      hand.pinched = false;
      this.release(hand, now);
    }
    if (hand.grab) this.drag(hand, at);
    else this.hover(hand, at);
    this.readWave(hand, now);
  }

  private hit(at: Point): Grab | null {
    for (let i = this.critters.length - 1; i >= 0; i--) {
      const critter = this.critters[i];
      if (critter.state === 'held' || critter.gone) continue;
      const size = this.pixel * 12;
      if (
        Math.abs(at.x - critter.x) < size * 0.55 &&
        at.y > critter.y - size * 1.05 &&
        at.y < critter.y + 4
      )
        return { kind: 'critter', id: critter.id };
    }
    const items = [...this.movables.values()].sort((a, b) => b.z - a.z);
    for (const item of items) {
      const inX = at.x >= item.x && at.x <= item.x + item.w;
      const reach = item.kind === 'window' ? item.top : item.h;
      if (inX && at.y >= item.y && at.y <= item.y + reach)
        return { kind: 'movable', id: item.id, dx: at.x - item.x, dy: at.y - item.y };
      // A window's body is solid: a pinch there grabs nothing and hatches nothing.
      if (inX && item.kind === 'window' && at.y >= item.y && at.y <= item.y + item.h)
        return { kind: 'movable', id: '', dx: 0, dy: 0 };
    }
    return null;
  }

  private pinchStart(hand: Hand, at: Point, now: number) {
    const target = this.hit(at);
    if (target?.kind === 'movable' && !target.id) return;
    if (target?.kind === 'movable') {
      const item = this.movables.get(target.id);
      if (!item) return;
      hand.grab = target;
      item.z = ++this.zTop;
      item.el.dataset.grabbed = 'true';
      this.place(item);
      this.say(`Holding “${item.label}”. Open your fingers to drop it.`, now, 60000);
      this.events.gesture('grab');
      return;
    }
    if (target?.kind === 'critter') {
      const critter = this.critters.find((c) => c.id === target.id);
      if (!critter) return;
      hand.grab = target;
      critter.state = 'held';
      critter.on = null;
      this.say(`Got the ${critter.sprite.name}! Open your fingers to let go.`, now, 60000);
      return;
    }
    this.spawn(at, now);
  }

  private drag(hand: Hand, at: Point) {
    const grab = hand.grab;
    if (!grab) return;
    if (grab.kind === 'movable') {
      const item = this.movables.get(grab.id);
      if (item) this.moveTo(item, at.x - grab.dx, at.y - grab.dy);
    } else {
      const critter = this.critters.find((c) => c.id === grab.id);
      if (critter) {
        critter.x = at.x;
        critter.y = at.y + this.pixel * 7;
      }
    }
  }

  private release(hand: Hand, now: number) {
    const grab = hand.grab;
    hand.grab = null;
    hand.hover = null;
    if (!grab) {
      if (this.message?.text.startsWith('Pinched')) this.message.until = now + 900;
      return;
    }
    if (grab.kind === 'movable') {
      const item = this.movables.get(grab.id);
      if (!item) return;
      delete item.el.dataset.grabbed;
      this.say(`Dropped “${item.label}”.`, now, 1400);
      this.events.gesture('drop');
      this.events.announce(`Moved ${item.label}.`);
    } else {
      const critter = this.critters.find((c) => c.id === grab.id);
      if (!critter) return;
      critter.state = 'fall';
      critter.vy = 0;
      this.say(`Wheee! The ${critter.sprite.name} drops.`, now, 1400);
    }
  }

  private hover(hand: Hand, at: Point) {
    const target = this.hit(at);
    const id = target?.kind === 'movable' && target.id ? target.id : null;
    hand.hover = id;
  }

  /** Marks what any hand hovers (or holds), once all the hands are read. */
  private syncHover() {
    const hovered = new Set<string>();
    for (const hand of this.hands.values()) {
      if (!hand.present) continue;
      if (hand.hover) hovered.add(hand.hover);
      if (hand.grab?.kind === 'movable') hovered.add(hand.grab.id);
    }
    for (const item of this.movables.values()) {
      const on = hovered.has(item.id);
      if (on && !item.el.dataset.hover) item.el.dataset.hover = 'true';
      else if (!on && item.el.dataset.hover) delete item.el.dataset.hover;
    }
  }

  private readWave(hand: Hand, now: number) {
    const wave = hand.wave;
    if (hand.pinched || hand.grab || !isOpen(hand.points)) {
      wave.turns = [];
      wave.extreme = NaN;
      wave.dir = 0;
      return;
    }
    const x = hand.points[WRIST].x;
    const swing = palm(hand.points) * 0.35;
    if (Number.isNaN(wave.extreme)) {
      wave.extreme = x;
      return;
    }
    const moved = x - wave.extreme;
    if (wave.dir === 0) {
      if (Math.abs(moved) > swing) {
        wave.dir = Math.sign(moved);
        wave.extreme = x;
      }
      return;
    }
    if (Math.sign(moved) === wave.dir) wave.extreme = x;
    else if (Math.abs(moved) > swing) {
      wave.dir = -wave.dir;
      wave.extreme = x;
      wave.turns = [...wave.turns.filter((t) => now - t < 1300), now];
      if (wave.turns.length >= 3 && now - wave.at > 1800) {
        wave.at = now;
        wave.turns = [];
        this.wave(now);
      }
    }
  }

  private readTogether(now: number) {
    const present = [...this.hands.values()].filter((h) => h.present && h.points.length === 21);
    let best = Infinity;
    for (let i = 0; i < present.length; i++)
      for (let j = i + 1; j < present.length; j++)
        best = Math.min(best, tipsGap(present[i].points, present[j].points));
    if (!this.together && best < TOGETHER_ON) {
      this.together = true;
      this.dance(now);
    } else if (this.together && best > TOGETHER_OFF) this.together = false;
    if (this.together) this.danceUntil = Math.max(this.danceUntil, now + 1200);
    if (this.dancing && now > this.danceUntil) {
      this.dancing = false;
      this.events.dance(false);
    }
  }

  // ——— Actions (also the buttons') ———

  private get pixel() {
    return Math.max(2, Math.round(Math.min(this.width / 300, this.height / 200)));
  }

  spawn(at?: Point, now = performance.now()) {
    const point = at ?? {
      x: this.width * (0.2 + Math.random() * 0.6),
      y: this.height * (0.12 + Math.random() * 0.3),
    };
    const alive = this.critters.filter((c) => !c.gone);
    if (alive.length >= MAX_CRITTERS) this.poof(alive[0], now);
    const sprite = CRITTERS[Math.floor(Math.random() * CRITTERS.length)];
    const critter: Critter = {
      id: this.nextCritter++,
      sprite,
      x: point.x,
      y: point.y + this.pixel * 6,
      vy: 0,
      dir: Math.random() < 0.5 ? 1 : -1,
      on: null,
      along: 0,
      state: 'pop',
      timer: 0,
      born: now,
      step: 0,
      hop: 0,
      hopFrom: 0,
      heart: 0,
      gone: 0,
    };
    this.critters.push(critter);
    this.burst(point.x, point.y, ['#ffffff', '#ffe27a', '#9fe0ff'], 10);
    if (at) this.say('Pinched! Open your fingers to hatch another.', now, 60000);
    this.events.announce(`A ${sprite.name} hatched.`);
    this.events.gesture('hatch');
    if (!this.running) this.draw(now);
  }

  /** Puts `count` critters on the dock, quietly, as if they had always been there. */
  seed(count: number) {
    const dock = this.dock;
    if (!dock || !dock.offsetWidth) return;
    for (let i = 0; i < count; i++) {
      const sprite = CRITTERS[(i * 2 + 1) % CRITTERS.length];
      const box = this.boxOf(dock);
      if (!box) return;
      const x = box.x + box.w * (0.18 + (0.64 * i) / Math.max(1, count - 1));
      this.critters.push({
        id: this.nextCritter++,
        sprite,
        x,
        y: box.y + 0.5,
        vy: 0,
        dir: i % 2 ? -1 : 1,
        on: null,
        along: 0,
        state: 'fall',
        timer: 0,
        born: 0,
        step: 0,
        hop: 0,
        hopFrom: 0,
        heart: 0,
        gone: 0,
      });
    }
    if (!this.running) {
      this.updateCritters(performance.now(), 0.016);
      this.draw(performance.now());
    }
  }

  clear(now = performance.now()) {
    const alive = this.critters.filter((c) => !c.gone);
    alive.forEach((critter) => this.poof(critter, now));
    if (alive.length) this.events.announce('The critters went home.');
  }

  wave(now = performance.now()) {
    const alive = this.critters.filter((c) => !c.gone && c.state !== 'held');
    alive.forEach((critter, i) => {
      critter.hop = now + i * 90;
      critter.hopFrom = 0;
      critter.heart = now + i * 90;
    });
    this.say(
      alive.length ? 'Hello! The critters wave back.' : 'Hello! Pinch to hatch someone to wave at.',
      now,
      1800,
    );
    this.events.announce(alive.length ? 'You waved; the critters hop back.' : 'You waved.');
    this.events.gesture('wave');
  }

  dance(now = performance.now()) {
    if (!this.dancing) {
      this.dancing = true;
      this.events.dance(true);
      this.events.announce('Both hands together: dance party.');
      this.events.gesture('dance');
    }
    this.danceUntil = Math.max(this.danceUntil, now + 3200);
    this.say('Both hands together: dance party!', now, 3200);
  }

  private poof(critter: Critter, now: number) {
    critter.gone = now;
    const size = this.pixel * 12;
    this.burst(
      critter.x,
      critter.y - size / 2,
      Object.values(critter.sprite.palette).slice(1, 4),
      12,
    );
  }

  private burst(x: number, y: number, colours: string[], count: number) {
    if (this.reduced) return;
    for (let i = 0; i < count; i++) {
      const angle = (i / count) * Math.PI * 2 + Math.random() * 0.4;
      const speed = 60 + Math.random() * 80;
      this.particles.push({
        x,
        y,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 30,
        life: 1,
        colour: colours[i % colours.length],
      });
    }
  }

  private say(text: string, now: number, ms: number) {
    this.message = { text, until: now + ms };
    this.updateHint(now);
  }

  private updateHint(now: number) {
    if (this.message && now > this.message.until) this.message = null;
    const text = this.message?.text ?? this.baseHint;
    if (text !== this.hintText) {
      this.hintText = text;
      this.events.hint(text);
    }
  }

  // ——— Critters ———

  private surfaces(): Surface[] {
    const list: Surface[] = [];
    for (const item of this.movables.values()) {
      if (item.el.dataset.grabbed && item.kind === 'window') continue;
      const inset = item.kind === 'folder' ? item.w * 0.18 : 10;
      list.push({
        id: item.id,
        x0: item.x + inset,
        x1: item.x + item.w - inset,
        y: item.y + (item.kind === 'folder' ? item.top * 0.08 : 0),
        dx: item.x,
        dy: item.y,
      });
    }
    const add = (el: HTMLElement | null, id: string, inset: number) => {
      const box = this.boxOf(el);
      if (box)
        list.push({
          id,
          x0: box.x + inset,
          x1: box.x + box.w - inset,
          y: box.y,
          dx: box.x,
          dy: box.y,
        });
      return box;
    };
    add(this.clock, 'clock', 14);
    const dock = add(this.dock, 'dock', 12);
    // The ground either side of the dock (critters never walk behind it).
    const left = dock ? dock.x - 4 : this.width / 2;
    const right = dock ? dock.x + dock.w + 4 : this.width / 2;
    const y = this.height - 3;
    list.push({ id: 'ground-left', x0: 6, x1: Math.max(6, left), y, dx: 0, dy: 0 });
    list.push({
      id: 'ground-right',
      x0: Math.min(this.width - 6, right),
      x1: this.width - 6,
      y,
      dx: 0,
      dy: 0,
    });
    return list;
  }

  private updateCritters(now: number, dt: number) {
    const surfaces = this.surfaces();
    const byId = new Map(surfaces.map((s) => [s.id, s]));
    const gravity = 1500 * (this.height / 600);
    const speed = this.reduced ? 0 : 26 * (this.width / 960) + 10;
    const size = this.pixel * 12;
    for (const critter of this.critters) {
      if (critter.gone) continue;
      if (critter.state === 'held') continue;
      if (critter.state === 'pop') {
        // It pops in, flutters where it hatched a moment, then drops.
        if (this.reduced || now - critter.born > 820) critter.state = 'fall';
        else continue;
      }
      if (critter.state === 'fall') {
        const before = critter.y;
        critter.vy += gravity * dt;
        critter.y += this.reduced ? this.height : critter.vy * dt;
        // The first surface crossed on the way down catches it.
        let landing: Surface | null = null;
        for (const surface of surfaces) {
          if (critter.x < surface.x0 || critter.x > surface.x1) continue;
          if (
            surface.y >= before - 1 &&
            surface.y <= critter.y &&
            (!landing || surface.y < landing.y)
          )
            landing = surface;
        }
        if (landing) {
          critter.y = landing.y;
          critter.vy = 0;
          critter.on = landing.id;
          critter.along = critter.x - landing.dx;
          critter.state =
            landing.id === 'dock' || landing.id.startsWith('ground') ? 'walk' : 'idle';
          critter.timer = now + 600 + Math.random() * 1600;
          critter.hop = this.reduced ? 0 : now;
          critter.hopFrom = -1;
        } else if (critter.y > this.height + size) {
          critter.gone = now;
        }
        continue;
      }
      const surface = critter.on ? byId.get(critter.on) : undefined;
      if (!surface) {
        critter.state = 'fall';
        critter.on = null;
        continue;
      }
      // It rides along with what it stands on.
      critter.x = surface.dx + critter.along;
      critter.y = surface.y;
      const dancing = this.dancing && !this.reduced;
      if (dancing) {
        if (now - critter.hop > 430) {
          critter.hop = now;
          critter.hopFrom = 0;
          critter.dir = critter.dir === 1 ? -1 : 1;
        }
      } else if (critter.state === 'walk') {
        critter.x += critter.dir * speed * dt;
        critter.step += speed * dt;
        if (critter.x > surface.x1 || critter.x < surface.x0) {
          const offEdge = surface.id === 'dock' && Math.random() < 0.35;
          if (offEdge) {
            critter.state = 'fall';
            critter.on = null;
            // Off the end, clear of the dock, down to the grass.
            critter.x =
              critter.dir === 1 ? surface.x1 + 12 + size * 0.6 : surface.x0 - 12 - size * 0.6;
            continue;
          }
          critter.x = clamp(critter.x, surface.x0, surface.x1);
          critter.dir = critter.dir === 1 ? -1 : 1;
        }
        if (now > critter.timer) {
          critter.state = 'idle';
          critter.timer = now + 900 + Math.random() * 1800;
        }
      } else if (now > critter.timer) {
        critter.state = speed ? 'walk' : 'idle';
        if (Math.random() < 0.4) critter.dir = critter.dir === 1 ? -1 : 1;
        critter.timer = now + 1500 + Math.random() * 3500;
      }
      critter.along = critter.x - surface.dx;
    }
    this.critters = this.critters.filter((c) => !c.gone);
    for (const particle of this.particles) {
      particle.x += particle.vx * dt;
      particle.y += particle.vy * dt;
      particle.vy += 260 * dt;
      particle.life -= dt * 1.8;
    }
    this.particles = this.particles.filter((p) => p.life > 0);
  }

  // ——— Drawing ———

  private draw(now: number) {
    const context = this.canvas.getContext('2d');
    if (!context) return;
    const { dpr } = this;
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    context.imageSmoothingEnabled = false;
    const pixel = this.pixel;
    const device = Math.max(1, Math.round(pixel * dpr));
    for (const critter of this.critters) {
      if (critter.gone) continue;
      let lift = 0;
      let scale = 1;
      if (critter.state === 'pop') {
        const t = clamp((now - critter.born) / 260, 0, 1);
        scale = t < 0.7 ? (t / 0.7) * 1.15 : 1.15 - ((t - 0.7) / 0.3) * 0.15;
        if (!this.reduced) lift = Math.sin(((now - critter.born) / 820) * Math.PI) * pixel * 2.5;
      }
      if (!this.reduced && critter.hop && critter.state !== 'fall') {
        const t = (now - critter.hop) / 380;
        if (t >= 0 && t <= 1)
          lift = Math.sin(Math.PI * t) * pixel * (critter.hopFrom === -1 ? 1.5 : 5);
      }
      if (critter.state === 'held') lift = Math.sin(now / 90) * pixel * 0.6;
      const walking =
        critter.state === 'walk' || critter.state === 'fall' || critter.state === 'held';
      const frame = walking ? Math.floor(critter.step / (pixel * 3)) % 2 : 0;
      const image = stamp(
        critter.sprite,
        critter.state === 'held' ? Math.floor(now / 120) : frame,
        device,
      );
      const w = (image.width / dpr) * scale;
      const h = (image.height / dpr) * scale;
      const x = Math.round((critter.x - w / 2) * dpr);
      const y = Math.round((critter.y - h - lift) * dpr);
      context.save();
      if (critter.dir === -1) {
        context.translate(x * 2 + Math.round(w * dpr), 0);
        context.scale(-1, 1);
      }
      context.drawImage(image, x, y, Math.round(w * dpr), Math.round(h * dpr));
      context.restore();
      // A pixel heart over a critter that was waved at.
      if (critter.heart && now - critter.heart < 1100 && now > critter.heart) {
        const t = (now - critter.heart) / 1100;
        context.globalAlpha = 1 - t;
        this.heart(
          context,
          critter.x * dpr,
          (critter.y - h - pixel * 3 - t * pixel * 6) * dpr,
          device / 2,
        );
        context.globalAlpha = 1;
      }
    }
    for (const particle of this.particles) {
      context.globalAlpha = Math.max(0, particle.life);
      context.fillStyle = particle.colour;
      const s = device;
      context.fillRect(
        Math.round(particle.x * dpr - s / 2),
        Math.round(particle.y * dpr - s / 2),
        s,
        s,
      );
    }
    context.globalAlpha = 1;
    for (const hand of this.hands.values()) {
      if (hand.alpha < 0.02 || hand.points.length < 21) continue;
      this.skeleton(context, hand.points, hand.alpha, palm(hand.points), hand.pinched);
    }
    // The camera's preview gets its own small skeletons, over the reader's own picture.
    if (this.tracker && this.pip && this.pip.offsetWidth) {
      const box = {
        x: this.pip.offsetLeft,
        y: this.pip.offsetTop,
        w: this.pip.offsetWidth,
        h: this.pip.offsetHeight,
      };
      const video = this.tracker.video;
      const ratio =
        video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3;
      // The video covers the box: work out the part of it that shows.
      const coverW = Math.max(box.w, box.h * ratio);
      const coverH = coverW / ratio;
      const ox = box.x + (box.w - coverW) / 2;
      const oy = box.y + (box.h - coverH) / 2;
      context.save();
      context.beginPath();
      context.rect(box.x * dpr, box.y * dpr, box.w * dpr, box.h * dpr);
      context.clip();
      for (const hand of this.hands.values()) {
        if (hand.source !== 'camera' || !hand.raw || hand.alpha < 0.02) continue;
        const points = hand.raw.map((p) => ({ x: ox + p.x * coverW, y: oy + p.y * coverH }));
        this.skeleton(context, points, hand.alpha, palm(points), false);
      }
      context.restore();
    }
  }

  private skeleton(
    context: CanvasRenderingContext2D,
    points: Landmarks,
    alpha: number,
    size: number,
    pinched: boolean,
  ) {
    const { dpr } = this;
    const line = clamp(size * 0.026, 1.2, 3) * dpr;
    const dot = clamp(size * 0.04, 1.6, 4) * dpr;
    context.save();
    context.globalAlpha = alpha * 0.95;
    context.strokeStyle = '#ffffff';
    context.fillStyle = '#ffffff';
    context.lineWidth = line;
    context.lineCap = 'round';
    context.shadowColor = 'rgba(12, 28, 70, 0.35)';
    context.shadowBlur = 4 * dpr;
    context.beginPath();
    for (const [a, b] of BONES) {
      context.moveTo(points[a].x * dpr, points[a].y * dpr);
      context.lineTo(points[b].x * dpr, points[b].y * dpr);
    }
    context.stroke();
    context.beginPath();
    for (const point of points) {
      context.moveTo(point.x * dpr + dot, point.y * dpr);
      context.arc(point.x * dpr, point.y * dpr, dot, 0, Math.PI * 2);
    }
    context.fill();
    if (pinched) {
      // A ring where the pinch holds.
      const at = cursorOf(points);
      context.shadowBlur = 0;
      context.globalAlpha = alpha * 0.9;
      context.lineWidth = line;
      context.strokeStyle = '#ffffff';
      context.beginPath();
      context.arc(at.x * dpr, at.y * dpr, dot * 3.2, 0, Math.PI * 2);
      context.stroke();
    }
    context.restore();
  }

  private heart(context: CanvasRenderingContext2D, x: number, y: number, s: number) {
    const shape = ['.k.k.', 'kkkkk', 'kkkkk', '.kkk.', '..k..'];
    context.fillStyle = '#ff5f8f';
    shape.forEach((row, j) =>
      [...row].forEach((c, i) => {
        if (c === 'k')
          context.fillRect(
            Math.round(x + (i - 2.5) * s),
            Math.round(y + j * s),
            Math.ceil(s),
            Math.ceil(s),
          );
      }),
    );
  }

  // ——— The demo ———

  /** Plays the demo with scripted hands until stopDemo(). */
  playDemo(folder: string) {
    if (this.demoAlive) return;
    this.demoAlive = true;
    const W = () => this.width;
    const H = () => this.height;
    const alive = () => {
      if (!this.demoAlive) throw new Error('stopped');
    };
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        const cancel = () => {
          window.clearTimeout(timer);
          reject(new Error('stopped'));
        };
        const timer = window.setTimeout(() => {
          this.demoWaits.delete(cancel);
          resolve();
        }, ms);
        this.demoWaits.add(cancel);
      });
    const glide = (puppet: Puppet, x: number, y: number, ms: number) =>
      new Promise<void>((resolve, reject) => {
        alive();
        const cancel = () => reject(new Error('stopped'));
        this.demoWaits.add(cancel);
        puppet.tween = {
          fx: puppet.x,
          fy: puppet.y,
          tx: x * W(),
          ty: y * H(),
          start: performance.now(),
          ms,
          done: () => {
            this.demoWaits.delete(cancel);
            puppet.tx = puppet.x;
            puppet.ty = puppet.y;
            resolve();
          },
        };
      });
    const enter = (id: string, left: boolean, x: number, y: number) => {
      const puppet = this.puppet(id, left);
      puppet.x = puppet.tx = x * W();
      puppet.y = puppet.ty = y * H();
      puppet.present = true;
      puppet.pinch = puppet.pinchTo = 0;
      puppet.point = puppet.pointTo = 0;
      return puppet;
    };
    const tap = async (puppet: Puppet) => {
      puppet.pinchTo = 1;
      await wait(340);
      puppet.pinchTo = 0;
      await wait(320);
    };
    const run = async () => {
      let away = false;
      for (let round = 0; ; round++) {
        alive();
        const a = enter('demo-a', false, 0.9, 1.15);
        const home = this.centreOf(folder);
        if (home) {
          await glide(a, home.x, home.y, 1000);
          await wait(260);
          a.pinchTo = 1;
          await wait(320);
          const to = away ? { x: 0.12, y: 0.42 } : { x: 0.38, y: 0.3 };
          away = !away;
          await glide(a, to.x, to.y, 1200);
          await wait(160);
          a.pinchTo = 0;
          await wait(520);
        }
        await glide(a, 0.47, 0.17, 700);
        await tap(a);
        await glide(a, 0.56, 0.3, 600);
        await tap(a);
        await glide(a, 0.3 + (round % 2) * 0.08, 0.6, 700);
        await tap(a);
        await wait(500);
        // A wave: the open hand swings left and right.
        for (let i = 0; i < 5; i++) await glide(a, 0.3 + (i % 2 ? 0.07 : -0.01), 0.58, 210);
        await wait(1100);
        const b = enter('demo-b', true, -0.12, 0.95);
        a.pointTo = 1;
        b.pointTo = 1;
        await Promise.all([glide(a, 0.56, 0.5, 900), glide(b, 0.44, 0.5, 900)]);
        await Promise.all([glide(a, 0.503, 0.5, 520), glide(b, 0.497, 0.5, 520)]);
        await wait(2600);
        await Promise.all([glide(a, 1.12, 0.85, 900), glide(b, -0.12, 0.85, 900)]);
        a.present = false;
        b.present = false;
        a.pointTo = b.pointTo = 0;
        await wait(1600);
      }
    };
    run().catch(() => {});
  }

  /**
   * A still virtual hand, held at a place in shares of the desk (or on a folder's icon), open,
   * pinching or pointing, until the desk goes: for the docs' locked states. It is read like any
   * hand, so a pinch on a folder grabs it, on the sky hatches, and two touching index tips dance.
   */
  hold(
    id: string,
    left: boolean,
    at: Point | string,
    pose: { pinch?: number; point?: number } = {},
  ) {
    const spot = typeof at === 'string' ? this.centreOf(at) : at;
    if (!spot) return;
    const puppet = this.puppet(`still-${id}`, left);
    puppet.x = puppet.tx = spot.x * this.width;
    puppet.y = puppet.ty = spot.y * this.height;
    puppet.pinch = puppet.pinchTo = pose.pinch ?? 0;
    puppet.point = puppet.pointTo = pose.point ?? 0;
    puppet.present = true;
    if (!this.running) this.draw(performance.now());
  }

  stopDemo() {
    if (!this.demoAlive) return;
    this.demoAlive = false;
    for (const cancel of this.demoWaits) cancel();
    this.demoWaits.clear();
    for (const [id, puppet] of this.puppets)
      if (id.startsWith('demo')) {
        puppet.tween = null;
        puppet.present = false;
        puppet.pinchTo = 0;
        puppet.pointTo = 0;
      }
  }
}
```

### hand-desktop/hand-model.ts

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

/*
 * The hand, as 21 points: the wrist, then four joints for each of the thumb, index, middle, ring
 * and little finger, from the knuckle to the tip. A camera tracker gives these points; without
 * a camera, a virtual hand makes them from three poses (open, pinching, pointing) blended
 * together, so the gestures below read both the same way.
 *
 * Every measure is divided by the palm's length (wrist to the middle finger's knuckle), so a hand
 * near the camera and one far away read the same.
 */

export type Point = { x: number; y: number };
export type Landmarks = Point[];

/** Index of each point, by name. */
export const WRIST = 0;
export const THUMB_TIP = 4;
export const INDEX_PIP = 6;
export const INDEX_TIP = 8;
export const MIDDLE_MCP = 9;

/** The bones the skeleton draws, as pairs of points. */
export const BONES: [number, number][] = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 4],
  [0, 5],
  [5, 6],
  [6, 7],
  [7, 8],
  [5, 9],
  [9, 10],
  [10, 11],
  [11, 12],
  [9, 13],
  [13, 14],
  [14, 15],
  [15, 16],
  [13, 17],
  [0, 17],
  [17, 18],
  [18, 19],
  [19, 20],
];

const p = (x: number, y: number): Point => ({ x, y });

/** A right hand, palm to the viewer, fingers up, in palm lengths from the wrist. */
const OPEN: Landmarks = [
  p(0, 0),
  p(-0.3, -0.12),
  p(-0.55, -0.33),
  p(-0.72, -0.55),
  p(-0.86, -0.74),
  p(-0.3, -0.92),
  p(-0.36, -1.3),
  p(-0.39, -1.54),
  p(-0.42, -1.76),
  p(-0.04, -1),
  p(-0.05, -1.42),
  p(-0.06, -1.68),
  p(-0.07, -1.92),
  p(0.2, -0.94),
  p(0.24, -1.32),
  p(0.27, -1.56),
  p(0.29, -1.76),
  p(0.4, -0.8),
  p(0.5, -1.08),
  p(0.56, -1.26),
  p(0.61, -1.43),
];

/** Thumb and index tips together; the other fingers relax a little. */
const PINCH: Landmarks = [
  p(0, 0),
  p(-0.28, -0.14),
  p(-0.5, -0.4),
  p(-0.6, -0.72),
  p(-0.62, -1.04),
  p(-0.3, -0.92),
  p(-0.42, -1.27),
  p(-0.56, -1.3),
  p(-0.63, -1.1),
  p(-0.04, -1),
  p(-0.06, -1.4),
  p(-0.1, -1.63),
  p(-0.13, -1.83),
  p(0.2, -0.94),
  p(0.25, -1.3),
  p(0.28, -1.51),
  p(0.3, -1.69),
  p(0.4, -0.8),
  p(0.51, -1.05),
  p(0.57, -1.21),
  p(0.62, -1.36),
];

/** The index finger out, the others curled into the palm. */
const POINT: Landmarks = [
  p(0, 0),
  p(-0.3, -0.14),
  p(-0.45, -0.4),
  p(-0.38, -0.64),
  p(-0.2, -0.74),
  p(-0.3, -0.92),
  p(-0.36, -1.3),
  p(-0.39, -1.54),
  p(-0.42, -1.76),
  p(-0.04, -1),
  p(-0.05, -1.3),
  p(0, -1.06),
  p(0.02, -0.88),
  p(0.2, -0.94),
  p(0.22, -1.18),
  p(0.24, -0.98),
  p(0.24, -0.82),
  p(0.4, -0.8),
  p(0.46, -1),
  p(0.46, -0.86),
  p(0.44, -0.73),
];

export type HandPose = {
  /** Where the hand works (between thumb and index tips, or the index tip when pointing), in px. */
  x: number;
  y: number;
  /** 0 open … 1 pinched. */
  pinch: number;
  /** 0 … 1 pointing with the index finger. */
  point: number;
  /** Tilt in radians, positive clockwise. */
  angle: number;
  /** Palm length in px. */
  size: number;
  /** A left hand is a mirrored right one. */
  left: boolean;
};

const mix = (a: number, b: number, t: number) => a + (b - a) * t;

/** The 21 points of a virtual hand in the given pose, in px. */
export function poseHand(pose: HandPose): Landmarks {
  const { pinch, point, angle, size, left } = pose;
  const local = OPEN.map((open, i) => {
    const x = mix(mix(open.x, PINCH[i].x, pinch), POINT[i].x, point);
    const y = mix(mix(open.y, PINCH[i].y, pinch), POINT[i].y, point);
    return p(x, y);
  });
  // The point that sits under the pointer: the pinch, sliding to the index tip when pointing.
  // The point under the pointer: between the thumb and index tips (where a pinch closes), or the
  // index tip when pointing.
  const between = cursorOf(local);
  const anchor = p(
    mix(between.x, local[INDEX_TIP].x, point),
    mix(between.y, local[INDEX_TIP].y, point),
  );
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  const flip = left ? -1 : 1;
  return local.map((at) => {
    const x = (at.x - anchor.x) * flip;
    const y = at.y - anchor.y;
    return p(pose.x + (x * cos - y * sin) * size, pose.y + (x * sin + y * cos) * size);
  });
}

const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);

/** The palm's length in px, the unit of every other measure. */
export const palm = (hand: Landmarks) => distance(hand[WRIST], hand[MIDDLE_MCP]) || 1;

/** Where a hand works: between the thumb and index tips. */
export const cursorOf = (hand: Landmarks): Point =>
  p((hand[THUMB_TIP].x + hand[INDEX_TIP].x) / 2, (hand[THUMB_TIP].y + hand[INDEX_TIP].y) / 2);

/** Thumb tip to index tip, in palms. */
export const pinchGap = (hand: Landmarks) =>
  distance(hand[THUMB_TIP], hand[INDEX_TIP]) / palm(hand);

/** A pinch closes under PINCH_ON and opens again over PINCH_OFF, so jitter never fires twice. */
export const PINCH_ON = 0.28;
export const PINCH_OFF = 0.45;

/** All four fingers stretched out: each tip farther from the wrist than its middle joint. */
export function isOpen(hand: Landmarks) {
  const wrist = hand[WRIST];
  return [8, 12, 16, 20].every(
    (tip) => distance(hand[tip], wrist) > distance(hand[tip - 2], wrist) * 1.08,
  );
}

/** Two hands touch index tips: within half a palm, and apart again past a whole one. */
export const TOGETHER_ON = 0.5;
export const TOGETHER_OFF = 1;
export const tipsGap = (a: Landmarks, b: Landmarks) =>
  distance(a[INDEX_TIP], b[INDEX_TIP]) / ((palm(a) + palm(b)) / 2);

/**
 * The One Euro filter: a low-pass filter whose cutoff rises with speed, so a still hand stops
 * shaking and a fast one does not lag behind.
 */
export class OneEuro {
  private x = NaN;
  private dx = 0;
  private last = 0;
  constructor(
    private minCutoff = 1.2,
    private beta = 0.02,
    private dCutoff = 1,
  ) {}
  private static alpha(cutoff: number, dt: number) {
    const tau = 1 / (2 * Math.PI * cutoff);
    return 1 / (1 + tau / dt);
  }
  filter(value: number, now: number) {
    if (Number.isNaN(this.x)) {
      this.x = value;
      this.last = now;
      return value;
    }
    const dt = Math.max(1e-3, (now - this.last) / 1000);
    this.last = now;
    const dx = (value - this.x) / dt;
    this.dx += OneEuro.alpha(this.dCutoff, dt) * (dx - this.dx);
    const cutoff = this.minCutoff + this.beta * Math.abs(this.dx);
    this.x += OneEuro.alpha(cutoff, dt) * (value - this.x);
    return this.x;
  }
}
```

### hand-desktop/tracker.ts

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

/*
 * The camera and the hand tracker. Nothing here runs until the reader asks for the camera:
 * the tracker's module, its WebAssembly runtime and its model are fetched then, at runtime, from
 * the addresses given (a CDN by default; a site can host the three files itself), never bundled.
 *
 * Frames stay in the browser: the model runs locally, and only its 21 points per hand come out.
 * Nothing is recorded, kept or sent. stop() ends every camera track.
 */

import type { Landmarks } from './hand-model';

export type TrackerSources = {
  /** The tracker's ES module (it exports FilesetResolver and HandLandmarker). */
  module: string;
  /** The folder of its WebAssembly runtime. */
  wasm: string;
  /** The hand landmark model file. */
  model: string;
};

export const DEFAULT_SOURCES: TrackerSources = {
  module: 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/vision_bundle.mjs',
  wasm: 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm',
  model:
    'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
};

/** A hand the camera sees: 21 points in shares of the frame (mirrored), and which hand it is. */
export type SeenHand = { points: Landmarks; side: string };

export type Tracker = {
  video: HTMLVideoElement;
  /** The hands in the newest frame, or null when no new frame has come since the last call. */
  detect: (now: number) => SeenHand[] | null;
  stop: () => void;
};

/** The few parts of the tracker's module used here. */
type Landmarker = {
  detectForVideo: (
    video: HTMLVideoElement,
    now: number,
  ) => {
    landmarks: { x: number; y: number }[][];
    handednesses?: { categoryName: string }[][];
    handedness?: { categoryName: string }[][];
  };
  close: () => void;
};
type VisionModule = {
  FilesetResolver: { forVisionTasks: (base: string) => Promise<unknown> };
  HandLandmarker: {
    createFromOptions: (fileset: unknown, options: Record<string, unknown>) => Promise<Landmarker>;
  };
};

export class TrackerError extends Error {
  constructor(
    public kind: 'unsupported' | 'denied' | 'missing' | 'load',
    message: string,
  ) {
    super(message);
  }
}

/**
 * Loads the tracker, then asks for the camera. `phase` reports progress: 'loading' while the
 * files come, 'asking' while the browser asks the reader for the camera.
 */
export async function startTracker(
  sources: TrackerSources,
  phase: (name: 'loading' | 'asking') => void,
  signal: AbortSignal,
): Promise<Tracker> {
  if (!navigator.mediaDevices?.getUserMedia)
    throw new TrackerError('unsupported', 'This browser has no camera access here.');

  phase('loading');
  let landmarker: Landmarker;
  try {
    const vision = (await import(/* webpackIgnore: true */ sources.module)) as VisionModule;
    const fileset = await vision.FilesetResolver.forVisionTasks(sources.wasm);
    const options = (delegate: 'GPU' | 'CPU') => ({
      baseOptions: { modelAssetPath: sources.model, delegate },
      runningMode: 'VIDEO',
      numHands: 2,
      minHandDetectionConfidence: 0.5,
      minHandPresenceConfidence: 0.5,
      minTrackingConfidence: 0.5,
    });
    try {
      landmarker = await vision.HandLandmarker.createFromOptions(fileset, options('GPU'));
    } catch {
      landmarker = await vision.HandLandmarker.createFromOptions(fileset, options('CPU'));
    }
  } catch (error) {
    throw new TrackerError('load', `The hand model could not load (${String(error)}).`);
  }
  if (signal.aborted) {
    landmarker.close();
    throw new DOMException('Stopped', 'AbortError');
  }

  phase('asking');
  let stream: MediaStream;
  try {
    stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
      audio: false,
    });
  } catch (error) {
    landmarker.close();
    const name = (error as DOMException)?.name;
    if (name === 'NotAllowedError' || name === 'SecurityError')
      throw new TrackerError('denied', 'Camera permission was denied.');
    throw new TrackerError('missing', 'No camera was found.');
  }
  if (signal.aborted) {
    stream.getTracks().forEach((track) => track.stop());
    landmarker.close();
    throw new DOMException('Stopped', 'AbortError');
  }

  const video = document.createElement('video');
  video.muted = true;
  video.playsInline = true;
  video.setAttribute('playsinline', '');
  video.srcObject = stream;
  await video.play();

  let lastTime = -1;
  let stopped = false;
  return {
    video,
    detect(now) {
      if (stopped || video.readyState < 2 || video.currentTime === lastTime) return null;
      lastTime = video.currentTime;
      const result = landmarker.detectForVideo(video, now);
      const sides = result.handednesses ?? result.handedness ?? [];
      return result.landmarks.map((points, i) => ({
        // Mirrored, as a selfie view is.
        points: points.map(({ x, y }) => ({ x: 1 - x, y })),
        side: sides[i]?.[0]?.categoryName ?? `hand${i}`,
      }));
    },
    stop() {
      if (stopped) return;
      stopped = true;
      stream.getTracks().forEach((track) => track.stop());
      video.srcObject = null;
      landmarker.close();
    },
  };
}
```

### hand-desktop/critters.ts

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

/*
 * The critters: six pixel animals, 12 × 12, facing right, each with two walking frames (only the
 * feet change). A sprite is a list of rows, one letter per pixel ('.' is see-through), and a
 * palette for its letters. They are drawn once per pixel size into small canvases, then stamped.
 */

export type Sprite = {
  name: string;
  palette: Record<string, string>;
  /** Rows of the body, then the feet of each walking frame. */
  body: string[];
  feet: [string, string];
};

const INK = '#1b1b1f';

export const CRITTERS: Sprite[] = [
  {
    name: 'chick',
    palette: { k: '#4a3412', y: '#ffd84a', Y: '#efb21c', w: '#fff6c2', e: INK, o: '#ff8a2a' },
    body: [
      '............',
      '....kkkk....',
      '...kyyyyk...',
      '..kywyyyyk..',
      '..kyyyyeyk..',
      '.kyyyyyyyook',
      '.kYyyyyyyyk.',
      'kYyyyyyyyyk.',
      'kYYyywwyyyk.',
      '.kYYYYYYYk..',
      '..kkkkkkk...',
    ],
    feet: ['...o...o....', '....o.o.....'],
  },
  {
    name: 'frog',
    palette: { k: '#173a14', g: '#64d24c', G: '#3c9a33', w: '#ffffff', e: INK, p: '#ff9fb0' },
    body: [
      '............',
      '.kkk...kkk..',
      'kwwek.kwwek.',
      'kwwekkkwwek.',
      'kggggggggggk',
      'kgpggggggpgk',
      'kgggkkkkgggk',
      'kgggggggggGk',
      '.kgggggggGk.',
      '.kGGgggGGGk.',
      '..kkkkkkkk..',
    ],
    feet: ['.gg.....gg..', '..gg...gg...'],
  },
  {
    name: 'seal pup',
    palette: {
      k: '#2b3442',
      s: '#f4f6fb',
      S: '#c9d2e2',
      b: '#4a8df0',
      e: INK,
      n: '#2b3442',
      p: '#ffb3c1',
    },
    body: [
      '............',
      '............',
      '...kkkkkk...',
      '..kssssssk..',
      '.ksssesesk..',
      '.kspssnspk..',
      '.kbbbbbbbk..',
      'kssbsssssSk.',
      'kssssssssSSk',
      'kSssssssSSk.',
      '.kkkkkkkkkk.',
    ],
    feet: ['..S.....S...', '...S...S....'],
  },
  {
    name: 'fox kit',
    palette: { k: '#3b1d0e', o: '#ff8a3d', O: '#d9652a', w: '#fff3e6', e: INK, n: INK },
    body: [
      '..k....k....',
      '.kok..kok...',
      '.kookkook...',
      '.kooooook...',
      '.koeoooek...',
      '.kwwwnwwk...',
      '..kwwwwk....',
      '.kooooook.kk',
      'kooowwoookok',
      'kOoowwooOkwk',
      '.kkkkkkkk.k.',
    ],
    feet: ['..k.k..k.k..', '...kk..kk...'],
  },
  {
    name: 'snail',
    palette: { k: '#3a2a52', m: '#a98cf5', M: '#7a5fd6', c: '#ffe2a8', C: '#e8b96a', e: INK },
    body: [
      '........k..k',
      '...kkkk.k.k.',
      '..kmmmmkkck.',
      '.kmMMMmmkcck',
      '.kmMmmMmkcek',
      '.kmMmMMmkcck',
      '.kmmMMmmkcck',
      '..kmmmmkccck',
      '.kkkkkkkccck',
      'kcccccccccck',
      'kCCCCCCCCCk.',
    ],
    feet: ['.kkkkkkkkk..', '..kkkkkkkkk.'],
  },
  {
    name: 'sprout bean',
    palette: {
      k: '#5a1f3a',
      r: '#ff9ec4',
      R: '#e2709f',
      l: '#57c84d',
      L: '#2f8a2a',
      e: INK,
      p: '#ffd3e4',
    },
    body: [
      '.....ll.....',
      '....lLll....',
      '.....L......',
      '...kkkkkk...',
      '..krrrrrrk..',
      '.krrererrrk.',
      '.krrrrrrrrk.',
      '.krprrrrprk.',
      '.kRrrrrrrRk.',
      '..kRRRRRRk..',
      '...kkkkkk...',
    ],
    feet: ['...k....k...', '....k..k....'],
  },
];

/** The rows of a frame. */
export const rows = (sprite: Sprite, frame: number) => [
  ...sprite.body,
  sprite.feet[Math.abs(Math.trunc(frame) || 0) % 2],
];

const cache = new Map<string, HTMLCanvasElement>();

/** A frame drawn at `size` device px per pixel, cached. */
export function stamp(sprite: Sprite, frame: number, size: number) {
  const key = `${sprite.name}:${Math.abs(Math.trunc(frame) || 0) % 2}:${size}`;
  let canvas = cache.get(key);
  if (canvas) return canvas;
  canvas = document.createElement('canvas');
  const lines = rows(sprite, frame);
  canvas.width = 12 * size;
  canvas.height = lines.length * size;
  const context = canvas.getContext('2d');
  if (context)
    lines.forEach((line, y) =>
      [...line].forEach((letter, x) => {
        const colour = sprite.palette[letter];
        if (!colour) return;
        context.fillStyle = colour;
        context.fillRect(x * size, y * size, size, size);
      }),
    );
  cache.set(key, canvas);
  return canvas;
}
```

### hand-desktop/art.tsx

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

'use client';

/*
 * The desk's drawings, all decorative: the painted sky with its rolling hills (a dusk palette at
 * night, from the stylesheet's tokens), the folder icon, the dock's icons and a critter drawn in
 * SVG squares for the pets window and the dock.
 */

import { useId, type ReactNode } from 'react';
import { rows, type Sprite } from './critters';
import styles from './hand-desktop.module.css';

/** Soft clouds: groups of blurred ellipses, in the wallpaper's 1600 × 1000 units. */
const CLOUDS: { x: number; y: number; s: number; puffs: [number, number, number, number][] }[] = [
  {
    x: 180,
    y: 150,
    s: 1,
    puffs: [
      [0, 0, 120, 34],
      [70, -22, 70, 34],
      [-60, -10, 60, 26],
      [140, 6, 80, 22],
    ],
  },
  {
    x: 980,
    y: 120,
    s: 1.2,
    puffs: [
      [0, 0, 130, 30],
      [-80, -18, 70, 30],
      [50, -30, 80, 36],
      [150, -4, 70, 24],
    ],
  },
  {
    x: 560,
    y: 360,
    s: 0.9,
    puffs: [
      [0, 0, 110, 30],
      [60, -26, 70, 34],
      [-70, -8, 60, 22],
    ],
  },
  {
    x: 1330,
    y: 400,
    s: 1,
    puffs: [
      [0, 0, 150, 34],
      [-60, -30, 80, 36],
      [70, -28, 70, 34],
      [-170, 10, 70, 18],
    ],
  },
  {
    x: 300,
    y: 470,
    s: 0.7,
    puffs: [
      [0, 0, 120, 24],
      [40, -18, 60, 24],
    ],
  },
];

/** Long thin streaks of high cloud. */
const WISPS: [number, number, number, number, number][] = [
  [700, 90, 260, 10, -14],
  [1250, 250, 300, 9, -10],
  [420, 260, 220, 8, -6],
  [1500, 120, 180, 8, -18],
];

/** Stars, seen only at night. */
const STARS: [number, number, number][] = [
  [90, 60, 2],
  [260, 220, 1.5],
  [430, 90, 2.2],
  [610, 200, 1.4],
  [760, 40, 1.8],
  [880, 260, 1.4],
  [1040, 60, 2],
  [1180, 180, 1.5],
  [1320, 70, 2.4],
  [1460, 240, 1.6],
  [1540, 40, 1.4],
  [340, 330, 1.2],
  [1100, 330, 1.2],
];

/** Flower specks on the near hill. */
const SPECKS: [number, number][] = Array.from({ length: 46 }, (_, i) => {
  const x = (i * 211) % 1600;
  const y = 830 + ((i * 97) % 150);
  return [x, y];
});

export function Wallpaper() {
  const id = useId().replace(/:/g, '');
  const sky = `${id}-sky`;
  const far = `${id}-far`;
  const mid = `${id}-mid`;
  const near = `${id}-near`;
  const blur = `${id}-blur`;
  const soft = `${id}-soft`;
  return (
    <svg
      className={styles.wallpaper}
      data-part="wallpaper"
      viewBox="0 0 1600 1000"
      preserveAspectRatio="xMidYMax slice"
      aria-hidden="true"
      focusable="false"
    >
      <defs>
        <linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" className={styles.skyTop} />
          <stop offset="0.55" className={styles.skyMid} />
          <stop offset="0.75" className={styles.skyLow} />
        </linearGradient>
        <linearGradient id={far} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" className={styles.farTop} />
          <stop offset="1" className={styles.farLow} />
        </linearGradient>
        <linearGradient id={mid} x1="0.2" y1="0" x2="0.5" y2="1">
          <stop offset="0" className={styles.midTop} />
          <stop offset="1" className={styles.midLow} />
        </linearGradient>
        <linearGradient id={near} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" className={styles.nearTop} />
          <stop offset="1" className={styles.nearLow} />
        </linearGradient>
        <filter id={blur} x="-30%" y="-60%" width="160%" height="220%">
          <feGaussianBlur stdDeviation="6" />
        </filter>
        <filter id={soft} x="-30%" y="-200%" width="160%" height="500%">
          <feGaussianBlur stdDeviation="5" />
        </filter>
      </defs>
      <rect width="1600" height="1000" fill={`url(#${sky})`} />
      <g className={styles.stars}>
        {STARS.map(([x, y, r]) => (
          <circle key={`${x}-${y}`} cx={x} cy={y} r={r} />
        ))}
      </g>
      <g className={styles.clouds}>
        {WISPS.map(([x, y, w, h, r]) => (
          <ellipse
            key={`${x}-${y}`}
            cx={x}
            cy={y}
            rx={w}
            ry={h}
            transform={`rotate(${r} ${x} ${y})`}
            filter={`url(#${soft})`}
            opacity="0.7"
          />
        ))}
        {CLOUDS.map((cloud) => (
          <g
            key={`${cloud.x}-${cloud.y}`}
            transform={`translate(${cloud.x} ${cloud.y}) scale(${cloud.s})`}
            filter={`url(#${blur})`}
          >
            {cloud.puffs.map(([x, y, rx, ry]) => (
              <ellipse key={`${x}-${y}`} cx={x} cy={y} rx={rx} ry={ry} />
            ))}
          </g>
        ))}
      </g>
      <path d="M0 610C240 560 520 575 820 600S1380 560 1600 585V1000H0Z" fill={`url(#${far})`} />
      <path
        d="M0 650C250 575 600 548 960 598C1210 632 1420 676 1600 690V1000H0Z"
        fill={`url(#${mid})`}
      />
      <g className={styles.streaks} fill="none" strokeLinecap="round">
        <path d="M60 700C380 640 700 630 1040 680" strokeWidth="10" />
        <path d="M200 760C520 700 900 700 1300 760" strokeWidth="7" />
        <path d="M0 735C200 700 380 690 560 700" strokeWidth="6" />
      </g>
      <path
        d="M0 835C320 775 700 785 1010 810C1250 830 1450 805 1600 795V1000H0Z"
        fill={`url(#${near})`}
      />
      <g className={styles.specks}>
        {SPECKS.map(([x, y], i) => (
          <rect key={i} x={x} y={y} width="5" height="5" rx="1" />
        ))}
      </g>
    </svg>
  );
}

export function FolderIcon() {
  return (
    <svg className={styles.folderArt} viewBox="0 0 64 50" aria-hidden="true" focusable="false">
      <path className={styles.folderBack} d="M3 6a4 4 0 0 1 4-4h15l5 5h30a4 4 0 0 1 4 4v33H3Z" />
      <rect className={styles.folderFront} x="1" y="11" width="62" height="38" rx="4" />
      <rect className={styles.folderShine} x="1" y="11" width="62" height="2.5" rx="1.25" />
    </svg>
  );
}

/** A critter, drawn in SVG squares (crisp at any size). */
export function CritterArt({ sprite, className }: { sprite: Sprite; className?: string }) {
  const lines = rows(sprite, 0);
  return (
    <svg
      className={className}
      viewBox={`0 0 12 ${lines.length}`}
      shapeRendering="crispEdges"
      aria-hidden="true"
      focusable="false"
    >
      {lines.flatMap((line, y) =>
        [...line].map((letter, x) =>
          sprite.palette[letter] ? (
            <rect
              key={`${x}-${y}`}
              x={x}
              y={y}
              width="1.02"
              height="1.02"
              fill={sprite.palette[letter]}
            />
          ) : null,
        ),
      )}
    </svg>
  );
}

/** The dock's app icons: plain glyphs on rounded squares, made up for this desk. */
export type DockGlyph =
  'home' | 'grid' | 'compass' | 'chat' | 'mail' | 'map' | 'gallery' | 'video' | 'folder' | 'trash';

export function DockIcon({ glyph }: { glyph: DockGlyph }) {
  const id = useId().replace(/:/g, '');
  const g = `${id}-g`;
  const tile = (from: string, to: string) => (
    <>
      <defs>
        <linearGradient id={g} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={from} />
          <stop offset="1" stopColor={to} />
        </linearGradient>
      </defs>
      <rect x="1" y="1" width="38" height="38" rx="9.5" fill={`url(#${g})`} />
    </>
  );
  const white = (
    <rect
      x="1"
      y="1"
      width="38"
      height="38"
      rx="9.5"
      fill="#fbfbfd"
      stroke="#dfe2e8"
      strokeWidth="0.8"
    />
  );
  let art: ReactNode = null;
  switch (glyph) {
    case 'home':
      art = (
        <>
          {tile('#5aa8ff', '#1f63e6')}
          <path d="M10 20 20 11l10 9v10H23v-6h-6v6h-7Z" fill="#fff" />
        </>
      );
      break;
    case 'grid':
      art = (
        <>
          {white}
          {[
            '#ff5f57',
            '#ffbd2e',
            '#28c840',
            '#38a3ff',
            '#a46bff',
            '#ff7ab6',
            '#3ad1c6',
            '#ff9a3c',
            '#6c7bff',
          ].map((c, i) => (
            <rect
              key={c}
              x={9 + (i % 3) * 8}
              y={9 + Math.floor(i / 3) * 8}
              width="6"
              height="6"
              rx="1.6"
              fill={c}
            />
          ))}
        </>
      );
      break;
    case 'compass':
      art = (
        <>
          {white}
          <circle cx="20" cy="20" r="13" fill="#2f8bff" />
          <circle cx="20" cy="20" r="11" fill="#e9f3ff" />
          <path d="m26 14-4.5 8L14 26l4.5-8Z" fill="#ff4d4d" />
          <path d="m14 26 4.5-8 3 4Z" fill="#9aa5b4" />
        </>
      );
      break;
    case 'chat':
      art = (
        <>
          {tile('#6be675', '#22b43b')}
          <path
            d="M20 10c6.6 0 11.5 4 11.5 9.2S26.6 28.4 20 28.4c-1.3 0-2.5-.1-3.6-.4L11 30.6l1.6-4.3C10 24.6 8.5 22 8.5 19.2 8.5 14 13.4 10 20 10Z"
            fill="#fff"
          />
        </>
      );
      break;
    case 'mail':
      art = (
        <>
          {tile('#6cc8ff', '#1f7fe8')}
          <rect x="9" y="12.5" width="22" height="15" rx="2.5" fill="#fff" />
          <path d="m10 14 10 7.5L30 14" fill="none" stroke="#3d93ee" strokeWidth="1.6" />
        </>
      );
      break;
    case 'map':
      art = (
        <>
          {tile('#f5f1e6', '#e7e0cf')}
          <path d="M1 27 39 15v6L1 33Z" fill="#ffd25e" />
          <path d="M14 1v38" stroke="#b9d8a8" strokeWidth="5" />
          <path d="M26 9a5 5 0 0 1 5 5c0 4-5 9-5 9s-5-5-5-9a5 5 0 0 1 5-5Z" fill="#ff4d5e" />
          <circle cx="26" cy="14" r="1.8" fill="#fff" />
        </>
      );
      break;
    case 'gallery':
      art = (
        <>
          {white}
          <rect x="7" y="9" width="26" height="22" rx="3" fill="#8fd0ff" />
          <circle cx="27" cy="15" r="3" fill="#ffd24a" />
          <path d="M7 27c5-6 9-8 14-5s8 1 12-2v8a3 3 0 0 1-3 3H10a3 3 0 0 1-3-3Z" fill="#4cbf4a" />
        </>
      );
      break;
    case 'video':
      art = (
        <>
          {tile('#6be675', '#1fb23a')}
          <rect x="8" y="13" width="17" height="14" rx="3" fill="#fff" />
          <path d="m26 18 6-4v12l-6-4Z" fill="#fff" />
        </>
      );
      break;
    case 'folder':
      art = (
        <>
          <path d="M3 9a3 3 0 0 1 3-3h10l4 4h14a3 3 0 0 1 3 3v21H3Z" fill="#3fa6e6" />
          <rect x="2" y="13" width="36" height="22" rx="3" fill="#6cc7f3" />
        </>
      );
      break;
    case 'trash':
      art = (
        <>
          <path d="M9 10h22l-2 25a3 3 0 0 1-3 3H14a3 3 0 0 1-3-3Z" fill="#8e949c" />
          <path d="M9 10h22l-.3 4H9.3Z" fill="#6f757d" />
          <rect x="7.5" y="6.5" width="25" height="4.5" rx="2" fill="#a9afb7" />
          <path d="M15 16v17M20 16v17M25 16v17" stroke="#767c84" strokeWidth="1.4" />
        </>
      );
      break;
  }
  return (
    <svg className={styles.dockArt} viewBox="0 0 40 40" aria-hidden="true" focusable="false">
      {art}
    </svg>
  );
}

/** Small line glyphs for the gesture guide. */
export function GestureGlyph({ name }: { name: 'pinch' | 'wave' | 'both' }) {
  return (
    <svg className={styles.glyph} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      {name === 'pinch' && (
        <path d="M8 20c-2-2-3-4-3-7l1-4M9 13V6a1.5 1.5 0 0 1 3 0v4m0 0a3 3 0 0 1 3.5 1.2M12 10c2.5-1.5 5-.5 5 2 0 3-2 6-5 8" />
      )}
      {name === 'wave' && (
        <path d="M7 12V7a1.4 1.4 0 0 1 2.8 0v4m0-1V5a1.4 1.4 0 0 1 2.8 0v5m0-1V6a1.4 1.4 0 0 1 2.8 0v6m0-2a1.4 1.4 0 0 1 2.8 0v4c0 4-3 7-7 7-3 0-5-2-6-4l-2-4a1.3 1.3 0 0 1 2.3-1.3L7 15M3 6l1.5-1.5M3.5 9.5H2" />
      )}
      {name === 'both' && (
        <path d="M3 18c1-3 3-5 6-6l2.5-1M21 18c-1-3-3-5-6-6l-2.5-1M12 11V6M10 4l2 2 2-2" />
      )}
    </svg>
  );
}
```

### hand-desktop/use-autoplay.ts

```tsx
'use client';

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

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

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

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

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

### hand-desktop/hand-desktop.module.css

```css
/* Hand desktop (hand-desktop.tsx): a painted sky over rolling hills, with folders, a window,
   a clock card, a frosted dock, a hint pill and the camera's status, and one canvas on top for the
   critters and the hand skeletons.

   Sizes follow the desk's width (cqw: a hundredth of it), clamped so text stays legible on a phone
   and nothing grows past its natural size on a wide screen.

   Colours are the tokens below, light-dark(day, dusk): the component takes the color-scheme it
   inherits from the page, or the one its colorScheme prop sets (data-color-scheme). */

.root {
  /* The sky, top to horizon, and its clouds and stars. */
  --sky-top: #1f5bd6;
  --sky-mid: #4a8ff0;
  --sky-low: #b4dafb;
  --cloud: rgb(255 255 255 / 0.96);
  --star: transparent;
  /* The hills, far to near, top and foot of each. */
  --far-top: #9fd2a2;
  --far-low: #69ad68;
  --mid-top: #86d447;
  --mid-low: #2e7c1b;
  --near-top: #4fa82c;
  --near-low: #286915;
  --streak: rgb(214 255 160 / 0.14);
  --speck: #fff6a6;
  /* Folders. */
  --folder: #74caf3;
  --folder-back: #47a5dc;
  --folder-shine: rgb(255 255 255 / 0.45);
  --label: #fff;
  /* Frosted glass: the dock, the clock card, the pills. */
  --glass: rgb(255 255 255 / 0.62);
  --glass-strong: rgb(250 251 252 / 0.88);
  --glass-edge: rgb(255 255 255 / 0.75);
  --pill: rgb(255 255 255 / 0.34);
  --ink: #1b2233;
  --ink-2: #6b7284;
  /* The window. */
  --window: #fff;
  --window-bar: #f2f3f6;
  --line: rgb(16 24 40 / 0.1);
  --accent: #2f6df6;
  --accent-ink: #fff;
  --shadow: rgb(10 30 70 / 0.22);
  /* The same on both schemes. */
  --focus: #387bff;
  --good: #2fbf5b;
  --wait: #f2a531;
  --bad: #ef5350;
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  min-height: 440px;
  max-height: 720px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--sky-mid);
  color: var(--ink);
  font-family:
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    sans-serif;
  -webkit-tap-highlight-color: transparent;
  isolation: isolate;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --sky-top: light-dark(#1f5bd6, #0a1431);
    --sky-mid: light-dark(#4a8ff0, #22336b);
    --sky-low: light-dark(#b4dafb, #6b4f88);
    --cloud: light-dark(rgb(255 255 255 / 0.96), rgb(200 207 240 / 0.3));
    --star: light-dark(transparent, rgb(255 255 255 / 0.9));
    --far-top: light-dark(#9fd2a2, #2c4a5e);
    --far-low: light-dark(#69ad68, #1d3a3d);
    --mid-top: light-dark(#86d447, #2b5f2e);
    --mid-low: light-dark(#2e7c1b, #10301a);
    --near-top: light-dark(#4fa82c, #1c4a21);
    --near-low: light-dark(#286915, #0b2410);
    --streak: light-dark(rgb(214 255 160 / 0.14), rgb(150 210 150 / 0.06));
    --speck: light-dark(#fff6a6, #9fb08a);
    --folder: light-dark(#74caf3, #5aaee0);
    --folder-back: light-dark(#47a5dc, #3787bf);
    --folder-shine: light-dark(rgb(255 255 255 / 0.45), rgb(255 255 255 / 0.25));
    --glass: light-dark(rgb(255 255 255 / 0.62), rgb(28 32 48 / 0.62));
    --glass-strong: light-dark(rgb(250 251 252 / 0.88), rgb(28 32 48 / 0.82));
    --glass-edge: light-dark(rgb(255 255 255 / 0.75), rgb(255 255 255 / 0.12));
    --pill: light-dark(rgb(255 255 255 / 0.34), rgb(16 20 36 / 0.5));
    --ink: light-dark(#1b2233, #eef1f8);
    --ink-2: light-dark(#6b7284, #a4abbd);
    --window: light-dark(#fff, #1c2030);
    --window-bar: light-dark(#f2f3f6, #252a3b);
    --line: light-dark(rgb(16 24 40 / 0.1), rgb(255 255 255 / 0.09));
    --accent: light-dark(#2f6df6, #6d9bff);
    --accent-ink: light-dark(#fff, #0b1020);
    --shadow: light-dark(rgb(10 30 70 / 0.22), rgb(0 0 0 / 0.5));
  }
}
.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;
}

.desk {
  position: absolute;
  inset: 0;
  container-type: inline-size;
  cursor: none;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}
.desk [data-hand-ignore] {
  cursor: auto;
}
.desk button {
  font: inherit;
  color: inherit;
}
.desk button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* ——— The wallpaper ——— */

.wallpaper {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
}
.skyTop {
  stop-color: var(--sky-top);
}
.skyMid {
  stop-color: var(--sky-mid);
}
.skyLow {
  stop-color: var(--sky-low);
}
.farTop {
  stop-color: var(--far-top);
}
.farLow {
  stop-color: var(--far-low);
}
.midTop {
  stop-color: var(--mid-top);
}
.midLow {
  stop-color: var(--mid-low);
}
.nearTop {
  stop-color: var(--near-top);
}
.nearLow {
  stop-color: var(--near-low);
}
.clouds {
  fill: var(--cloud);
}
.stars {
  fill: var(--star);
}
.streaks {
  stroke: var(--streak);
}
.specks {
  fill: var(--speck);
  opacity: 0.75;
}

/* ——— Folders ——— */

.folder {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(2px, 0.4cqw, 5px);
  width: clamp(66px, 8cqw, 92px);
  padding: 0;
  border: 0;
  background: none;
  cursor: inherit;
  touch-action: none;
  will-change: transform;
}
.folderIcon {
  display: block;
  width: calc(clamp(38px, 4.6cqw, 62px) + 6px);
  padding: 3px;
  border-radius: 7px;
  transition:
    background-color 0.15s,
    scale 0.18s cubic-bezier(0.3, 1.4, 0.5, 1),
    filter 0.18s;
}
.folderArt {
  display: block;
  width: 100%;
  height: auto;
  filter: drop-shadow(0 1px 1.5px rgb(0 30 80 / 0.25));
}
.folderBack {
  fill: var(--folder-back);
}
.folderFront {
  fill: var(--folder);
}
.folderShine {
  fill: var(--folder-shine);
}
.folderLabel {
  max-width: 100%;
  padding: 1px 5px;
  border-radius: 4px;
  color: var(--label);
  font-size: clamp(10px, 0.95cqw, 12px);
  font-weight: 600;
  line-height: 1.25;
  text-align: center;
  text-shadow:
    0 1px 3px rgb(0 0 0 / 0.75),
    0 0 1px rgb(0 0 0 / 0.9);
}
.folder[data-hover] .folderIcon,
.folder:focus-visible .folderIcon {
  background: rgb(255 255 255 / 0.24);
}
.folder[data-grabbed] .folderIcon {
  background: rgb(255 255 255 / 0.3);
  scale: 1.08;
  filter: drop-shadow(0 8px 10px rgb(0 20 60 / 0.3));
}
.folder[data-grabbed] .folderLabel {
  background: var(--accent);
  color: var(--accent-ink);
  text-shadow: none;
}

/* ——— The window ——— */

.window {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  width: clamp(212px, 28cqw, 300px);
  max-width: calc(100% - 16px);
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--window);
  box-shadow:
    0 14px 34px var(--shadow),
    0 2px 6px var(--shadow);
  color: var(--ink);
  font-size: 12px;
  line-height: 1.4;
  will-change: transform;
  transition: box-shadow 0.2s;
}
.window[data-grabbed] {
  box-shadow:
    0 22px 48px var(--shadow),
    0 4px 10px var(--shadow);
}
.titleBar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  height: 30px;
  padding: 0 6px 0 12px;
  border-bottom: 1px solid var(--line);
  background: var(--window-bar);
  touch-action: none;
}
.window[data-hover] .titleBar,
.window[data-grabbed] .titleBar {
  background: color-mix(in srgb, var(--accent) 12%, var(--window-bar));
}
.title {
  overflow: hidden;
  font-weight: 650;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.close {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
}
.close:hover {
  background: var(--line);
}
.close svg {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}
.windowBody {
  padding: 10px 12px 12px;
}
.guide,
.pets,
.files {
  margin: 0;
  padding: 0;
  list-style: none;
}
.guide li {
  display: grid;
  grid-template-columns: 18px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
}
.guide li + li {
  border-top: 1px solid var(--line);
}
.guide strong {
  font-weight: 650;
}
.glyph {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: var(--accent);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.try {
  padding: 3px 9px;
  border: 0;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  color: var(--accent) !important;
  font-size: 11px !important;
  font-weight: 600;
  cursor: pointer;
}
.try:hover {
  background: var(--accent);
  color: var(--accent-ink) !important;
}
.note {
  margin: 8px 0 0;
  color: var(--ink-2);
  font-size: 11px;
}
.pets {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px 6px;
}
.pets li,
.files li {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  font-size: 11px;
  text-align: center;
}
.petArt {
  width: 30px;
  height: 30px;
}
.files {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
}
.file {
  width: 26px;
  height: 32px;
  border: 1px solid var(--line);
  border-radius: 3px 9px 3px 3px;
  background: linear-gradient(var(--window-bar), var(--window));
}

/* ——— The clock card ——— */

.clock {
  position: absolute;
  right: clamp(10px, 2.4cqw, 32px);
  bottom: max(6.5cqw, 58px);
  z-index: 5;
  width: clamp(128px, 16.5cqw, 220px);
  padding: clamp(8px, 1.15cqw, 14px) 10px;
  border: 1px solid var(--glass-edge);
  border-radius: clamp(10px, 1.2cqw, 16px);
  background: var(--glass-strong);
  box-shadow: 0 4px 14px rgb(0 20 60 / 0.12);
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  text-align: center;
  transform-origin: 50% 100%;
}
.clock p {
  margin: 0;
}
.clockLabel {
  font-size: clamp(8px, 0.78cqw, 10px);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.time {
  margin: 0.15em 0 0.2em !important;
  font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  font-size: clamp(18px, 2.15cqw, 28px);
  font-variant-numeric: tabular-nums;
  font-weight: 300;
  letter-spacing: -0.02em;
  line-height: 1.1;
}
.time small {
  margin: 0 0.04em;
  color: var(--ink-2);
  font-size: 0.72em;
}
.clockNote {
  color: var(--ink-2);
  font-size: clamp(7px, 0.7cqw, 9px);
  font-weight: 500;
  letter-spacing: 0.03em;
  line-height: 1.3;
  text-transform: uppercase;
}
.root[data-dance] .clock {
  animation: wiggle 0.43s ease-in-out infinite alternate;
}
@keyframes wiggle {
  from {
    rotate: -2.5deg;
  }
  to {
    rotate: 2.5deg;
  }
}

/* ——— The dock ——— */

.dock {
  position: absolute;
  bottom: clamp(4px, 0.6cqw, 8px);
  left: 50%;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: clamp(3px, 0.4cqw, 6px);
  padding: clamp(4px, 0.55cqw, 7px);
  border: 1px solid var(--glass-edge);
  border-radius: clamp(12px, 1.5cqw, 18px);
  background: var(--glass);
  box-shadow: 0 6px 20px rgb(0 20 60 / 0.16);
  backdrop-filter: blur(16px) saturate(1.5);
  -webkit-backdrop-filter: blur(16px) saturate(1.5);
  translate: -50% 0;
}
.app {
  display: block;
  flex: none;
  width: clamp(26px, 3.75cqw, 50px);
  height: clamp(26px, 3.75cqw, 50px);
  padding: 0;
  border: 0;
  border-radius: 24%;
  background: none;
  transition: translate 0.2s cubic-bezier(0.3, 1.5, 0.5, 1);
}
button.app {
  cursor: pointer;
}
button.app:hover {
  translate: 0 -3px;
}
.dockArt {
  display: block;
  width: 100%;
  height: 100%;
  filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.14));
}
.slot {
  border: 1.5px solid var(--glass-edge);
  background: rgb(255 255 255 / 0.28);
}
.critterTile {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  border-radius: 24%;
  background: linear-gradient(#ffe98a, #ffc93d);
  box-shadow: inset 0 0 0 0.8px rgb(0 0 0 / 0.08);
}
.tileArt {
  width: 70%;
  height: 70%;
}
.divider {
  flex: none;
  width: 1px;
  height: clamp(20px, 3cqw, 40px);
  margin: 0 clamp(2px, 0.3cqw, 4px);
  background: rgb(0 0 0 / 0.14);
}

/* ——— The hint pill and the status ——— */

.hint {
  position: absolute;
  top: clamp(8px, 1.1cqw, 14px);
  left: 50%;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 6px;
  max-width: calc(100% - 24px);
  margin: 0;
  padding: 0.4em 1.1em;
  border-radius: 999px;
  background: var(--pill);
  color: var(--ink);
  font-size: clamp(11px, 1cqw, 13px);
  font-weight: 500;
  white-space: nowrap;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  translate: -50% 0;
  pointer-events: none;
}
.hint svg {
  flex: none;
  width: 1.1em;
  height: 1.1em;
  fill: none;
  stroke: #f5a623;
  stroke-width: 1.8;
}
.status {
  position: absolute;
  top: clamp(8px, 1.1cqw, 14px);
  right: clamp(8px, 1.2cqw, 16px);
  z-index: 60;
  display: flex;
  align-items: center;
  gap: 6px;
}
.chip,
.cameraButton {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  padding: 0 10px;
  border: 1px solid var(--glass-edge);
  border-radius: 999px;
  background: var(--glass-strong);
  color: var(--ink);
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}
.dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--ink-2);
}
.chip[data-tone='good'] .dot {
  background: var(--good);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 25%, transparent);
}
.chip[data-tone='wait'] .dot {
  background: var(--wait);
  animation: blink 0.9s ease-in-out infinite alternate;
}
.chip[data-tone='bad'] .dot {
  background: var(--bad);
}
@keyframes blink {
  to {
    opacity: 0.3;
  }
}
.cameraButton {
  border-color: transparent;
  background: var(--accent);
  color: var(--accent-ink) !important;
  cursor: pointer;
}
.cameraButton svg {
  width: 14px;
  height: 14px;
  fill: currentColor;
}
.cameraButton:hover {
  filter: brightness(1.08);
}

/* ——— The camera preview ——— */

.pip {
  position: absolute;
  bottom: max(6.5cqw, 58px);
  left: clamp(10px, 2.4cqw, 32px);
  z-index: 4;
  width: clamp(120px, 20cqw, 220px);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border: 1px solid var(--glass-edge);
  border-radius: 14px;
  background: #0b0f1a;
  box-shadow: 0 8px 22px var(--shadow);
}
.pip[hidden] {
  display: none;
}
.feed {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scaleX(-1);
  filter: brightness(0.55) saturate(0.75);
}
.pipLabel {
  position: absolute;
  top: 6px;
  left: 50%;
  padding: 2px 8px;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.45);
  color: #fff;
  font-size: 10px;
  font-weight: 600;
  white-space: nowrap;
  translate: -50% 0;
}

/* ——— The overlay ——— */

.overlay {
  position: absolute;
  inset: 0;
  z-index: 50;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

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

/* ——— Narrow desks ——— */

@container (max-width: 620px) {
  .app[data-app='grid'],
  .app[data-app='compass'],
  .app[data-app='map'],
  .app[data-app='video'] {
    display: none;
  }
  .hint {
    top: 46px;
  }
}
@container (max-width: 459px) {
  .app[data-app='gallery'],
  .app[data-app='mail'] {
    display: none;
  }
  .hint {
    top: 10px;
    left: 10px;
    max-width: calc(100% - 64px);
    padding: 0.5em 0.9em;
    border-radius: 14px;
    line-height: 1.25;
    white-space: normal;
    translate: none;
  }
  .chip {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
  }
  .root:not([data-camera='off']) .chip {
    top: 50px;
    right: 0;
    width: auto;
    height: 28px;
    padding: 0 10px;
    overflow: visible;
    clip-path: none;
  }
  .cameraButton {
    justify-content: center;
    width: 34px;
    height: 34px;
    padding: 0;
  }
  .cameraLabel {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }
  .clock {
    bottom: 54px;
    width: 112px;
  }
  .clockNote {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root[data-dance] .clock {
    animation: none;
  }
  .chip[data-tone='wait'] .dot {
    animation: none;
  }
  .folderIcon,
  .app {
    transition: none;
  }
}
```