{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"lloyd-avatars","type":"registry:ui","title":"Lloyd Avatars","description":"A reviewer facepile in which every face owns the part of the frame nearest to it. Overlap is impossible rather than prevented, so nothing has to be re-tuned per head count — drag one face into a corner and its share collapses while the rest swell to take it up.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/lloyd-avatars.tsx","target":"components/ui/lloyd-avatars.tsx","content":"'use client';\n\nimport './lloyd-avatars.css';\n\nimport { useEffect, useRef, useState, type KeyboardEvent } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A reviewer group whose packing is solved rather than authored.\n *\n * Each face is a Voronoi site in the frame, and the tile it owns is the set of points\n * closer to it than to anybody else:\n *\n *     V_i = { p in F : |p − s_i| <= |p − s_j| for all j != i }\n *\n * which is an intersection of half-planes, one per other face, each bounded by the\n * perpendicular bisector of that pair — so a tile is the frame polygon clipped once\n * against every other site. The arrangement then walks downhill on the energy of a\n * centroidal Voronoi tessellation,\n *\n *     E(s) = sum_i integral_{V_i} |p − s_i|^2 dA,     dE/ds_i = 2 * A_i * (s_i − c_i)\n *\n * where A_i is the tile's area and c_i its area centroid, both read straight off the\n * clipped polygon by the shoelace formula. One Lloyd iteration\n *\n *     s_i <- s_i + L * (c_i − s_i)\n *\n * is therefore a gradient step preconditioned by the tile's own mass, −(L/2A_i) dE/ds_i,\n * and its fixed point is the centroidal condition s_i = c_i: every face sitting at the\n * centre of mass of its own territory. Integrated at a fixed 1/120 s step so the same\n * drag settles the same way on a 60 Hz and a 144 Hz screen.\n *\n * This is not a grid under a rounded mask, not an eased keyframe between two authored\n * layouts, and not a force-directed cluster with a collision check. What follows from\n * actually solving it:\n *\n *   - Overlap is impossible rather than prevented. Two tiles are separated by the\n *     bisector of their two faces, so they are disjoint by construction: there is no\n *     collision test to lose, at any head count, mid-drag, at any frame rate.\n *   - Nothing is authored per head count. Seat an eighth face and the neighbours' tiles\n *     each gain one more half-plane; unseat one and the hole closes because the same\n *     twenty lines now solve a seven-site problem.\n *\n * The face a press picks up is the one whose tile the press landed in, because that is\n * what a Voronoi diagram is. It is then pinned to the pointer while the other seven keep\n * descending around it.\n */\n\n/** Seconds per iteration. The descent is first order and stable for any L <= 1, so the\n *  fixed step is here for reproducibility, not for stability. */\nconst STEP = 1 / 120;\n/** Iterations per frame, capped. The 0.05 s delta clamp already bounds a frame to six of\n *  them; twelve is the backstop for a clock that jumped, not a budget. */\nconst MAX_STEPS = 12;\n/** L: the fraction of the way to its centroid a face moves per iteration. Time constant\n *  STEP/L = 0.12 s, which is the lag on the other faces while one is dragged. L = 1 is\n *  plain Lloyd and lands within a frame, too fast to read; 0.02 takes three seconds. */\nconst RELAX = 0.07;\n/** Reduced motion runs that same iteration to its fixed point instead. A twentieth of a\n *  pixel of movement is converged at this size, and the measured worst case — eight faces\n *  from the stacked start — is 54 iterations, so 120 is a ceiling with room over it. */\nconst SETTLE_TOL = 0.05;\nconst SETTLE_MAX = 120;\n/** Seconds for a newly seated face's art to come up. Only the art fades: the site joins\n *  the tessellation on the frame it is seated, which is the whole point. */\nconst FADE = 0.22;\n\n/** Frame corner radius in px, and arc samples per corner. Five segments hold the polygon\n *  within CORNER * (1 − cos 9deg) = 0.17px of a true arc, under one device pixel at 2x. */\nconst CORNER = 14;\nconst ARC_SEGS = 5;\n/** How close to the frame edge a site may be pushed, px. A site on the boundary owns a\n *  wedge with no room for a monogram, and nine is the least a legible disc needs. */\nconst EDGE_KEEP = 9;\n/** Half the seam between two tiles, px. A tile is its cell eroded by this, so the 6px\n *  gutter is geometry that the clipper produced and not a stroke laid over a join. */\nconst GUTTER = 3;\n/** Monogram radius as a fraction of the site's clearance to its own cell edge. Under a\n *  half, so a disc cannot cross a seam whatever shape the cell around it takes. */\nconst DISC = 0.46;\n/** Least clearance, px, at which a tile is worth lettering. The stacked start never gets\n *  tighter than 15px of room, so the monograms are there from the first frame at a third\n *  of their settled size and grow as the tiles open up. */\nconst MIN_ROOM = 13;\n/** Arrow-key step, px. A site's move carries every boundary it shares half as far, so\n *  one press shifts a seam by exactly one gutter — visible, and ten presses cross a tile. */\nconst NUDGE = 12;\n/** Vertex capacity per polygon. The frame is a 24-gon, seven bisector clips can add one\n *  vertex each, and the erosion at most one per edge again: 62 worst case, doubled. */\nconst VERTS = 128;\n/** Two colours and a tint — ink, one accent, and that accent under a tenth of an alpha. */\nconst INK = '234, 243, 255';\nconst ACCENT = '134, 224, 203';\nconst TAU = Math.PI * 2;\n\n/** The roster. Distinct initials A–H, so a monogram is one legible letter at tile size. */\nconst ROSTER = [\n  { first: 'Ana', last: 'Reyes' },\n  { first: 'Ben', last: 'Okafor' },\n  { first: 'Cleo', last: 'Marsh' },\n  { first: 'Devi', last: 'Rao' },\n  { first: 'Eli', last: 'Nakamura' },\n  { first: 'Farah', last: 'Osman' },\n  { first: 'Gil', last: 'Andrade' },\n  { first: 'Hana', last: 'Weiss' },\n];\n/** Seats taken on load: seven in, one open, so the eighth can be seated and watched. */\nconst SEATED = 7;\n\n/** One face: its site, the cell that site owns, and what the shoelace pass read off it. */\ninterface Tile {\n  readonly member: number;\n  x: number;\n  y: number;\n  /** The cell as a flat x,y loop, rebuilt every iteration. */\n  readonly cell: Float64Array;\n  cellLen: number;\n  area: number;\n  cx: number;\n  cy: number;\n  /** Distance from the site to the nearest edge of its own cell — the room it has. */\n  room: number;\n  fade: number;\n}\n\ninterface LloydState {\n  /** The frame as a convex polygon. Every cell starts life as a copy of it. */\n  readonly frame: Float64Array;\n  readonly frameLen: number;\n  /** The frame eroded by EDGE_KEEP: the region a site is allowed to stand in. */\n  readonly roam: Float64Array;\n  readonly roamLen: number;\n  /** Ping-pong buffers for the clipper, so a frame of this allocates nothing. */\n  readonly bufA: Float64Array;\n  readonly bufB: Float64Array;\n  readonly midX: number;\n  readonly midY: number;\n  tiles: Tile[];\n  /** Roster index held by the pointer, pinned by the keyboard, and lit by the roster. */\n  heldMember: number;\n  pinMember: number;\n  spot: number;\n  /** Site minus pointer at the moment of the grab, so taking hold never jumps a face. */\n  grabX: number;\n  grabY: number;\n  clock: number;\n  carry: number;\n  /** Solve to the fixed point instead of stepping toward it. Set under reduced motion,\n   *  where the loop never runs and one L per repaint would never arrive anywhere. */\n  snap: boolean;\n}\n\n/**\n * The frame as a convex polygon: a rounded rectangle sampled ARC_SEGS times per corner.\n * Convex is the entire requirement — clipping a convex polygon by a half-plane leaves a\n * convex polygon, which is what keeps every cell a single simple loop with no bookkeeping.\n * Wound so the shoelace area comes out positive (clockwise on screen, y down), and\n * Sutherland-Hodgman preserves winding, so the inward normal of any edge of any cell\n * derived from this is always (−dy, dx).\n */\nfunction framePolygon(width: number, height: number, out: Float64Array): number {\n  // Inset by the hairline the frame is stroked with, so the stroke lands inside the\n  // canvas instead of losing half its width off the edge.\n  const x0 = 1;\n  const y0 = 1;\n  const x1 = width - 1;\n  const y1 = height - 1;\n  const r = Math.max(0, Math.min(CORNER, (x1 - x0) / 2, (y1 - y0) / 2));\n  let n = 0;\n  const arc = (cx: number, cy: number, from: number) => {\n    for (let i = 0; i <= ARC_SEGS; i += 1) {\n      const a = from + (Math.PI / 2) * (i / ARC_SEGS);\n      out[n * 2] = cx + r * Math.cos(a);\n      out[n * 2 + 1] = cy + r * Math.sin(a);\n      n += 1;\n    }\n  };\n  arc(x1 - r, y0 + r, -Math.PI / 2);\n  arc(x1 - r, y1 - r, 0);\n  arc(x0 + r, y1 - r, Math.PI / 2);\n  arc(x0 + r, y0 + r, Math.PI);\n  return n;\n}\n\n/**\n * Sutherland-Hodgman against one half-plane, keeping { p : n.p <= c }. Every piece of\n * geometry in this file is this function: a cell is the frame clipped by one of these per\n * other face, and a tile is that cell clipped by one per edge of itself.\n */\nfunction clipHalfPlane(\n  src: Float64Array,\n  n: number,\n  nx: number,\n  ny: number,\n  c: number,\n  out: Float64Array,\n): number {\n  let m = 0;\n  for (let i = 0; i < n; i += 1) {\n    const j = (i + 1) % n;\n    const ax = src[i * 2];\n    const ay = src[i * 2 + 1];\n    const bx = src[j * 2];\n    const by = src[j * 2 + 1];\n    const da = nx * ax + ny * ay - c;\n    const db = nx * bx + ny * by - c;\n    if (da <= 0) {\n      out[m * 2] = ax;\n      out[m * 2 + 1] = ay;\n      m += 1;\n    }\n    // Strict signs on both ends: an endpoint exactly on the plane is already kept above,\n    // and emitting it twice would leave a zero-length edge in the loop.\n    if ((da < 0 && db > 0) || (da > 0 && db < 0)) {\n      const t = da / (da - db);\n      out[m * 2] = ax + (bx - ax) * t;\n      out[m * 2 + 1] = ay + (by - ay) * t;\n      m += 1;\n    }\n  }\n  return m;\n}\n\n/** Trace a flat polygon as a path. */\nfunction tracePoly(context: CanvasRenderingContext2D, p: Float64Array, n: number): void {\n  context.beginPath();\n  context.moveTo(p[0], p[1]);\n  for (let i = 1; i < n; i += 1) context.lineTo(p[i * 2], p[i * 2 + 1]);\n  context.closePath();\n}\n\n/**\n * Erode a convex polygon by `gap`: clip it against each of its own edges pushed inward,\n * which for a convex polygon is exactly the inward offset. The planes are read off `src`\n * throughout, since a half-plane intersection does not care what order it is taken in, and\n * the answer is left in `a`. Returns 0 when the gap has eaten the polygon, which is the\n * right answer for a sliver of a cell.\n */\nfunction erodeInto(\n  src: Float64Array,\n  n: number,\n  gap: number,\n  a: Float64Array,\n  b: Float64Array,\n): number {\n  let from = a;\n  let to = b;\n  let m = n;\n  for (let i = 0; i < n * 2; i += 1) a[i] = src[i];\n  for (let i = 0; i < n; i += 1) {\n    const j = (i + 1) % n;\n    const ax = src[i * 2];\n    const ay = src[i * 2 + 1];\n    const dx = src[j * 2] - ax;\n    const dy = src[j * 2 + 1] - ay;\n    const len = Math.hypot(dx, dy);\n    if (len < 1e-6) continue;\n    // The outward unit normal: the inward one, (−dy, dx)/len, negated.\n    const nx = dy / len;\n    const ny = -dx / len;\n    m = clipHalfPlane(from, m, nx, ny, nx * ax + ny * ay - gap, to);\n    if (m < 3) return 0;\n    const swap = from;\n    from = to;\n    to = swap;\n  }\n  if (from !== a) for (let i = 0; i < m * 2; i += 1) a[i] = from[i];\n  return m;\n}\n\n/** Area, area centroid and the site's clearance to its own cell edge, in one pass. */\nfunction measure(tile: Tile): void {\n  const p = tile.cell;\n  const n = tile.cellLen;\n  if (n < 3) {\n    tile.area = 0;\n    tile.cx = tile.x;\n    tile.cy = tile.y;\n    tile.room = 0;\n    return;\n  }\n  let twice = 0;\n  let cx = 0;\n  let cy = 0;\n  let room = Number.POSITIVE_INFINITY;\n  for (let i = 0; i < n; i += 1) {\n    const j = (i + 1) % n;\n    const ax = p[i * 2];\n    const ay = p[i * 2 + 1];\n    const bx = p[j * 2];\n    const by = p[j * 2 + 1];\n    // Each cross term is twice the signed area of the triangle back to the origin, and the\n    // centroid is those crosses weighted by the edge midpoints — the origin cancels out.\n    const cross = ax * by - bx * ay;\n    twice += cross;\n    cx += (ax + bx) * cross;\n    cy += (ay + by) * cross;\n    const dx = bx - ax;\n    const dy = by - ay;\n    const len = Math.hypot(dx, dy);\n    if (len > 1e-6) {\n      const gap = (-dy * (tile.x - ax) + dx * (tile.y - ay)) / len;\n      if (gap < room) room = gap;\n    }\n  }\n  tile.area = twice / 2;\n  tile.cx = twice === 0 ? tile.x : cx / (3 * twice);\n  tile.cy = twice === 0 ? tile.y : cy / (3 * twice);\n  tile.room = Number.isFinite(room) ? Math.max(0, room) : 0;\n}\n\n/** One face's cell: the frame clipped by the bisector of it and every other face. */\nfunction cellOf(state: LloydState, tile: Tile): void {\n  const { frame, frameLen, bufA, bufB } = state;\n  let from = bufA;\n  let to = bufB;\n  let n = frameLen;\n  for (let i = 0; i < frameLen * 2; i += 1) bufA[i] = frame[i];\n  const own = tile.x * tile.x + tile.y * tile.y;\n  for (const other of state.tiles) {\n    if (other === tile) continue;\n    const dx = other.x - tile.x;\n    const dy = other.y - tile.y;\n    // Two faces on the same pixel have no bisector between them. That pair simply divides\n    // nothing this iteration, and the next one moves them apart.\n    if (dx * dx + dy * dy < 1e-9) continue;\n    // |p − s|^2 <= |p − o|^2 rearranges to 2(o − s).p <= |o|^2 − |s|^2.\n    const c = other.x * other.x + other.y * other.y - own;\n    n = clipHalfPlane(from, n, 2 * dx, 2 * dy, c, to);\n    if (n < 3) break;\n    const swap = from;\n    from = to;\n    to = swap;\n  }\n  tile.cellLen = n < 3 ? 0 : n;\n  for (let i = 0; i < tile.cellLen * 2; i += 1) tile.cell[i] = from[i];\n}\n\n/** Rebuild every cell against the current sites and measure them. No motion. */\nfunction tessellate(state: LloydState): void {\n  for (const tile of state.tiles) {\n    cellOf(state, tile);\n    measure(tile);\n  }\n}\n\n/** Held by the pointer or pinned by the keyboard: either way the solver leaves it alone. */\nfunction pinned(state: LloydState, tile: Tile): boolean {\n  return tile.member === state.heldMember || tile.member === state.pinMember;\n}\n\n/**\n * One Lloyd iteration. Every cell is built against the same set of positions and only then\n * is anything moved — a Jacobi sweep rather than Gauss-Seidel, so the arrangement cannot\n * depend on the order the roster happens to be in. Returns the largest step taken, which\n * at L = 1 is the residual and is the convergence test below.\n */\nfunction relax(state: LloydState, lambda: number): number {\n  tessellate(state);\n  let largest = 0;\n  for (const tile of state.tiles) {\n    if (pinned(state, tile) || tile.cellLen < 3) continue;\n    const dx = lambda * (tile.cx - tile.x);\n    const dy = lambda * (tile.cy - tile.y);\n    tile.x += dx;\n    tile.y += dy;\n    const step = Math.hypot(dx, dy);\n    if (step > largest) largest = step;\n  }\n  return largest;\n}\n\n/** The answer rather than the route to it: plain Lloyd, L = 1, run to its fixed point. */\nfunction settle(state: LloydState): void {\n  for (let i = 0; i < SETTLE_MAX; i += 1) {\n    if (relax(state, 1) < SETTLE_TOL) break;\n  }\n  // Nothing here is on its way anywhere, so nothing may be mid-fade either.\n  for (const tile of state.tiles) tile.fade = 1;\n}\n\n/** A tile with its own clipping buffer. Allocated when a face is seated, never per frame. */\nfunction makeTile(member: number, x: number, y: number, fade: number): Tile {\n  return {\n    member,\n    x,\n    y,\n    cell: new Float64Array(VERTS * 2),\n    cellLen: 0,\n    area: 0,\n    cx: x,\n    cy: y,\n    room: 0,\n    fade,\n  };\n}\n\n/**\n * Put a face at (x, y), or at the nearest point of the region a site is allowed to stand\n * in. The out-of-bounds case is a true projection onto the boundary rather than a clamp\n * per edge: a pointer under capture is regularly a long way outside the frame, and pushing\n * it back along one edge normal at a time accumulates across the twenty-four of them and\n * lands the face somewhere the pointer never was.\n */\nfunction pinTo(state: LloydState, tile: Tile, x: number, y: number): void {\n  const p = state.roam;\n  const n = state.roamLen;\n  if (n < 3) {\n    // A frame too small to hold a face clear of its own edge. The middle is the only\n    // defensible answer, and it keeps the cell non-empty.\n    tile.x = state.midX;\n    tile.y = state.midY;\n    return;\n  }\n  let inside = true;\n  for (let i = 0; i < n && inside; i += 1) {\n    const j = (i + 1) % n;\n    const ax = p[i * 2];\n    const ay = p[i * 2 + 1];\n    const dx = p[j * 2] - ax;\n    const dy = p[j * 2 + 1] - ay;\n    if (-dy * (x - ax) + dx * (y - ay) < 0) inside = false;\n  }\n  if (inside) {\n    tile.x = x;\n    tile.y = y;\n    return;\n  }\n  let best = Number.POSITIVE_INFINITY;\n  for (let i = 0; i < n; i += 1) {\n    const j = (i + 1) % n;\n    const ax = p[i * 2];\n    const ay = p[i * 2 + 1];\n    const dx = p[j * 2] - ax;\n    const dy = p[j * 2 + 1] - ay;\n    const span = dx * dx + dy * dy;\n    const t = span < 1e-9 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / span));\n    const px = ax + dx * t;\n    const py = ay + dy * t;\n    const d = (x - px) * (x - px) + (y - py) * (y - py);\n    if (d < best) {\n      best = d;\n      tile.x = px;\n      tile.y = py;\n    }\n  }\n}\n\n/** The face nearest a point, which is the face whose tile the point is in. */\nfunction tileAt(state: LloydState, x: number, y: number): Tile | null {\n  let best: Tile | null = null;\n  let near = Number.POSITIVE_INFINITY;\n  for (const tile of state.tiles) {\n    const d = (tile.x - x) * (tile.x - x) + (tile.y - y) * (tile.y - y);\n    if (d < near) {\n      near = d;\n      best = tile;\n    }\n  }\n  return best;\n}\n\nfunction tileOf(state: LloydState, member: number): Tile | null {\n  for (const tile of state.tiles) if (tile.member === member) return tile;\n  return null;\n}\n\n/**\n * Seat a face. The newcomer is dropped half way out toward the far corner of whichever\n * tile is largest, so the split it forces begins where there is most room to give — and\n * it is never coincident with the site already there, which a bisector needs.\n */\nfunction seat(state: LloydState, member: number): void {\n  let host: Tile | null = null;\n  for (const tile of state.tiles) if (!host || tile.area > host.area) host = tile;\n  let x = state.midX;\n  let y = state.midY;\n  if (host && host.cellLen >= 3) {\n    let far = 0;\n    for (let i = 0; i < host.cellLen; i += 1) {\n      const vx = host.cell[i * 2] - host.cx;\n      const vy = host.cell[i * 2 + 1] - host.cy;\n      const d = vx * vx + vy * vy;\n      if (d > far) {\n        far = d;\n        x = host.cx + vx * 0.5;\n        y = host.cy + vy * 0.5;\n      }\n    }\n  }\n  const tile = makeTile(member, x, y, 0);\n  pinTo(state, tile, x, y);\n  state.tiles.push(tile);\n}\n\n/** Bring the tessellation in line with the roster: take the leavers out, seat the joiners. */\nfunction reconcile(state: LloydState, seated: readonly boolean[]): void {\n  for (let i = state.tiles.length - 1; i >= 0; i -= 1) {\n    const tile = state.tiles[i];\n    if (seated[tile.member]) continue;\n    if (state.heldMember === tile.member) state.heldMember = -1;\n    state.tiles.splice(i, 1);\n  }\n  for (let member = 0; member < ROSTER.length; member += 1) {\n    if (seated[member] && !tileOf(state, member)) seat(state, member);\n  }\n}\n\n/** Everything the scene owns, sized to this frame. Re-run on every resize. */\nfunction build(\n  { width, height }: SceneSetupContext,\n  seated: readonly boolean[],\n  places: number[],\n  snap: boolean,\n): LloydState {\n  const frame = new Float64Array(VERTS * 2);\n  const frameLen = framePolygon(width, height, frame);\n  const bufA = new Float64Array(VERTS * 2);\n  const bufB = new Float64Array(VERTS * 2);\n  const roam = new Float64Array(VERTS * 2);\n  const roamLen = erodeInto(frame, frameLen, EDGE_KEEP, bufA, bufB);\n  for (let i = 0; i < roamLen * 2; i += 1) roam[i] = bufA[i];\n\n  const state: LloydState = {\n    frame,\n    frameLen,\n    roam,\n    roamLen,\n    bufA,\n    bufB,\n    midX: width / 2,\n    midY: height / 2,\n    tiles: [],\n    heldMember: -1,\n    pinMember: -1,\n    spot: -1,\n    grabX: 0,\n    grabY: 0,\n    clock: 0,\n    carry: 0,\n    snap,\n  };\n  // Sites come from the fractions of the frame they were last at, not from pixels of the\n  // old one, so a resize re-seats each face where it already was instead of restarting the\n  // descent. On the first build those fractions are the overlapped avatar row.\n  for (let member = 0; member < ROSTER.length; member += 1) {\n    if (!seated[member]) continue;\n    const tile = makeTile(member, state.midX, state.midY, 1);\n    pinTo(state, tile, places[member * 2] * width, places[member * 2 + 1] * height);\n    state.tiles.push(tile);\n  }\n  if (snap) settle(state);\n  else tessellate(state);\n  return state;\n}\n\n/** One face: the territory it owns, and the monogram sitting in the middle of it. */\nfunction drawTile(context: CanvasRenderingContext2D, state: LloydState, tile: Tile): void {\n  const n = erodeInto(tile.cell, tile.cellLen, GUTTER, state.bufA, state.bufB);\n  if (n < 3) return;\n  const lit = pinned(state, tile) || tile.member === state.spot;\n  const fade = tile.fade;\n  const r = DISC * tile.room;\n\n  context.save();\n  tracePoly(context, state.bufA, n);\n  // A vignette per panel, centred on the face rather than on the panel, so neighbours read\n  // apart even where their shared seam crosses empty frame. The base alpha climbs with the\n  // roster index, which is what keeps eight identical panels from reading as one surface.\n  const wash = context.createRadialGradient(tile.x, tile.y, 0, tile.x, tile.y, Math.max(8, tile.room * 2.4));\n  const base = 0.028 + 0.006 * tile.member;\n  wash.addColorStop(0, lit ? `rgba(${ACCENT}, ${0.095 * fade})` : `rgba(${INK}, ${(base + 0.045) * fade})`);\n  wash.addColorStop(1, lit ? `rgba(${ACCENT}, ${0.038 * fade})` : `rgba(${INK}, ${base * fade})`);\n  context.fillStyle = wash;\n  context.fill();\n  context.lineWidth = 1;\n  context.strokeStyle = lit ? `rgba(${ACCENT}, ${0.45 * fade})` : `rgba(${INK}, ${0.11 * fade})`;\n  context.stroke();\n\n  // Everything inside is clipped to the panel, so a monogram is cropped by its own\n  // territory exactly the way a photograph in a mosaic would be.\n  context.clip();\n  if (r >= 4) {\n    context.beginPath();\n    context.arc(tile.x, tile.y, r, 0, TAU);\n    context.fillStyle = lit ? `rgba(${ACCENT}, ${0.16 * fade})` : `rgba(${INK}, ${0.075 * fade})`;\n    context.fill();\n    context.strokeStyle = lit ? `rgba(${ACCENT}, ${0.62 * fade})` : `rgba(${INK}, ${0.24 * fade})`;\n    context.stroke();\n  }\n  if (tile.room >= MIN_ROOM) {\n    context.textAlign = 'center';\n    context.textBaseline = 'middle';\n    context.font = `500 ${(r * 1.05).toFixed(1)}px ui-monospace, \"SFMono-Regular\", Menlo, monospace`;\n    context.fillStyle = lit ? `rgba(255, 255, 255, ${0.92 * fade})` : `rgba(${INK}, ${0.78 * fade})`;\n    context.fillText(ROSTER[tile.member].first.charAt(0), tile.x, tile.y);\n  }\n  context.restore();\n}\n\nfunction paint(\n  context: CanvasRenderingContext2D,\n  state: LloydState,\n  width: number,\n  height: number,\n): void {\n  context.clearRect(0, 0, width, height);\n  context.lineJoin = 'round';\n  context.lineCap = 'round';\n\n  // The frame's own ground, from the very polygon the cells are cut out of.\n  tracePoly(context, state.frame, state.frameLen);\n  context.fillStyle = `rgba(${INK}, 0.022)`;\n  context.fill();\n\n  for (const tile of state.tiles) drawTile(context, state, tile);\n\n  tracePoly(context, state.frame, state.frameLen);\n  context.lineWidth = 1;\n  context.strokeStyle = `rgba(${INK}, 0.16)`;\n  context.stroke();\n}\n\n/** The line an avatar group carries. Real grammar at every count this one can reach. */\nfunction summarise(names: readonly string[]): string {\n  if (names.length <= 2) return names.join(' and ');\n  if (names.length === 3) return `${names[0]}, ${names[1]} and ${names[2]}`;\n  const rest = names.length - 3;\n  return `${names[0]}, ${names[1]}, ${names[2]} and ${rest} ${rest === 1 ? 'other' : 'others'}`;\n}\n\n/**\n * A reviewer group. The seat gauge, the frame, the names line and the roster are one\n * control over one tessellation: the chips seat and unseat, a press drags whichever face\n * it landed on, and the arrow keys pin a face and walk it.\n */\n/** `compact` is the 298x240 catalogue card: the same eight faces, the same tessellation and\n *  the same drag, with the sentence naming the reviewers and the hint dropped. Every seat is\n *  stored as a fraction of the frame and the frame polygon is measured from the canvas box, so\n *  the packing is scale-free and a shorter frame is the same solve. See `lloyd-avatars.css`. */\nexport type LloydAvatarsProps = { compact?: boolean };\n\nexport function LloydAvatars({ compact = false }: LloydAvatarsProps) {\n  const reduced = useReducedMotion();\n  const [seated, setSeated] = useState<readonly boolean[]>(() =>\n    ROSTER.map((_, index) => index < SEATED),\n  );\n  /**\n   * Where each face is, as a fraction of the frame. Starts as the overlapped avatar row\n   * every one of these components is handed — that row is the unsolved state, and the first\n   * second of the loop is it being solved.\n   */\n  const placesRef = useRef<number[]>(\n    ROSTER.flatMap((_, index) => [\n      0.3 + (0.4 * index) / (ROSTER.length - 1),\n      // Alternated either side of the mid-line. A perfectly collinear start is a symmetric\n      // configuration and Lloyd preserves symmetry exactly: the row would descend into\n      // vertical bands, which is a critical point of the energy and not the answer.\n      index % 2 === 0 ? 0.445 : 0.555,\n    ]),\n  );\n  /** Roster index the pointer or focus is over, and the one the keyboard is holding. Refs,\n   *  not state: the canvas is their only reader, so a hover need not re-render the chips. */\n  const spotRef = useRef(-1);\n  const pinRef = useRef(-1);\n  const nudgeRef = useRef<{ member: number; dx: number; dy: number } | null>(null);\n\n  const draw = (scene: SceneDrawContext<LloydState>) => {\n    const { context, width, height, state, pointer } = scene;\n    state.snap = reduced;\n    state.spot = spotRef.current;\n    reconcile(state, seated);\n\n    const nudge = nudgeRef.current;\n    if (nudge) {\n      nudgeRef.current = null;\n      const tile = tileOf(state, nudge.member);\n      // A nudge on its own would be undone by the next iteration, because the centroid is\n      // an attractor. Pinning is what makes the keyboard the same gesture as the drag.\n      if (tile) {\n        pinRef.current = nudge.member;\n        pinTo(state, tile, tile.x + nudge.dx, tile.y + nudge.dy);\n      }\n    }\n    state.pinMember = pinRef.current;\n\n    if (!pointer.down) state.heldMember = -1;\n    else if (state.heldMember < 0 && pointer.inside) {\n      const tile = tileAt(state, pointer.x, pointer.y);\n      if (tile) {\n        state.heldMember = tile.member;\n        state.grabX = tile.x - pointer.x;\n        state.grabY = tile.y - pointer.y;\n        // One face at a time: taking hold of one lets go of whatever the keys were holding.\n        pinRef.current = -1;\n        state.pinMember = -1;\n      }\n    }\n    const held = state.heldMember < 0 ? null : tileOf(state, state.heldMember);\n    if (held) pinTo(state, held, pointer.x + state.grabX, pointer.y + state.grabY);\n\n    const now = performance.now();\n    const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;\n    state.clock = now;\n\n    if (state.snap) {\n      settle(state);\n      state.carry = 0;\n    } else {\n      state.carry += elapsed;\n      const steps = Math.min(MAX_STEPS, Math.floor(state.carry / STEP));\n      for (let i = 0; i < steps; i += 1) relax(state, RELAX);\n      state.carry -= steps * STEP;\n      // A tab left in the background banks minutes of arrears. Drop them rather than\n      // paying them off at twelve iterations a frame for the next quarter of a second.\n      if (state.carry > STEP * MAX_STEPS) state.carry = 0;\n      for (const tile of state.tiles) {\n        if (tile.fade < 1) tile.fade = Math.min(1, tile.fade + (steps * STEP) / FADE);\n      }\n    }\n\n    // The last thing either branch did was move the sites, which leaves every cell one\n    // step behind the face inside it. Rebuilding here is what guarantees that the tile\n    // drawn around a face really is the set of points nearest that face.\n    tessellate(state);\n    paint(context, state, width, height);\n\n    for (const tile of state.tiles) {\n      placesRef.current[tile.member * 2] = tile.x / width;\n      placesRef.current[tile.member * 2 + 1] = tile.y / height;\n    }\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<LloydState>({\n    setup: (scene) => build(scene, seated, placesRef.current, reduced),\n    draw,\n  });\n\n  // The roster is React state and the tessellation is not, so seating somebody — or the\n  // motion preference changing, which stops the loop outright — has to ask for the repaint\n  // that keeps the frame showing the same roster the chips do.\n  useEffect(() => {\n    requestRender();\n  }, [seated, reduced, requestRender]);\n\n  const toggle = (member: number) => {\n    setSeated((prev) => prev.map((on, index) => (index === member ? !on : on)));\n  };\n\n  const light = (member: number) => {\n    spotRef.current = member;\n    requestRender();\n  };\n\n  const dim = () => {\n    spotRef.current = -1;\n    requestRender();\n  };\n\n  /** Focus leaving a chip lets its face go, so one is never left held by a control nobody\n   *  is on. A pointer merely leaving does not: the keys, not the mouse, took hold of it. */\n  const letGo = () => {\n    spotRef.current = -1;\n    pinRef.current = -1;\n    requestRender();\n  };\n\n  /**\n   * The arrows walk the face this chip belongs to and hold it there while they do: one\n   * press carries every boundary it shares half as far, which is one gutter. Escape hands\n   * it back to the solver, which pulls it home to its own centroid.\n   */\n  const onChipKey = (event: KeyboardEvent<HTMLButtonElement>, member: number) => {\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      pinRef.current = -1;\n      requestRender();\n      return;\n    }\n    if (!seated[member]) return;\n    let dx = 0;\n    let dy = 0;\n    if (event.key === 'ArrowLeft') dx = -NUDGE;\n    else if (event.key === 'ArrowRight') dx = NUDGE;\n    else if (event.key === 'ArrowUp') dy = -NUDGE;\n    else if (event.key === 'ArrowDown') dy = NUDGE;\n    else return;\n    event.preventDefault();\n    nudgeRef.current = { member, dx, dy };\n    requestRender();\n  };\n\n  const names = ROSTER.filter((_, index) => seated[index]).map((member) => member.first);\n\n  return (\n    <div className=\"lloyd-avatars-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"lloyd-avatars-card\">\n        <div className=\"lloyd-avatars-head\">\n          <p className=\"lloyd-avatars-label\">Reviewers</p>\n          <div\n            className=\"lloyd-avatars-seats\"\n            role=\"meter\"\n            aria-label=\"Review seats filled\"\n            aria-valuemin={0}\n            aria-valuemax={ROSTER.length}\n            aria-valuenow={names.length}\n            aria-valuetext={`${names.length} of ${ROSTER.length} seats filled`}\n          >\n            <span className=\"lloyd-avatars-pips\" aria-hidden=\"true\">\n              {ROSTER.map((member, index) => (\n                <span\n                  key={member.first}\n                  className={\n                    seated[index] ? 'lloyd-avatars-pip lloyd-avatars-pip-on' : 'lloyd-avatars-pip'\n                  }\n                />\n              ))}\n            </span>\n            <span className=\"lloyd-avatars-tally\">\n              {names.length}/{ROSTER.length}\n            </span>\n          </div>\n        </div>\n\n        <div ref={stageRef} className=\"lloyd-avatars-frame\" aria-hidden=\"true\">\n          <canvas ref={canvasRef} />\n        </div>\n\n        {names.length === 0 ? (\n          <p className=\"lloyd-avatars-names\">No reviewers yet — add somebody below.</p>\n        ) : (\n          <p className=\"lloyd-avatars-names\">\n            <strong>{names.length === 1 ? '1 person' : `${names.length} people`}</strong>\n            {` · ${summarise(names)}`}\n          </p>\n        )}\n\n        {/* A sibling of the tracked element, never a child: the canvas host takes pointer\n            capture on press and would swallow these clicks and their keys. */}\n        <div className=\"lloyd-avatars-roster\" role=\"group\" aria-label=\"People on this review\">\n          {ROSTER.map((member, index) => (\n            <button\n              key={member.first}\n              type=\"button\"\n              className=\"lloyd-avatars-chip\"\n              aria-pressed={seated[index]}\n              aria-label={`${member.first} ${member.last}`}\n              /* Still pressable in a card, but out of the tab order: the card frame is\n                 aria-hidden, and a focusable node inside one is a trap with no label. */\n              tabIndex={compact ? -1 : undefined}\n              onClick={() => toggle(index)}\n              onKeyDown={(event) => onChipKey(event, index)}\n              onPointerEnter={() => light(index)}\n              onPointerLeave={dim}\n              onFocus={() => light(index)}\n              onBlur={letGo}\n            >\n              {member.first}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <p className=\"lloyd-avatars-hint\">Drag a face · arrows nudge · esc lets go</p>\n    </div>\n  );\n}\n\nexport default LloydAvatars;\n","type":"registry:ui"},{"path":"components/ui/lloyd-avatars.css","target":"components/ui/lloyd-avatars.css","content":"/*\n * lloyd-avatars — a reviewer group whose packing is solved rather than authored: each\n * face owns the part of the frame nearer to it than to anyone else. The canvas draws the\n * frame itself — the rounded rectangle, the seams, the monograms — from the same polygon\n * the solver clips, so there is no CSS radius or inset here for a canvas constant to\n * drift away from.\n */\n\n.lloyd-avatars-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 26rem;\n  padding: 2rem 1.5rem 3rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 110% at 50% 0%, #0b1a1e 0%, #070d11 60%, #05080b 100%);\n  color: #eaf3ff;\n}\n\n.lloyd-avatars-card {\n  display: grid;\n  width: min(23.5rem, 100%);\n  gap: 0.875rem;\n}\n\n.lloyd-avatars-head {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1rem;\n}\n\n.lloyd-avatars-label {\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(234, 243, 255, 0.5);\n}\n\n/* The seat gauge. A `meter` treats its own children as presentational, so the tally\n   beside the pips restates the value text rather than adding a second reading. */\n.lloyd-avatars-seats {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n}\n\n.lloyd-avatars-pips {\n  display: flex;\n  gap: 0.1875rem;\n}\n\n.lloyd-avatars-pip {\n  width: 0.25rem;\n  height: 0.6875rem;\n  border-radius: 1px;\n  background: rgba(234, 243, 255, 0.14);\n}\n\n.lloyd-avatars-pip-on {\n  background: rgba(134, 224, 203, 0.72);\n}\n\n.lloyd-avatars-tally {\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  font-variant-numeric: tabular-nums;\n  color: rgba(234, 243, 255, 0.45);\n}\n\n/*\n * The canvas host. It takes pointer capture on press — a drag has to survive leaving the\n * frame — so nothing that needs a click of its own can live inside it. The roster below\n * is a sibling for exactly that reason.\n */\n.lloyd-avatars-frame {\n  position: relative;\n  height: 12rem;\n  touch-action: none;\n  cursor: grab;\n}\n\n.lloyd-avatars-frame:active {\n  cursor: grabbing;\n}\n\n.lloyd-avatars-frame canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n.lloyd-avatars-names {\n  margin: 0;\n  font-size: 0.8125rem;\n  line-height: 1.45;\n  color: rgba(234, 243, 255, 0.55);\n}\n\n.lloyd-avatars-names strong {\n  font-weight: 500;\n  color: rgba(234, 243, 255, 0.85);\n}\n\n.lloyd-avatars-roster {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.375rem;\n}\n\n.lloyd-avatars-chip {\n  appearance: none;\n  margin: 0;\n  padding: 0.3125rem 0.6875rem;\n  border: 1px solid rgba(234, 243, 255, 0.12);\n  border-radius: 999px;\n  background: rgba(6, 12, 16, 0.5);\n  font: inherit;\n  font-size: 0.75rem;\n  font-weight: 500;\n  color: rgba(234, 243, 255, 0.46);\n  cursor: pointer;\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.lloyd-avatars-chip:hover {\n  border-color: rgba(134, 224, 203, 0.4);\n  color: rgba(234, 243, 255, 0.8);\n}\n\n.lloyd-avatars-chip[aria-pressed='true'] {\n  border-color: rgba(134, 224, 203, 0.55);\n  background: rgba(134, 224, 203, 0.13);\n  color: #eefaf5;\n}\n\n.lloyd-avatars-chip:focus-visible {\n  outline: 2px solid rgba(134, 224, 203, 0.8);\n  outline-offset: 2px;\n}\n\n.lloyd-avatars-hint {\n  position: absolute;\n  right: 1.25rem;\n  bottom: 1rem;\n  margin: 0;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  pointer-events: none;\n  color: rgba(234, 243, 255, 0.26);\n}\n\n/*\n * What reduced motion switches off is the descent, not the control. The loop is stopped\n * and every repaint instead runs the same iteration to its fixed point, so the frame is\n * always showing a solved tessellation: dragging a face re-solves the other seven around\n * it between one pointer event and the next, seating an eighth opens its tile\n * immediately, and the arrow keys still walk the face they are pointed at.\n */\n@media (prefers-reduced-motion: reduce) {\n  .lloyd-avatars-chip {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The packing is scale-free — every seat is kept as a fraction of the frame and the frame\n * polygon is measured off the canvas box on every resize — so a 278x148 frame is the same\n * tessellation with the same eight cells, not a cropped one. What goes is the sentence that\n * names the reviewers and the hint; the head, the frame and all eight chips stay, because\n * they are the control.\n */\n.lloyd-avatars-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* `place-content: center` leaves the single row auto-sized, and a row sized from its\n     content is what the card's `height: 100%` would then resolve against — so the frame\n     would collapse to nothing. Stretched, the row is the card frame. */\n  place-content: stretch;\n  padding: 0.5rem 0.625rem;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/* The frame takes what the head and the roster leave. `minmax(0, 1fr)` and not `1fr`: the\n   canvas is a min-content floor otherwise and the row would refuse to shrink. */\n.lloyd-avatars-stage[data-compact='true'] .lloyd-avatars-card {\n  width: 100%;\n  height: 100%;\n  grid-template-rows: auto minmax(0, 1fr) auto;\n  gap: 0.4375rem;\n}\n\n/* Takes what the head and the roster leave, rather than a fixed 12rem it has no room for.\n   And `pan-y`: a full-bleed surface that claims every touch traps the page inside a\n   scrolling grid, so the vertical gesture goes back to the document while dragging a face\n   still arrives. */\n.lloyd-avatars-stage[data-compact='true'] .lloyd-avatars-frame {\n  height: auto;\n  min-height: 0;\n  touch-action: pan-y;\n}\n\n/* Eight chips on two rows, and the rows are what the frame is paying for. */\n.lloyd-avatars-stage[data-compact='true'] .lloyd-avatars-roster {\n  gap: 0.3125rem;\n}\n\n.lloyd-avatars-stage[data-compact='true'] .lloyd-avatars-chip {\n  padding: 0.25rem 0.5625rem;\n  font-size: 0.6875rem;\n}\n\n/* The roll call and the hint. The card's own title carries what both were for. */\n.lloyd-avatars-stage[data-compact='true'] .lloyd-avatars-names,\n.lloyd-avatars-stage[data-compact='true'] .lloyd-avatars-hint {\n  display: none;\n}\n","type":"registry:file"},{"path":"hooks/use-canvas-scene.ts","target":"hooks/use-canvas-scene.ts","content":"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing\n * store, a rebuild on resize, a loop that stops when the stage scrolls out of\n * view, pointer tracking with per-frame deltas, and teardown.\n *\n * A scene supplies two functions. `setup` builds whatever mutable state the\n * animation owns and is re-run whenever the stage changes size, so the state can\n * be sized to the stage without ever being resized in place. `draw` paints one\n * frame from that state — it is called with the transform already scaled to\n * device pixels, so every coordinate in it is a CSS pixel.\n */\n\nexport type ScenePointer = {\n  x: number\n  y: number\n  /** Position at the previous painted frame, so `x - lastX` is a frame delta. */\n  lastX: number\n  lastY: number\n  down: boolean\n  inside: boolean\n}\n\nexport type SceneSetupContext = {\n  context: CanvasRenderingContext2D\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SceneDrawContext<State> = SceneSetupContext & {\n  state: State\n  pointer: ScenePointer\n  /** Painted frames since the last rebuild. Useful for every-Nth-frame work. */\n  frame: number\n}\n\nexport type CanvasSceneOptions<State> = {\n  setup: (context: SceneSetupContext) => State\n  draw: (context: SceneDrawContext<State>) => void\n}\n\nexport type CanvasScene = {\n  /** The sizing element. Owns the pointer listeners and is what is observed. */\n  stageRef: (node: HTMLDivElement | null) => void\n  canvasRef: (node: HTMLCanvasElement | null) => void\n  /** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */\n  requestRender: () => void\n}\n\n/** Live `prefers-reduced-motion`. False during SSR and the first paint. */\nexport function useReducedMotion() {\n  const [reduced, setReduced] = useState(false)\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(query.matches)\n    const onChange = () => setReduced(query.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return reduced\n}\n\nexport function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {\n  const reduced = useReducedMotion()\n\n  /*\n   * `draw` is usually an inline closure, so it is a new function on every\n   * render. Reading it through a ref keeps the loop from being torn down and\n   * the scene from being rebuilt each time the component re-renders.\n   */\n  const optionsRef = useRef(options)\n  optionsRef.current = options\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const canvas = useRef<HTMLCanvasElement | null>(null)\n\n  /*\n   * Plain ref assignment, with no state behind it. React attaches refs during\n   * the commit phase, before passive effects run, so the effect below already\n   * sees both nodes on the first mount — which is why these used to bump a\n   * `mounted` counter for nothing: the two `setMounted` calls batched into one\n   * re-render, the counter went 0 → 2, and the effect's dependency on it tore\n   * the live scene down and rebuilt it. Every scene was constructed, measured\n   * and warmed twice on every mount, four times under StrictMode in dev.\n   *\n   * The requirement this trades for that: a consumer must render the stage and\n   * the canvas unconditionally, in the same commit as the component itself. All\n   * thirteen do. Gating the canvas behind a flag would leave the effect bailing\n   * on the null guard with nothing to re-run it.\n   */\n  const stageRef = useCallback((node: HTMLDivElement | null) => {\n    stage.current = node\n  }, [])\n  const canvasRef = useCallback((node: HTMLCanvasElement | null) => {\n    canvas.current = node\n  }, [])\n\n  /** Set once the scene is live, so `requestRender` before that is a no-op. */\n  const render = useRef<(() => void) | null>(null)\n  const requestRender = useCallback(() => render.current?.(), [])\n\n  useEffect(() => {\n    const stageNode = stage.current\n    const canvasNode = canvas.current\n    if (!stageNode || !canvasNode) return\n\n    const context = canvasNode.getContext(\"2d\")\n    if (!context) return\n\n    const pointer: ScenePointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      down: false,\n      inside: false,\n    }\n\n    let state: State | null = null\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frame = 0\n    let loop = 0\n    let pending = 0\n    let visible = true\n\n    /** Rebuild the backing store and the scene state for the current size. */\n    const measure = () => {\n      // `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is\n      // post-transform, so a scene sitting inside a scaled ancestor measured its\n      // own frame at the scaled size, sized the backing store to that, and then\n      // had CSS scale the result a second time — the scene ran at a fraction of\n      // the box it was drawn into. The catalogue's scaled-poster branch is the\n      // one place that happens, and it is reachable again the moment an\n      // animation is registered without a card composition. These two properties\n      // are the untransformed layout box; both are integers, which is what the\n      // rounding below already reduced the rect to.\n      const nextWidth = Math.max(1, stageNode.offsetWidth)\n      const nextHeight = Math.max(1, stageNode.offsetHeight)\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1)\n      if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return\n\n      width = nextWidth\n      height = nextHeight\n      dpr = nextDpr\n      canvasNode.width = Math.round(width * dpr)\n      canvasNode.height = Math.round(height * dpr)\n      canvasNode.style.width = `${width}px`\n      canvasNode.style.height = `${height}px`\n      frame = 0\n      state = optionsRef.current.setup({ context, width, height, dpr })\n    }\n\n    const paint = () => {\n      if (!state) return\n      // Re-applied every frame: a scene is free to install its own transform\n      // for a cell or a sprite, and most do.\n      context.setTransform(dpr, 0, 0, dpr, 0, 0)\n      optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n      frame += 1\n    }\n\n    /** One frame on the next tick, coalescing however many were asked for. */\n    const paintOnce = () => {\n      if (pending) return\n      pending = requestAnimationFrame(() => {\n        pending = 0\n        measure()\n        paint()\n      })\n    }\n    render.current = paintOnce\n\n    const tick = () => {\n      loop = requestAnimationFrame(tick)\n      if (visible) paint()\n    }\n\n    const start = () => {\n      if (loop || reduced) return\n      loop = requestAnimationFrame(tick)\n    }\n    const stop = () => {\n      if (!loop) return\n      cancelAnimationFrame(loop)\n      loop = 0\n    }\n\n    const at = (event: PointerEvent) => {\n      const rect = stageNode.getBoundingClientRect()\n      // The rect is the right thing to subtract here — `clientX` is viewport\n      // space and so is the rect — but the difference comes back in *rendered*\n      // pixels, and a scene reads `pointer` in the scene pixels `measure()` set\n      // up from the untransformed box. Under a CSS scale those two disagree, so\n      // divide the transform back out. `rect.width / offsetWidth` is the scale\n      // actually in force, whatever produced it, and it is exactly 1 when there\n      // is none.\n      const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1\n      pointer.x = (event.clientX - rect.left) / (scale || 1)\n      pointer.y = (event.clientY - rect.top) / (scale || 1)\n      // A frozen loop still owes the user feedback for a drag.\n      if (reduced) paintOnce()\n    }\n\n    const onEnter = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n    }\n    const onMove = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n    }\n    const onDown = (event: PointerEvent) => {\n      pointer.down = true\n      at(event)\n      // Capture keeps a drag alive past the edge of the stage, which is where\n      // a hard throw naturally ends up.\n      stageNode.setPointerCapture(event.pointerId)\n    }\n    const onUp = (event: PointerEvent) => {\n      pointer.down = false\n      at(event)\n      if (stageNode.hasPointerCapture(event.pointerId)) {\n        stageNode.releasePointerCapture(event.pointerId)\n      }\n    }\n    const onLeave = () => {\n      pointer.inside = false\n      pointer.down = false\n      if (reduced) paintOnce()\n    }\n\n    stageNode.addEventListener(\"pointerenter\", onEnter)\n    stageNode.addEventListener(\"pointermove\", onMove)\n    stageNode.addEventListener(\"pointerdown\", onDown)\n    stageNode.addEventListener(\"pointerup\", onUp)\n    stageNode.addEventListener(\"pointercancel\", onUp)\n    stageNode.addEventListener(\"pointerleave\", onLeave)\n\n    const resizes = new ResizeObserver(() => paintOnce())\n    resizes.observe(stageNode)\n\n    /*\n     * An animation nobody can see is heat. The observer both pauses the loop\n     * and, on the way back in, repaints immediately rather than waiting a frame.\n     */\n    const views = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((entry) => entry.isIntersecting)\n        if (visible) {\n          start()\n          paintOnce()\n        } else {\n          stop()\n        }\n      },\n      { rootMargin: \"120px\" },\n    )\n    views.observe(stageNode)\n\n    measure()\n    paint()\n    start()\n\n    return () => {\n      render.current = null\n      stop()\n      if (pending) cancelAnimationFrame(pending)\n      resizes.disconnect()\n      views.disconnect()\n      stageNode.removeEventListener(\"pointerenter\", onEnter)\n      stageNode.removeEventListener(\"pointermove\", onMove)\n      stageNode.removeEventListener(\"pointerdown\", onDown)\n      stageNode.removeEventListener(\"pointerup\", onUp)\n      stageNode.removeEventListener(\"pointercancel\", onUp)\n      stageNode.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [reduced])\n\n  return { stageRef, canvasRef, requestRender }\n}\n","type":"registry:hook"}],"meta":{"kind":"animations","categories":["draggable","morph","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/lloyd-avatars"}}