Skip to content
Display

Display

ParticleQrCode

Scannable QR codes that assemble from grains and cycle, with a pause button.

npx bezel-add add particle-qr-code

packages/ui/src/display/ParticleQrCode.tsx · 905 lines

"use client";

import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";

/*
 * ParticleQrCode: a real, scannable QR code that assembles itself.
 *
 * The symbol is encoded here, with no dependency, so the code on screen always
 * decodes to `value`. When it scrolls into view each dark module flies in from a
 * point under the code as a small cloud of grains that tighten onto the grid as
 * they land. Change `value` and the code dissolves back the way it came, swaps
 * while nothing is on screen, and assembles again.
 *
 * The whole animation is one clock running forwards or backwards, so leaving is
 * the arrival reversed and a change mid-flight retargets rather than restarts.
 * A still SVG of the same code sits underneath for no-JS and for the moment
 * before the canvas takes over. Reduced motion gets a short fade instead.
 *
 * Give it a list and it takes turns: each code holds, fully assembled, for
 * `hold` milliseconds, then scatters and re-forms as the next, forever. A
 * visible Pause and Next sit beside it, with a hairline that fills while the
 * code holds; that fill is the timer, so one paused animation stops both. It
 * holds while the pointer is over it, while focus is inside, off screen and in
 * a hidden tab. All codes in a list share the largest version among them, so
 * the square keeps its footprint from one to the next. Automatic changes are
 * not announced; the image's name and the caption change with the code, and a
 * press of Next is announced once. Reduced motion starts paused and fades
 * between codes; Next still works, and Play opts back in to taking turns.
 */

const CSS = `
.bz-qr{position:relative;display:inline-block;flex:none;vertical-align:middle;width:var(--bz-qr-size,240px);height:var(--bz-qr-size,240px);border-radius:var(--bz-radius-xl,16px);background:var(--bz-paper,#ffffff);color:var(--bz-ink,#0a0a0a)}
.bz-qr-set{display:inline-flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:16px 20px;max-width:100%;vertical-align:middle;color:var(--bz-ink,#0a0a0a);font-family:var(--bz-font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif)}
.bz-qr-side{display:flex;flex-direction:column;align-items:flex-start;gap:10px;min-width:0;width:12rem;max-width:100%}
.bz-qr-caption{margin:0;font-size:0.9375rem;font-weight:600;line-height:1.35;letter-spacing:-0.01em;color:var(--bz-ink,#0a0a0a);overflow-wrap:anywhere}
.bz-qr-count{margin:0;font:500 0.75rem/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bz-ink-muted,#4a4a4c);font-variant-numeric:tabular-nums}
.bz-qr-groove{position:relative;width:100%;height:3px;overflow:hidden;border-radius:999px;background:rgba(10,10,10,0.1)}
.bz-qr-fill{display:block;width:100%;height:100%;background:var(--bz-accent,#912c22);transform:scaleX(0);transform-origin:left center;animation:bz-qr-hold var(--bz-qr-hold,4500ms) linear forwards}
@keyframes bz-qr-hold{to{transform:scaleX(1)}}
.bz-qr-set[data-running="false"] .bz-qr-fill{animation-play-state:paused}
.bz-qr-set[data-playing="false"] .bz-qr-fill{opacity:0.4}
.bz-qr-controls{display:flex;flex-wrap:wrap;gap:8px}
.bz-qr-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-width:48px;min-height:48px;margin:0;padding:0 16px 0 14px;border:1px solid #8a8a8f;border-radius:999px;background:var(--bz-paper,#ffffff);color:var(--bz-ink,#0a0a0a);font:inherit;font-size:0.875rem;font-weight:600;line-height:1;cursor:pointer;transition:background-color 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1)),border-color 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1)),transform 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1))}
.bz-qr-button svg{flex:none;width:14px;height:14px}
.bz-qr-button:focus-visible{outline:2px solid var(--bz-focus-ring,#912c22);outline-offset:2px}
.bz-qr-button:active{transform:scale(0.97)}
@media (hover:hover){.bz-qr-button:hover{background:var(--bz-paper-raised,#f7f3ee);border-color:var(--bz-ink,#0a0a0a)}}
@media (prefers-reduced-motion:reduce){.bz-qr-button{transition:none}.bz-qr-button:active{transform:none}}
.bz-qr-still,.bz-qr-canvas{position:absolute;inset:0;display:block;width:100%;height:100%}
.bz-qr[data-canvas] .bz-qr-still{visibility:hidden}
.bz-qr-canvas{pointer-events:none}
.bz-qr-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.bz-qr-error{margin:8px 0 0;font:500 0.875rem/1.5 var(--bz-font-sans,ui-sans-serif,system-ui,sans-serif);color:var(--bz-danger,#b91c1c)}
`;

/* ---------------------------------------------------------- encoding */

export type QrLevel = "L" | "M" | "Q" | "H";

type QrMatrix = { modules: Uint8Array[]; size: number; version: number };

const ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";

/** [error-correction codewords per block, blocks, data codewords per block] for versions 1 to 4. */
const BLOCKS: Record<QrLevel, ReadonlyArray<readonly [number, number, number]>> = {
  L: [[7, 1, 19], [10, 1, 34], [15, 1, 55], [20, 1, 80]],
  M: [[10, 1, 16], [16, 1, 28], [26, 1, 44], [18, 2, 32]],
  Q: [[13, 1, 13], [22, 1, 22], [18, 2, 17], [26, 2, 24]],
  H: [[17, 1, 9], [28, 1, 16], [22, 2, 13], [16, 4, 9]],
};

const LEVEL_BITS: Record<QrLevel, number> = { L: 1, M: 0, Q: 3, H: 2 };

const GF_EXP = new Uint8Array(512);
const GF_LOG = new Uint8Array(256);
{
  let x = 1;
  for (let i = 0; i < 255; i++) {
    GF_EXP[i] = x;
    GF_LOG[x] = i;
    x <<= 1;
    if (x & 0x100) x ^= 0x11d;
  }
  for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
}

const gfMultiply = (a: number, b: number) => (a && b ? GF_EXP[GF_LOG[a] + GF_LOG[b]] : 0);

function errorCorrection(data: number[], degree: number) {
  let generator = [1];
  for (let i = 0; i < degree; i++) {
    const next = new Array<number>(generator.length + 1).fill(0);
    for (let j = 0; j < generator.length; j++) {
      next[j] ^= generator[j];
      next[j + 1] ^= gfMultiply(generator[j], GF_EXP[i]);
    }
    generator = next;
  }
  const out = new Array<number>(degree).fill(0);
  for (const byte of data) {
    const factor = byte ^ (out.shift() as number);
    out.push(0);
    for (let i = 0; i < degree; i++) out[i] ^= gfMultiply(generator[i + 1], factor);
  }
  return out;
}

const pushBits = (bits: number[], value: number, length: number) => {
  for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1);
};

const MASKS: Array<(row: number, col: number) => boolean> = [
  (r, c) => (r + c) % 2 === 0,
  (r) => r % 2 === 0,
  (_r, c) => c % 3 === 0,
  (r, c) => (r + c) % 3 === 0,
  (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
  (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
  (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
  (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];

function formatBits(level: QrLevel, mask: number) {
  const data = (LEVEL_BITS[level] << 3) | mask;
  let rem = data;
  for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
  return ((data << 10) | rem) ^ 0x5412;
}

function penalty(m: Uint8Array[]) {
  const n = m.length;
  let score = 0;
  for (let i = 0; i < n; i++) {
    let row = 1;
    let col = 1;
    for (let j = 1; j < n; j++) {
      if (m[i][j] === m[i][j - 1]) row++;
      else {
        if (row >= 5) score += row - 2;
        row = 1;
      }
      if (m[j][i] === m[j - 1][i]) col++;
      else {
        if (col >= 5) score += col - 2;
        col = 1;
      }
    }
    if (row >= 5) score += row - 2;
    if (col >= 5) score += col - 2;
  }
  for (let r = 0; r + 1 < n; r++) {
    for (let c = 0; c + 1 < n; c++) {
      const v = m[r][c];
      if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
    }
  }
  const finderA = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
  const finderB = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
  for (let i = 0; i < n; i++) {
    for (let j = 0; j + 11 <= n; j++) {
      let ra = true;
      let rb = true;
      let ca = true;
      let cb = true;
      for (let k = 0; k < 11; k++) {
        if (m[i][j + k] !== finderA[k]) ra = false;
        if (m[i][j + k] !== finderB[k]) rb = false;
        if (m[j + k][i] !== finderA[k]) ca = false;
        if (m[j + k][i] !== finderB[k]) cb = false;
      }
      score += 40 * (Number(ra) + Number(rb) + Number(ca) + Number(cb));
    }
  }
  let dark = 0;
  for (const row of m) for (const v of row) dark += v;
  score += Math.floor(Math.abs((dark * 100) / (n * n) - 50) / 5) * 10;
  return score;
}

/**
 * Encode `text` as a version 1 to 4 symbol. Throws when it does not fit.
 * `minVersion` pads a short value up to a larger grid, so codes shown in turn can share one footprint.
 */
export function buildQrSymbol(text: string, level: QrLevel = "M", minVersion = 1): QrMatrix {
  const alphanumeric = [...text].every((ch) => ALPHANUMERIC.includes(ch));
  const bytes = alphanumeric ? null : new TextEncoder().encode(text);
  const payload = bytes ? bytes.length * 8 : 11 * Math.floor(text.length / 2) + 6 * (text.length % 2);
  const needed = 4 + (bytes ? 8 : 9) + payload;
  let version = 0;
  for (let v = Math.max(1, Math.min(4, Math.floor(minVersion))); v <= 4; v++) {
    const [, blocks, perBlock] = BLOCKS[level][v - 1];
    if (needed <= blocks * perBlock * 8) {
      version = v;
      break;
    }
  }
  if (!version) throw new Error(`ParticleQrCode: the value does not fit a version 4 code at level ${level}.`);

  const [ecPerBlock, blockCount, dataPerBlock] = BLOCKS[level][version - 1];
  const capacity = blockCount * dataPerBlock * 8;
  const bits: number[] = [];
  if (bytes) {
    pushBits(bits, 0b0100, 4);
    pushBits(bits, bytes.length, 8);
    for (const b of bytes) pushBits(bits, b, 8);
  } else {
    pushBits(bits, 0b0010, 4);
    pushBits(bits, text.length, 9);
    for (let i = 0; i + 1 < text.length; i += 2) {
      pushBits(bits, ALPHANUMERIC.indexOf(text[i]) * 45 + ALPHANUMERIC.indexOf(text[i + 1]), 11);
    }
    if (text.length % 2) pushBits(bits, ALPHANUMERIC.indexOf(text[text.length - 1]), 6);
  }
  pushBits(bits, 0, Math.min(4, capacity - bits.length));
  while (bits.length % 8) bits.push(0);
  for (let i = 0; bits.length < capacity; i++) pushBits(bits, i % 2 ? 0x11 : 0xec, 8);

  const codewords: number[] = [];
  for (let i = 0; i < bits.length; i += 8) {
    let byte = 0;
    for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
    codewords.push(byte);
  }
  const dataBlocks: number[][] = [];
  const ecBlocks: number[][] = [];
  for (let b = 0; b < blockCount; b++) {
    const block = codewords.slice(b * dataPerBlock, (b + 1) * dataPerBlock);
    dataBlocks.push(block);
    ecBlocks.push(errorCorrection(block, ecPerBlock));
  }
  const stream: number[] = [];
  for (let i = 0; i < dataPerBlock; i++) for (const block of dataBlocks) pushBits(stream, block[i], 8);
  for (let i = 0; i < ecPerBlock; i++) for (const block of ecBlocks) pushBits(stream, block[i], 8);
  if (version > 1) for (let i = 0; i < 7; i++) stream.push(0);

  const n = 17 + 4 * version;
  let best: Uint8Array[] | null = null;
  let bestScore = Infinity;

  for (let mask = 0; mask < 8; mask++) {
    const m = Array.from({ length: n }, () => new Uint8Array(n));
    const fixed = Array.from({ length: n }, () => new Uint8Array(n));
    const put = (r: number, c: number, dark: boolean) => {
      if (r < 0 || c < 0 || r >= n || c >= n) return;
      m[r][c] = dark ? 1 : 0;
      fixed[r][c] = 1;
    };
    for (let i = 0; i < n; i++) {
      put(6, i, i % 2 === 0);
      put(i, 6, i % 2 === 0);
    }
    for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
      for (let dy = -1; dy <= 7; dy++) {
        for (let dx = -1; dx <= 7; dx++) {
          const inside = dx >= 0 && dx <= 6 && dy >= 0 && dy <= 6;
          const ring = dx === 0 || dx === 6 || dy === 0 || dy === 6;
          const core = dx >= 2 && dx <= 4 && dy >= 2 && dy <= 4;
          put(r0 + dy, c0 + dx, inside && (ring || core));
        }
      }
    }
    if (version > 1) {
      const centre = 4 * version + 10;
      for (let dy = -2; dy <= 2; dy++) {
        for (let dx = -2; dx <= 2; dx++) put(centre + dy, centre + dx, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
      }
    }
    // Reserve the format cells, leaving the two timing modules they cross alone.
    for (let i = 0; i <= 8; i++) {
      if (i !== 6) {
        put(8, i, false);
        put(i, 8, false);
      }
    }
    for (let i = 0; i < 8; i++) {
      put(8, n - 1 - i, false);
      put(n - 1 - i, 8, false);
    }
    put(n - 8, 8, true);

    let index = 0;
    for (let right = n - 1; right >= 1; right -= 2) {
      if (right === 6) right = 5;
      const upward = ((right + 1) & 2) === 0;
      for (let step = 0; step < n; step++) {
        const row = upward ? n - 1 - step : step;
        for (let side = 0; side < 2; side++) {
          const col = right - side;
          if (fixed[row][col]) continue;
          const bit = index < stream.length ? stream[index] : 0;
          index++;
          m[row][col] = bit ^ (MASKS[mask](row, col) ? 1 : 0);
        }
      }
    }

    const format = formatBits(level, mask);
    const bit = (i: number) => (format >>> i) & 1;
    for (let i = 0; i <= 5; i++) m[i][8] = bit(i);
    m[7][8] = bit(6);
    m[8][8] = bit(7);
    m[8][7] = bit(8);
    for (let i = 9; i < 15; i++) m[8][14 - i] = bit(i);
    for (let i = 0; i < 8; i++) m[8][n - 1 - i] = bit(i);
    for (let i = 8; i < 15; i++) m[n - 15 + i][8] = bit(i);
    m[n - 8][8] = 1;

    const score = penalty(m);
    if (score < bestScore) {
      bestScore = score;
      best = m;
    }
  }

  return { modules: best as Uint8Array[], size: n, version };
}

/* ------------------------------------------------------------ drawing */

const FLIGHT = 800;
const SPREAD = 600;
const TOTAL = SPREAD + FLIGHT;
const EXIT_RATE = 1.7;
const FADE = 150;

const easeOut = (t: number) => 1 - Math.pow(1 - t, 5);
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));

function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Where the code sits in its square, in CSS pixels. Shared by the still and the canvas. */
function geometry(size: number, n: number) {
  const module = Math.max(2, Math.floor((size * 0.74) / n));
  const code = module * n;
  return { module, left: Math.round((size - code) / 2), top: Math.round((size - code) * 0.32) };
}

type Mark = { tx: number; ty: number; dx: number; dy: number; angle: number; start: number; grains: Float32Array; reach: number };

type EngineConfig = {
  size: number;
  mode: "dust" | "beam";
  reduced: boolean;
  modules: Uint8Array[] | null;
  /** Called with the grid once it is fully assembled and nothing else is queued. */
  onSettled: (grid: Uint8Array[]) => void;
};

type Engine = {
  start: () => void;
  swap: () => void;
  rebuild: () => void;
  motionChanged: () => void;
  destroy: () => void;
};

function createEngine(root: HTMLElement, canvas: HTMLCanvasElement, config: () => EngineConfig): Engine | null {
  const context = canvas.getContext("2d");
  if (!context) return null;
  const ctx = context;

  let grid = config().modules;
  let pending: Uint8Array[] | null | undefined;
  let clock = 0;
  let open = false;
  let started = false;
  let dead = false;
  let raf = 0;
  let prev = 0;
  let wasReduced = config().reduced;
  let marks: Mark[] = [];
  let cell = 4;
  let grain = 1;
  let lx = 0;
  let ly = 0;
  let ink = "#0a0a0a";
  let dpr = 1;

  const span = () => (config().reduced ? FADE : TOTAL);

  function paint() {
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = ink;
    const half = cell / 2;
    if (config().reduced) {
      const a = clamp01(clock / FADE);
      if (a <= 0) return;
      ctx.globalAlpha = a;
      for (const m of marks) ctx.fillRect(m.tx - half, m.ty - half, cell, cell);
      ctx.globalAlpha = 1;
      return;
    }
    const mode = config().mode;
    for (const m of marks) {
      const t = clamp01((clock - m.start) / FLIGHT);
      if (t <= 0) continue;
      if (t >= 1) {
        ctx.globalAlpha = 1;
        ctx.fillRect(m.tx - half, m.ty - half, cell, cell);
        continue;
      }
      const e = easeOut(t);
      const k = Math.pow(1 - t, 4);
      const x = lx + m.dx * e;
      const y = ly + m.dy * e;
      ctx.globalAlpha = t < 0.2 ? t / 0.2 : 1;
      if (mode === "beam") {
        const stretch = 1 + 7 * k;
        const squash = 1 - 0.55 * k;
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(m.angle * Math.min(1, k * 5));
        ctx.fillRect((-cell * stretch) / 2, (-cell * squash) / 2, cell * stretch, cell * squash);
        ctx.restore();
        continue;
      }
      const scatter = 1 - e;
      if (scatter * m.reach < 0.6) {
        ctx.fillRect(x - half, y - half, cell, cell);
        continue;
      }
      const g = m.grains;
      for (let i = 0; i < g.length; i += 4) ctx.fillRect(x + g[i] + g[i + 2] * scatter, y + g[i + 1] + g[i + 3] * scatter, grain, grain);
    }
    ctx.globalAlpha = 1;
  }

  function rebuild() {
    if (dead) return;
    const { size, mode } = config();
    dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
    const px = Math.round(size * dpr);
    if (canvas.width !== px || canvas.height !== px) {
      canvas.width = px;
      canvas.height = px;
    }
    ink = getComputedStyle(root).color || ink;
    marks = [];
    if (!grid) {
      paint();
      return;
    }
    const n = grid.length;
    const g = geometry(size, n);
    cell = Math.max(2, Math.round(g.module * dpr));
    const ox = Math.round(g.left * dpr);
    const oy = Math.round(g.top * dpr);
    // The largest grain that tiles a module exactly, at five grains a side at most.
    const smallest = Math.max(Math.round(dpr), Math.ceil(cell / 5));
    grain = cell;
    for (let s = smallest; s <= cell; s++) {
      if (cell % s === 0) {
        grain = s;
        break;
      }
    }
    const perSide = cell / grain;
    lx = Math.round(px / 2);
    ly = px - Math.round(2 * dpr);
    for (let y = 0; y < n; y++) {
      for (let x = 0; x < n; x++) {
        if (!grid[y][x]) continue;
        const tx = ox + x * cell + cell / 2;
        const ty = oy + y * cell + cell / 2;
        marks.push({ tx, ty, dx: tx - lx, dy: ty - ly, angle: Math.atan2(ty - ly, tx - lx), start: 0, grains: new Float32Array(0), reach: 0 });
      }
    }
    // Nearest modules land first, so the code grows up out of the launch point.
    marks.sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy));
    const last = Math.max(1, marks.length - 1);
    marks.forEach((m, i) => {
      m.start = (SPREAD * i) / last;
    });
    if (mode === "dust") {
      const rand = seeded(0x2f6b1d ^ n);
      const reach = cell * 4;
      for (const m of marks) {
        const grains = new Float32Array(perSide * perSide * 4);
        let k = 0;
        let far = 0;
        for (let gy = 0; gy < perSide; gy++) {
          for (let gx = 0; gx < perSide; gx++) {
            grains[k++] = -cell / 2 + gx * grain;
            grains[k++] = -cell / 2 + gy * grain;
            const spread = reach * (0.4 + rand() * 0.6);
            const theta = rand() * Math.PI * 2;
            const along = rand() * reach - reach * 0.25;
            const sx = Math.cos(theta) * spread + Math.cos(m.angle) * along;
            const sy = Math.sin(theta) * spread + Math.sin(m.angle) * along;
            grains[k++] = sx;
            grains[k++] = sy;
            far = Math.max(far, Math.hypot(sx, sy));
          }
        }
        m.grains = grains;
        m.reach = far;
      }
    }
    paint();
  }

  function onPark(atOpen: boolean) {
    if (atOpen) {
      if (pending !== undefined) {
        open = false;
        run();
      } else if (grid) {
        config().onSettled(grid);
      }
      return;
    }
    if (pending !== undefined) {
      grid = pending;
      pending = undefined;
      rebuild();
    }
    if (grid) {
      open = true;
      run();
    }
  }

  // Park on the signed step, never on the flag, so a change mid-run retargets.
  function frame(now: number) {
    if (dead) return;
    const dt = Math.max(0, Math.min(64, now - prev));
    prev = now;
    const s = span();
    const step = dt * (open ? 1 : -EXIT_RATE);
    clock = Math.max(0, Math.min(s, clock + step));
    const parked = (step > 0 && clock >= s) || (step < 0 && clock <= 0);
    paint();
    if (parked) {
      raf = 0;
      onPark(open);
      return;
    }
    raf = requestAnimationFrame(frame);
  }

  function run() {
    if (dead || raf) return;
    prev = performance.now();
    raf = requestAnimationFrame(frame);
  }

  const onResize = () => {
    if (Math.max(1, Math.min(3, window.devicePixelRatio || 1)) !== dpr) rebuild();
  };
  window.addEventListener("resize", onResize);

  rebuild();

  return {
    start() {
      if (started || dead) return;
      started = true;
      root.dataset.canvas = "on";
      if (grid) {
        open = true;
        run();
      }
    },
    swap() {
      const next = config().modules;
      if (!started) {
        grid = next;
        rebuild();
        return;
      }
      pending = next;
      if (open) {
        open = false;
        run();
      } else if (!raf) {
        onPark(false);
      }
    },
    rebuild,
    motionChanged() {
      const was = wasReduced ? FADE : TOTAL;
      const reduced = config().reduced;
      clock = (clock / was) * (reduced ? FADE : TOTAL);
      wasReduced = reduced;
      paint();
      if (started) run();
    },
    destroy() {
      dead = true;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      window.removeEventListener("resize", onResize);
    },
  };
}

/* ---------------------------------------------------------- component */

const RM_QUERY = "(prefers-reduced-motion: reduce)";
const subscribeReducedMotion = (onChange: () => void) => {
  const query = window.matchMedia(RM_QUERY);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const readReducedMotion = () => window.matchMedia(RM_QUERY).matches;
const serverReducedMotion = () => false;

/** One code in a list: what it decodes to, its accessible name and a visible caption. */
export type ParticleQrItem = {
  value: string;
  /** Accessible name. Say where scanning goes. Defaults to the caption when it is a string, else the value. */
  label?: string;
  /** Shown beside the code and changed with it. */
  caption?: ReactNode;
};

export type ParticleQrCodeProps = {
  /**
   * What the code decodes to. A list takes turns, one code at a time. Each value fits up to 62 bytes of
   * text, or 90 characters from the QR alphanumeric set, at level M.
   */
  value: string | ReadonlyArray<string | ParticleQrItem>;
  /** Width and height of the code in CSS pixels. */
  size?: number;
  /** Accessible name of a single code. Say what scanning the code does. List entries carry their own. */
  label?: string;
  /** Error correction: L, M, Q or H. */
  level?: QrLevel;
  /** A cloud of grains per module, or a streak. */
  mode?: "dust" | "beam";
  /** Assemble when a quarter of it is in view, or as soon as it mounts. */
  play?: "view" | "mount";
  /** With a list: how long each code rests, fully assembled, before the next, in milliseconds. */
  hold?: number;
  /** With a list: take turns on their own. Reduced motion starts paused either way. */
  autoPlay?: boolean;
  /** With a list: called with the index of the code now showing. */
  onChange?: (index: number) => void;
  /** Applied to the outermost element. */
  className?: string;
};

const toItem = (entry: string | ParticleQrItem): ParticleQrItem => (typeof entry === "string" ? { value: entry } : entry);

export function ParticleQrCode({
  value,
  size = 240,
  label,
  level = "M",
  mode = "dust",
  play = "view",
  hold = 4500,
  autoPlay = true,
  onChange,
  className = "",
}: ParticleQrCodeProps) {
  const reduced = useSyncExternalStore(subscribeReducedMotion, readReducedMotion, serverReducedMotion);
  const listed = typeof value !== "string";
  const items = listed ? (value as ReadonlyArray<string | ParticleQrItem>).map(toItem) : [{ value: value as string }];
  const count = items.length;
  const valuesKey = items.map((item) => item.value).join("\n");

  const [active, setActive] = useState(0);
  const index = count ? Math.min(active, count - 1) : 0;
  const item: ParticleQrItem = items[index] ?? { value: "" };

  // Every code in a list is drawn at the largest version any of them needs.
  const minVersion = useMemo(() => {
    if (!listed) return 1;
    let version = 1;
    for (const v of valuesKey.split("\n")) {
      try {
        version = Math.max(version, buildQrSymbol(v, level).version);
      } catch {
        // A value that does not fit is reported when it comes round.
      }
    }
    return version;
  }, [listed, valuesKey, level]);

  const symbol = useMemo(() => {
    try {
      return buildQrSymbol(item.value, level, minVersion);
    } catch {
      return null;
    }
  }, [item.value, level, minVersion]);

  const [settledGrid, setSettledGrid] = useState<Uint8Array[] | null>(null);
  const settled = Boolean(symbol) && settledGrid === symbol?.modules;

  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const engineRef = useRef<Engine | null>(null);
  const configRef = useRef<EngineConfig>({ size, mode, reduced, modules: symbol?.modules ?? null, onSettled: setSettledGrid });
  configRef.current = { size, mode, reduced, modules: symbol?.modules ?? null, onSettled: setSettledGrid };
  const playRef = useRef(play);
  playRef.current = play;
  const [announcement, setAnnouncement] = useState("");
  const firstValue = useRef(true);
  const manualChange = useRef(false);

  const [playPref, setPlayPref] = useState<boolean | null>(null);
  const [inView, setInView] = useState(true);
  const [backgrounded, setBackgrounded] = useState(false);
  const [pointerOver, setPointerOver] = useState(false);
  const [focusInside, setFocusInside] = useState(false);
  const cycling = listed && count > 1;
  const playing = cycling && (playPref ?? (autoPlay && !reduced));
  const running = playing && inView && !backgrounded && !pointerOver && !focusInside;

  const fallbackName = item.caption && typeof item.caption === "string" ? `QR code: ${item.caption}` : `QR code for ${item.value}`;
  const name = item.label ?? (listed ? undefined : label) ?? fallbackName;

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const engine = createEngine(root, canvas, () => configRef.current);
    if (!engine) return;
    engineRef.current = engine;
    let io: IntersectionObserver | null = null;
    if (!("IntersectionObserver" in window)) {
      engine.start();
    } else {
      if (playRef.current === "mount") engine.start();
      io = new IntersectionObserver(
        (entries) => {
          const entry = entries[entries.length - 1];
          setInView(entry.isIntersecting);
          if (entry.isIntersecting && entry.intersectionRatio >= 0.25) engine.start();
        },
        { threshold: [0, 0.25] },
      );
      io.observe(root);
    }
    return () => {
      io?.disconnect();
      engine.destroy();
      engineRef.current = null;
      delete root.dataset.canvas;
    };
  }, []);

  useEffect(() => {
    const sync = () => setBackgrounded(document.visibilityState === "hidden");
    sync();
    document.addEventListener("visibilitychange", sync);
    return () => document.removeEventListener("visibilitychange", sync);
  }, []);

  useEffect(() => {
    engineRef.current?.swap();
    if (firstValue.current) {
      firstValue.current = false;
      return;
    }
    // A list changing on its own stays quiet; the name and caption carry it.
    if (listed) {
      if (manualChange.current && symbol) setAnnouncement(`Code ${index + 1} of ${count}. ${name}.`);
      manualChange.current = false;
      return;
    }
    setAnnouncement(symbol ? `QR code updated. ${name}.` : "");
  }, [symbol, index]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    engineRef.current?.rebuild();
  }, [size, mode]);

  useEffect(() => {
    engineRef.current?.motionChanged();
  }, [reduced]);

  const go = (next: number, manual: boolean) => {
    if (!count) return;
    const target = ((next % count) + count) % count;
    manualChange.current = manual;
    setActive(target);
    onChange?.(target);
  };

  const still = useMemo(() => {
    if (!symbol) return "";
    const g = geometry(size, symbol.size);
    let d = "";
    symbol.modules.forEach((row, y) => {
      row.forEach((dark, x) => {
        if (dark) d += `M${g.left + x * g.module} ${g.top + y * g.module}h${g.module}v${g.module}h-${g.module}z`;
      });
    });
    return d;
  }, [symbol, size]);

  const code = (
    <div
      ref={rootRef}
      className={listed || item.caption ? "bz-qr" : `bz-qr ${className}`.trim()}
      role="img"
      aria-label={name}
      style={{ ["--bz-qr-size" as string]: `${size}px` } as CSSProperties}
    >
      <svg className="bz-qr-still" viewBox={`0 0 ${size} ${size}`} aria-hidden="true" focusable="false">
        <path d={still} fill="currentColor" shapeRendering="crispEdges" />
      </svg>
      <canvas ref={canvasRef} className="bz-qr-canvas" aria-hidden="true" />
    </div>
  );

  const tail = (
    <>
      <span className="bz-qr-sr" aria-live="polite">
        {announcement}
      </span>
      {symbol ? null : (
        <p className="bz-qr-error" role="alert">
          This value is too long for a QR code.
        </p>
      )}
    </>
  );

  if (!listed && !item.caption) {
    return (
      <>
        <style dangerouslySetInnerHTML={{ __html: CSS }} />
        {code}
        {tail}
      </>
    );
  }

  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div
        className={`bz-qr-set ${className}`.trim()}
        data-playing={playing ? "true" : "false"}
        data-running={running ? "true" : "false"}
        style={{ ["--bz-qr-hold" as string]: `${Math.max(1000, hold)}ms` } as CSSProperties}
        onPointerEnter={(event) => {
          if (event.pointerType === "mouse") setPointerOver(true);
        }}
        onPointerLeave={() => setPointerOver(false)}
        onFocus={() => setFocusInside(true)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusInside(false);
        }}
      >
        {code}
        <div className="bz-qr-side">
          {item.caption ? <p className="bz-qr-caption">{item.caption}</p> : null}
          {cycling ? (
            <>
              <p className="bz-qr-count">
                Code {index + 1} of {count}
              </p>
              <div className="bz-qr-groove" aria-hidden="true">
                {settled ? (
                  <span key={`${index}-${item.value}`} className="bz-qr-fill" onAnimationEnd={() => go(index + 1, false)} />
                ) : null}
              </div>
              <div className="bz-qr-controls">
                <button
                  type="button"
                  className="bz-qr-button"
                  aria-label={playing ? "Pause code rotation" : "Play code rotation"}
                  onClick={() => setPlayPref(!playing)}
                >
                  {playing ? (
                    <svg viewBox="0 0 14 14" aria-hidden="true">
                      <rect x="2.5" y="1.5" width="3" height="11" rx="1" fill="currentColor" />
                      <rect x="8.5" y="1.5" width="3" height="11" rx="1" fill="currentColor" />
                    </svg>
                  ) : (
                    <svg viewBox="0 0 14 14" aria-hidden="true">
                      <path d="M3.5 2.2v9.6c0 .6.7 1 1.2.6l7.2-4.8a.7.7 0 0 0 0-1.2L4.7 1.6c-.5-.4-1.2 0-1.2.6z" fill="currentColor" />
                    </svg>
                  )}
                  {playing ? "Pause" : "Play"}
                </button>
                <button type="button" className="bz-qr-button" aria-label="Next code" onClick={() => go(index + 1, true)}>
                  <svg viewBox="0 0 14 14" aria-hidden="true">
                    <path d="M5 2.5 9.5 7 5 11.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                  Next
                </button>
              </div>
            </>
          ) : null}
        </div>
      </div>
      {tail}
    </>
  );
}