Skip to content
← Components

Dither Card

00 layers

Source

dither-card.tsx
"use client";

import { useEffect, useEffectEvent, useId, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { focusQuietly } from "@/design-system";
import { hostTransport, play } from "@/lib/sound";

/*
 * Project links as keys with a screen in each face, after a launcher's LCD
 * keys. Each screen runs a creature (a butterfly that flaps, a jellyfish that
 * pulses, a flower that turns) in three-tone ordered dither: a Bayer 8×8
 * threshold resolves every cell to the glass, a mid tone or the ink, which
 * doubles the steps a two-tone dither can show.
 *
 * The field is raw WebGL drawn a cell per 2 CSS px and scaled up pixel for
 * pixel, so a screen costs a few thousand fragments a frame. It draws in the
 * LCD's ink on a clear ground, so the glass behind it shows through, and it
 * stays monochrome like every light on the body. A hand lights the screen
 * under it and quickens the creature about fourfold, a press sinks the key
 * 2px with a click and sends a ring through the dots, and keyboard focus
 * lights it from the centre with a ring of its own. The loop writes uniforms,
 * never React state, runs only while a screen is on the page, and holds a
 * still frame under reduced motion.
 */

/* --- Shader --------------------------------------------------------------- */

const VERTEX = `
attribute vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
`;

const FRAGMENT = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif

uniform vec2 uRes;
uniform float uTime;
uniform vec2 uMouse;
uniform float uHover;
uniform vec3 uRipple;   // xy = origin (px), z = age in seconds (<0: none)
uniform float uPattern;
uniform vec3 uInk;

float bayer2(vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
float bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }
float bayer8(vec2 a) { return bayer4(0.5 * a) * 0.25 + bayer2(a); }

float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 6; i++) { v += a * noise(p); p = p * 2.03 + 17.0; a *= 0.5; }
  return v;
}
// Thin bright bands where x crosses a multiple of 1/n — contour lines.
float contour(float x, float n, float sharp) { return pow(abs(cos(x * n * 3.14159)), sharp); }
// Signed distance to an ellipse, in units of its radii: <0 inside, 0 on the edge.
float ellipse(vec2 p, vec2 c, vec2 r) { return length((p - c) / r) - 1.0; }
mat2 rot(float a) { float s = sin(a), c = cos(a); return mat2(c, -s, s, c); }

// A wing: tinted inside, inked along the rim, with an eyespot.
float wing(vec2 q, vec2 c, vec2 r, float tilt, vec2 eye) {
  vec2 w = rot(tilt) * (q - c);
  float d = length(w / r) - 1.0;
  if (d > 0.0) return 0.0;
  float veins = contour(atan(q.y, q.x) * 0.9, 3.0, 24.0) * 0.25;
  float rim = smoothstep(-0.28, -0.16, d);
  float e = length(q - eye);
  float spot = smoothstep(0.045, 0.035, e) - smoothstep(0.025, 0.017, e) * 0.85;
  return clamp(0.36 + veins + rim * 0.62 + spot * 0.6, 0.0, 1.0);
}

// hair: half a cell in these units. No stroke is drawn finer, so a small screen keeps its antennae.
float butterfly(vec2 p, float t, float hair) {
  p.y -= sin(t * 0.9) * 0.025;
  // Wings fold toward the body and open again; foreshortening fakes the flap.
  float flap = 0.3 + 0.7 * abs(cos(t * 2.4));
  vec2 q = vec2(abs(p.x) / flap, p.y);
  float v = max(wing(q, vec2(0.15, 0.08), vec2(0.17, 0.12), -0.5, vec2(0.2, 0.12)),
                wing(q, vec2(0.11, -0.1), vec2(0.11, 0.085), 0.6, vec2(0.13, -0.12)));
  // Body and antennae stay full width and full ink.
  float body = step(abs(p.x), max(0.016, hair)) * step(-0.17, p.y) * step(p.y, 0.15);
  vec2 a = vec2(abs(p.x), p.y - 0.15);
  float antenna = step(abs(a.x - a.y * 0.55), max(0.006, hair)) * step(0.0, a.y) * step(a.y, 0.12);
  float tip = step(length(a - vec2(0.066, 0.12)), max(0.014, hair * 1.5));
  return max(v, max(body, max(antenna, tip)));
}

float jellyfish(vec2 p, float t, float hair) {
  float pulse = sin(t * 2.2);
  p.y -= 0.04 + pulse * 0.02;
  // The bell squeezes as it pushes, widening again as it relaxes.
  vec2 c = vec2(0.0, 0.08);
  vec2 r = vec2(0.24 - pulse * 0.025, 0.2 + pulse * 0.02);
  float hem = c.y - 0.02 + 0.012 * sin(p.x * 70.0);
  float d = ellipse(p, c, r);
  float bell = 0.0;
  if (d < 0.0 && p.y > hem) {
    float depth = (p.y - hem) / r.y;
    bell = 0.32 + contour(d, 4.0, 10.0) * 0.4 + smoothstep(-0.18, -0.06, d) * 0.5 + (1.0 - depth) * 0.12;
  }
  // Tentacles trail and sway, thinning toward their tips.
  float tent = 0.0;
  for (int i = 0; i < 6; i++) {
    float fi = float(i);
    float x0 = -0.17 + fi * 0.068;
    float len = 0.34 + 0.08 * sin(fi * 2.3);
    float y = hem - p.y;
    float sway = sin(y * 16.0 - t * 2.6 + fi * 1.7) * 0.03 * smoothstep(0.0, 0.2, y);
    float width = max(mix(0.009, 0.003, clamp(y / len, 0.0, 1.0)), hair);
    tent = max(tent, step(abs(p.x - x0 - sway), width) * step(0.0, y) * step(y, len));
  }
  return clamp(max(bell, tent), 0.0, 1.0);
}

float bloom(vec2 p, float t) {
  float breathe = 1.0 + sin(t * 1.3) * 0.04;
  p /= breathe;
  float r = length(p), a = atan(p.y, p.x);
  float spin = t * 0.25;
  // Two rings of five petals, the inner one offset and turning the other way.
  float outer = 0.3 * (0.45 + 0.55 * pow(abs(cos(a * 2.5 + spin)), 0.7));
  float inner = 0.19 * (0.5 + 0.5 * pow(abs(cos(a * 2.5 - spin * 1.4 + 0.63)), 0.7));
  float v = 0.0;
  if (r < outer) v = 0.3 + smoothstep(outer - 0.05, outer - 0.015, r) * 0.6 + contour(a * 0.8 + spin * 0.32, 4.0, 30.0) * 0.12;
  if (r < inner) v = 0.5 + smoothstep(inner - 0.04, inner - 0.01, r) * 0.5;
  // A seeded centre.
  if (r < 0.065) v = 0.55 + 0.45 * step(0.5, fract(r * 40.0 - t * 0.3 + sin(a * 8.0) * 0.1));
  return v;
}

float field(vec2 uv, float t) {
  float aspect = uRes.x / uRes.y;
  // Fit the creature to the screen's shorter side.
  float fit = min(aspect, 1.0) * 1.3;
  vec2 p = (uv - 0.5) * vec2(aspect, 1.0) / fit;
  float hair = 0.5 / (uRes.y * fit);
  // A faint drifting ground so the dither never goes flat.
  float ground = smoothstep(0.45, 0.9, fbm(uv * 3.0 + t * 0.05)) * 0.22;
  float shape = uPattern < 0.5 ? butterfly(p, t, hair) : uPattern < 1.5 ? jellyfish(p, t, hair) : bloom(p, t);
  return max(ground, shape);
}

void main() {
  vec2 px = gl_FragCoord.xy;
  vec2 uv = px / uRes;
  float v = field(uv, uTime) * 0.86;

  // The light under the hand.
  float r = uRes.y * 0.42;
  float d = distance(px, uMouse);
  float light = exp(-(d * d) / (r * r)) * uHover;

  // A ring that widens and fades from a press.
  float ring = 0.0;
  if (uRipple.z >= 0.0) {
    float rad = uRipple.z * uRes.y * 1.4;
    float rd = abs(distance(px, uRipple.xy) - rad);
    ring = smoothstep(uRes.y * 0.03, 0.0, rd) * exp(-uRipple.z * 2.2);
  }

  float threshold = bayer8(px);
  float level = clamp(v + light * 0.55 + ring, 0.0, 1.0);
  // Three tones: 0 glass, 1 mid, 2 ink.
  float scaled = level * 2.0;
  float tone = min(2.0, floor(scaled) + step(threshold, fract(scaled)));
  // The ink is solid; the mid tone is a tint of it that the light brightens, so a solid run of it
  // still reads as a tint; and the light backs the glass faintly, as a lamp behind an LCD would.
  float alpha = tone > 1.5 ? 1.0 : tone > 0.5 ? mix(0.3, 0.55, max(light, ring)) : light * 0.07;
  gl_FragColor = vec4(uInk * alpha, alpha); // premultiplied
}
`;

/* --- Helpers -------------------------------------------------------------- */

const PATTERNS = { butterfly: 0, jellyfish: 1, bloom: 2 } as const;
export type DitherPattern = keyof typeof PATTERNS;

/**
 * Where each creature's clock starts: a frame worth holding still, the same
 * on every mount, so a remount, a capture and reduced motion all show it. The
 * butterfly's wings are open (t = kπ / 2.4) and the jellyfish's bell is
 * relaxed (sin 2.2t = −1).
 */
const START: Record<DitherPattern, number> = {
  butterfly: (4 * Math.PI) / 2.4,
  jellyfish: (3.5 * Math.PI) / 2.2,
  bloom: 3,
};

let swatch: CanvasRenderingContext2D | null = null;

/**
 * Reads a CSS colour as 0–1 RGB for the shader. Painting it into a 1×1
 * canvas accepts any CSS colour (#111, #111111, rgb(), oklch()…), so a
 * minifier rewriting a token can't break it.
 */
function readColor(el: Element, name: string, fallback: [number, number, number]) {
  const raw = getComputedStyle(el).getPropertyValue(name).trim();
  swatch ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!raw || !swatch) return fallback;
  swatch.clearRect(0, 0, 1, 1);
  swatch.fillStyle = "#000";
  swatch.fillStyle = raw;
  swatch.fillRect(0, 0, 1, 1);
  const [r, g, b] = swatch.getImageData(0, 0, 1, 1).data;
  return [r / 255, g / 255, b / 255] as [number, number, number];
}

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

/* --- DitherField ---------------------------------------------------------- */

type DitherFieldProps = {
  pattern?: DitherPattern;
  /** Size of one dither cell in CSS px. */
  pixel?: number;
  /** Light the field from its centre without a hand on it: keyboard focus, a selection, a demo's ghost. Turning it on sends a ring. */
  active?: boolean;
  /** Hold the creature still. The light and a ring still play out. */
  paused?: boolean;
  className?: string;
};

/**
 * A dither field on a canvas drawn at 1/`pixel` resolution and scaled up with
 * `image-rendering: pixelated`, so even a large screen costs only tens of
 * thousands of fragments a frame. Raw WebGL, no library. It draws in its CSS
 * `color` on a clear ground: set the ink with a text colour, and whatever is
 * behind it is the ground. It only animates while on screen, holds still
 * under reduced motion, and follows theme changes.
 */
export function DitherField({ pattern = "butterfly", pixel = 2, active = false, paused = false, className = "" }: DitherFieldProps) {
  const hostRef = useRef<HTMLSpanElement>(null);
  // Bridges from React props into the render loop, so a change never restarts it.
  const activeRef = useRef(active);
  const pausedRef = useRef(paused);
  const wakeRef = useRef<(ring: boolean) => void>(() => {});

  useEffect(() => {
    pausedRef.current = paused;
    wakeRef.current(false);
  }, [paused]);

  useEffect(() => {
    const rising = active && !activeRef.current;
    activeRef.current = active;
    wakeRef.current(rising);
  }, [active]);

  useEffect(() => {
    const host = hostRef.current!;
    // Made here rather than rendered: the cleanup loses its context for good, so every mount
    // (StrictMode's second one included) needs a canvas of its own.
    const canvas = document.createElement("canvas");
    // Drawn, the dots keep the LCD's light ink whichever finish the study is in (so switching finish
    // never leaves them the drawing's dark ink on dark glass), and the canvas flips them to dark ink on
    // the light ground; in dark they stay light. A touch more than the generic half opacity, as they are the screen's content.
    canvas.className = "block size-full [image-rendering:pixelated] drawn:text-(--device-lcd-ink)! drawn:opacity-70! drawn:invert! drawn:dark:invert-0!";
    host.append(canvas);
    const context = canvas.getContext("webgl", { antialias: false, alpha: true, premultipliedAlpha: true, preserveDrawingBuffer: true });
    if (!context) return () => canvas.remove(); // Whatever is behind shows through as a quiet fallback.
    const gl = context;

    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
    gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
    gl.linkProgram(program);
    gl.useProgram(program);

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const aPos = gl.getAttribLocation(program, "aPos");
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    const u = (name: string) => gl.getUniformLocation(program, name);
    const uRes = u("uRes"), uTime = u("uTime"), uMouse = u("uMouse"), uHover = u("uHover");
    const uRipple = u("uRipple"), uPattern = u("uPattern"), uInk = u("uInk");

    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
    const state = {
      time: START[pattern],
      hover: 0,
      hoverTarget: 0,
      mouse: [0, 0] as [number, number],
      ripple: [0, 0, -1] as [number, number, number],
      visible: false,
      raf: 0,
      last: 0,
    };
    const centre = (): [number, number] => [canvas.width / 2, canvas.height / 2];

    const setInk = () => gl.uniform3fv(uInk, readColor(canvas, "color", [0.96, 0.96, 0.96]));

    function resize() {
      const rect = canvas.getBoundingClientRect();
      canvas.width = Math.max(1, Math.round(rect.width / pixel));
      canvas.height = Math.max(1, Math.round(rect.height / pixel));
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uRes, canvas.width, canvas.height);
      if (!state.hoverTarget) state.mouse = centre();
    }

    function draw() {
      gl.uniform1f(uTime, state.time);
      gl.uniform2fv(uMouse, state.mouse);
      gl.uniform1f(uHover, state.hover);
      gl.uniform3fv(uRipple, state.ripple);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }

    function frame(now: number) {
      const dt = Math.min(0.05, (now - (state.last || now)) / 1000);
      state.last = now;
      const target = Math.max(state.hoverTarget, activeRef.current ? 1 : 0);
      // Light from outside comes from the centre.
      if (activeRef.current && !state.hoverTarget) state.mouse = centre();
      // Exponential ease toward the target: fast in, slightly slower out.
      state.hover += (target - state.hover) * (1 - Math.exp(-dt * (target > state.hover ? 10 : 5)));
      if (!pausedRef.current) state.time += dt * (0.35 + state.hover * 1.1);
      if (state.ripple[2] >= 0) {
        state.ripple[2] += dt;
        if (state.ripple[2] > 1.6) state.ripple[2] = -1;
      }
      // Held, the loop runs only until the light has settled and the ring has gone.
      const settled = pausedRef.current && Math.abs(target - state.hover) < 0.002 && state.ripple[2] < 0;
      if (settled) state.hover = target;
      draw();
      state.raf = state.visible && !settled ? requestAnimationFrame(frame) : 0;
    }

    /** Reduced motion: no loop, just the light on or off over the still frame. */
    function still() {
      state.hover = state.hoverTarget || activeRef.current ? 1 : 0;
      if (activeRef.current && !state.hoverTarget) state.mouse = centre();
      draw();
    }

    function start() {
      if (reduced) return still();
      if (!state.raf && state.visible) {
        state.last = 0;
        state.raf = requestAnimationFrame(frame);
      }
    }

    function toLocal(event: PointerEvent): [number, number] {
      const rect = canvas.getBoundingClientRect();
      return [
        ((event.clientX - rect.left) / rect.width) * canvas.width,
        (1 - (event.clientY - rect.top) / rect.height) * canvas.height,
      ];
    }

    const onMove = (e: PointerEvent) => {
      state.mouse = toLocal(e);
      state.hoverTarget = 1;
      start();
    };
    const onLeave = () => {
      state.hoverTarget = 0;
      start();
    };
    const onDown = (e: PointerEvent) => {
      if (reduced) return;
      state.ripple = [...toLocal(e), 0];
      start();
    };
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);
    host.addEventListener("pointerdown", onDown);

    const resizeObserver = new ResizeObserver(() => {
      resize();
      draw();
    });
    resizeObserver.observe(canvas);

    const visibility = new IntersectionObserver(([entry]) => {
      state.visible = entry.isIntersecting;
      if (state.visible) start();
    });
    visibility.observe(canvas);

    const themeObserver = new MutationObserver(() => {
      setInk();
      draw();
    });
    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });

    wakeRef.current = (ring) => {
      if (ring && !reduced) state.ripple = [...centre(), 0];
      start();
    };

    gl.uniform1f(uPattern, PATTERNS[pattern]);
    setInk();
    resize();
    draw();

    return () => {
      cancelAnimationFrame(state.raf);
      state.visible = false;
      resizeObserver.disconnect();
      visibility.disconnect();
      themeObserver.disconnect();
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
      host.removeEventListener("pointerdown", onDown);
      wakeRef.current = () => {};
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
    };
  }, [pattern, pixel]);

  return <span ref={hostRef} aria-hidden className={`block size-full ${className}`} />;
}

/* --- DitherCard: a project key -------------------------------------------- */

type DitherCardProps = {
  title: string;
  /** Said to screen readers as the link's description. */
  description: string;
  meta?: string;
  href: string;
  pattern?: DitherPattern;
  /** Light the screen from its centre without a hand on it: a selection, or a demo's ghost. */
  active?: boolean;
  /** Hold the creature still. */
  paused?: boolean;
};

/**
 * A project link as a key with a screen in its face. The face sinks 2px onto
 * its base under the finger and clicks; the screen runs a dither field in the
 * LCD's ink. Keyboard focus lights the screen from the centre; focus that
 * followed a press doesn't.
 */
export function DitherCard({ title, description, meta, href, pattern = "butterfly", active = false, paused = false }: DitherCardProps) {
  const [focused, setFocused] = useState(false);
  const descriptionId = useId();
  // Checked again on key up: the first key after a quiet press brings the ring back.
  const checkFocus = (el: HTMLElement) => setFocused(el.matches(":focus-visible") && !el.hasAttribute("data-quiet"));

  return (
    <a
      href={href}
      target="_blank"
      rel="noreferrer"
      draggable={false}
      data-sound="key"
      aria-describedby={descriptionId}
      onPointerDown={(e) => e.button === 0 && focusQuietly(e.currentTarget)}
      onFocus={(e) => checkFocus(e.currentTarget)}
      onKeyUp={(e) => checkFocus(e.currentTarget)}
      onBlur={() => setFocused(false)}
      className="group/key flex min-w-0 flex-col items-center gap-[0.6em] rounded-[0.7em] outline-offset-2 [-webkit-touch-callout:none]"
    >
      {/* The key. Only its face sinks, so the hit area never moves under the finger. */}
      <span data-part="key" className="block w-full rounded-[0.7em] p-[0.32em] [background:var(--device-key-face)] shadow-(--device-key-shadow) transition-[transform,box-shadow] duration-(--duration-exit) ease-out group-active/key:translate-y-[2px] group-active/key:shadow-(--device-key-shadow-pressed) group-active/key:duration-75">
        {/* The screen set into the face: the field draws on a clear ground, so this is its glass. */}
        <span data-part="lcd" className="relative block aspect-square rounded-[0.4em] text-(--device-lcd-ink) [background:var(--device-lcd)]">
          <span className="absolute inset-0 overflow-hidden rounded-[inherit]">
            <DitherField pattern={pattern} active={focused || active} paused={paused} />
          </span>
          {/* The glass's edge, over the dots. */}
          <span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-(--device-lcd-edge)" />
        </span>
      </span>
      <span data-part="lettering" className="flex max-w-full items-baseline gap-[0.6em] text-[0.6em] font-semibold uppercase leading-none tracking-[0.16em] [text-shadow:var(--device-engrave)]">
        <span className="min-w-0 truncate text-(--device-label)">{title}</span>
        {meta && <span className="shrink-0 tabular-nums text-(--device-label-quiet) drawn:opacity-70!">{meta}</span>}
      </span>
      <span id={descriptionId} hidden>
        {description}
      </span>
      <span className="sr-only">(opens in a new tab)</span>
    </a>
  );
}

/* --- Demo: a launcher's keys, one project on each ------------------------- */

const PROJECTS: { title: string; description: string; year: string; href: string; pattern: DitherPattern }[] = [
  { title: "Wavr", description: "Shader code in, motion graphics out.", year: "2026", href: "https://litt.design", pattern: "butterfly" },
  { title: "Carson", description: "Learn a layout by wrecking one.", year: "2026", href: "https://litt.design", pattern: "jellyfish" },
  { title: "litt.works", description: "Prints and long-form pieces.", year: "2024–26", href: "https://litt.design", pattern: "bloom" },
];
const FIRST = 350; // ms from power-up to the ghost's first key
const STEP = 1400; // ms on each key after that, so all three are lit inside 4 s

const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;

export default function Demo() {
  const [at, setAt] = useState(0); // the project on the readout
  const [lit, setLit] = useState(-1); // the key the ghost is on
  const [ghost, setGhost] = useState(false);
  const [paused, setPaused] = useState(true); // the screens hold their first frame until power-up
  const rootRef = useRef<HTMLDivElement>(null);
  const touched = useRef(false); // a hand has been here: the ghost doesn't come back

  /** Real input takes over from the ghost, for good, and wakes the screens. */
  function takeOver() {
    if (touched.current) return;
    touched.current = true;
    setGhost(false);
    setLit(-1);
    setPaused(false);
  }

  /** A hand on a key. Chrome also sends moves as the study slides in under a resting cursor; those don't count. */
  function onKeyMove(e: ReactPointerEvent<HTMLLIElement>, i: number) {
    if (e.pointerType === "mouse" && !e.movementX && !e.movementY) return;
    takeOver();
    setAt(i);
  }

  // Power up: the screens run and the ghost walks the keys, unless the host's tape is stopped; then follow its PLAY.
  // Under reduced motion each screen holds its first frame and there is no ghost.
  const powerUp = useEffectEvent(() => {
    if (touched.current || hostTransport(rootRef.current) === "stop") return;
    setPaused(false);
    if (!reducedMotion()) setGhost(true);
  });
  useEffect(() => {
    const root = rootRef.current;
    const start = requestAnimationFrame(powerUp);
    const host = root?.closest("[data-transport]");
    const observer = new MutationObserver(() => hostTransport(root) === "play" && powerUp());
    if (host) observer.observe(host, { attributes: true, attributeFilter: ["data-transport"] });
    return () => {
      cancelAnimationFrame(start);
      observer.disconnect();
    };
  }, []);

  // The ghost: a beat after power-up it lights the first key, then each next one in turn, and each
  // screen rings as it lands. A quiet tick for each, while the host's tape plays.
  useEffect(() => {
    if (!ghost) return;
    const root = rootRef.current;
    const id = setTimeout(
      () => {
        const next = (lit + 1) % PROJECTS.length;
        setLit(next);
        setAt(next);
        if (hostTransport(root) === "play") play("tick", { gain: 0.4, pitch: 0.94 + next * 0.06 });
      },
      lit === -1 ? FIRST : STEP,
    );
    return () => clearTimeout(id);
  }, [ghost, lit]);

  const project = PROJECTS[at];

  return (
    <div ref={rootRef} onPointerDownCapture={takeOver} onKeyDownCapture={takeOver} onFocusCapture={takeOver} className="@container w-full max-w-[480px] select-none">
      <div data-part="plate" className="relative isolate animate-enter overflow-hidden rounded-[1.25em] p-[0.9em] text-[clamp(11px,4cqw,14px)] [background:var(--device-body)] shadow-[var(--device-body-edge),0_1px_2px_rgb(0_0_0/0.06),0_16px_32px_-18px_rgb(0_0_0/0.3)] @[26rem]:p-[1.1em]">
        <div aria-hidden className="device-grain pointer-events-none absolute inset-0 -z-10 rounded-[inherit]" />

        {/* The readout: the year and the project in a line, for the key under the hand or the ghost. */}
        <div
          aria-hidden
          data-part="lcd"
          className="flex h-[3.3em] flex-col justify-between overflow-hidden rounded-[0.7em] px-[0.8em] pb-[0.5em] pt-[0.55em] text-(--device-lcd-ink) [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
        >
          <span data-part="chip" className="inline-flex items-center gap-[0.35em] self-start rounded-[0.4em] bg-white px-[0.42em] py-[0.24em] text-black">
            {/* Drawn, the ghost's pulse stays red, a signal; idle it is an unlit ring. */}
            <span className={ghost ? "size-[0.55em] animate-pulse rounded-full bg-(--device-rec) motion-reduce:animate-none drawn:bg-(--device-rec)!" : "size-[0.55em] rounded-full bg-black drawn:border"} />
            <span className="text-[0.58em] font-semibold uppercase leading-none tracking-[0.02em] tabular-nums">{project.year}</span>
          </span>
          <span key={at} className="animate-enter truncate text-[1.05em] font-light leading-none tracking-[-0.01em]">
            {project.description}
          </span>
        </div>

        {/* The keys, one project on each. */}
        <ul aria-label="Projects" className="mt-[0.8em] grid grid-cols-3 gap-[0.55em]">
          {PROJECTS.map((p, i) => (
            <li key={p.title} className="min-w-0" onPointerMove={(e) => onKeyMove(e, i)} onFocus={() => setAt(i)}>
              <DitherCard title={p.title} description={p.description} href={p.href} pattern={p.pattern} active={lit === i} paused={paused} />
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}