{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"chip-pile","type":"registry:ui","title":"Chip Pile","description":"A tag list where every chip is a rigid body as wide as its own label, solved by sequential impulses with real friction. Drag one out and the pile answers.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/chip-pile.tsx","target":"components/ui/chip-pile.tsx","content":"'use client';\n\nimport './chip-pile.css';\n\nimport { useEffect, useState } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneDrawContext,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A tag list that is a pile of things.\n *\n * The chips are rigid bodies — mass, moment of inertia, contacts, friction — solved\n * by sequential impulses, the method a 2D physics engine uses. Nothing is scripted:\n * where a chip ends up is where the stack it landed on happened to be, and a chip\n * lying across two others bridges them because that is what the contact set says.\n *\n * They are capsules rather than boxes, which is the one decision the rest follows\n * from. A rounded rectangle *is* a capsule when its corner radius is half its height,\n * so the collision shape and the drawn shape are the same shape — no skin, no\n * approximation, and the corner a box would catch on does not exist. The cost is that\n * two capsules touch at a single point, and a single point cannot hold a chip level:\n * a stack built on one contact per pair rocks forever, because there is no torque to\n * resist rotation about it. So the near-parallel case is detected and clipped, and\n * emits *two* contacts spanning the overlap — the same reference-face clipping a box\n * solver does, for the same reason.\n *\n * The label widths come from `measureText`, so each chip is exactly as wide as its\n * own word at the reader's font size, and the pile is different at every viewport.\n * The same labels are also a real list, for anything that is not looking at a canvas.\n */\n\n/** Seconds per step. */\nconst STEP = 1 / 120;\n/** Sequential-impulse passes per step. Ten is where a four-high stack stops creeping. */\nconst ITERATIONS = 10;\n/** Gravity, px/s². */\nconst G = 2000;\n/** Restitution. Plastic chips on a desk: almost none. */\nconst REST = 0.05;\n/** Approach speed below which restitution is dropped, so a resting chip cannot buzz. */\nconst REST_CUT = 70;\n/** Coulomb friction. Above about 0.5 a pile holds its own slope instead of spreading. */\nconst MU = 0.55;\n/** Baumgarte factor: the fraction of the remaining overlap pushed out per step. */\nconst BIAS = 0.2;\n/** Overlap left alone, in pixels. Solving to zero is what makes contacts chatter. */\nconst SLOP = 0.5;\n/** Velocity lost per second to the air and to the desk. */\nconst LINEAR_DAMP = 0.35;\nconst ANGULAR_DAMP = 0.9;\n/** Mass per square pixel. Only ratios matter, but keeping masses near 1 keeps the\n *  impulses in a range where single-precision noise never shows. */\nconst DENSITY = 0.0012;\n/** Half the height of a chip, and therefore the capsule's radius. */\nconst RADIUS = 13;\n/** Space between the end of the word and the end of the chip. */\nconst PAD = 13;\n/** Where the walls and the desk sit, inset from the canvas edges. */\nconst WALL = 14;\nconst DESK = 26;\n/** |sin θ| below which two capsules are treated as parallel and their contact clipped. */\nconst PARALLEL = 0.08;\n/** How near a chip a press has to land to take hold of it. */\nconst GRAB_R = 30;\n/** Stiffness of the hand, as a velocity gain per second. */\nconst GRAB_K = 20;\n/** Fastest the hand may drag a chip, px/s. A clamp is what keeps it from tunnelling. */\nconst GRAB_MAX = 2400;\n/** Steps run in `setup` when motion is reduced, so the still is a settled pile. */\nconst WARM = 420;\n\nconst LABELS = [\n  'TypeScript',\n  'React',\n  'Postgres',\n  'Rust',\n  'WebGL',\n  'CI/CD',\n  'Figma',\n  'Redis',\n  'Docker',\n  'GraphQL',\n  'Swift',\n  'Terraform',\n  'Kafka',\n  'Go',\n];\n\nconst FONT = '500 12.5px ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif';\n\ninterface Chip {\n  readonly label: string;\n  readonly tone: number;\n  /** Half the length of the capsule's spine. The word's own width decides it. */\n  readonly half: number;\n  readonly invMass: number;\n  readonly invInertia: number;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  angle: number;\n  spin: number;\n}\n\n/**\n * One contact. `b` is −1 for the desk and the walls, which are not bodies: an\n * immovable surface is exactly a body with zero inverse mass, so the solver needs no\n * branch for it beyond skipping the half of the application that would move it.\n */\ninterface Contact {\n  a: number;\n  b: number;\n  nx: number;\n  ny: number;\n  /** Arms from each centre of mass to the contact point. */\n  rax: number;\n  ray: number;\n  rbx: number;\n  rby: number;\n  /** Velocity the normal is solved toward: the overlap push plus any bounce. */\n  target: number;\n  /** Effective mass along the normal and along the tangent, once per step. */\n  kn: number;\n  kt: number;\n  /** Impulse accumulated across the passes. The friction cone is clamped against it. */\n  pn: number;\n  pt: number;\n}\n\ninterface PileState {\n  readonly chips: Chip[];\n  readonly contacts: Contact[];\n  used: number;\n  left: number;\n  right: number;\n  floor: number;\n  /** Index of the chip in hand, or −1. */\n  held: number;\n  /** Where on that chip the hand took hold, in the chip's own frame. */\n  grabX: number;\n  grabY: number;\n  /** The toss the pile was last built for. Compared against the component's counter. */\n  toss: number;\n  carry: number;\n  clock: number;\n  /** Stop integrating. Set under `prefers-reduced-motion`. */\n  snap: boolean;\n}\n\n/** Closest points between two segments, as parameters along each. */\nconst pair = new Float64Array(2);\n\n/**\n * The classic segment-segment routine: minimise the squared distance over the two\n * parameters, clamp, and re-solve the other one against the clamped edge. Exact for\n * every configuration except two parallel segments, where the minimum is a whole\n * interval rather than a point — which is precisely the case handled separately.\n */\nfunction closest(\n  px: number,\n  py: number,\n  dx: number,\n  dy: number,\n  qx: number,\n  qy: number,\n  ex: number,\n  ey: number,\n) {\n  const a = dx * dx + dy * dy;\n  const e = ex * ex + ey * ey;\n  const rx = px - qx;\n  const ry = py - qy;\n  const f = ex * rx + ey * ry;\n  const c = dx * rx + dy * ry;\n  const b = dx * ex + dy * ey;\n  const denom = a * e - b * b;\n\n  let s = denom > 1e-9 ? Math.max(0, Math.min(1, (b * f - c * e) / denom)) : 0;\n  let t = (b * s + f) / e;\n  if (t < 0) {\n    t = 0;\n    s = Math.max(0, Math.min(1, -c / a));\n  } else if (t > 1) {\n    t = 1;\n    s = Math.max(0, Math.min(1, (b - c) / a));\n  }\n  pair[0] = s;\n  pair[1] = t;\n}\n\n/**\n * Record one contact and precompute everything the passes will need from it: the\n * arms, the effective mass along the normal and the tangent, and the velocity the\n * normal is to be solved toward. All of it is fixed for the step, so the ten passes\n * that follow are pure arithmetic on the velocities.\n */\nfunction add(\n  state: PileState,\n  ai: number,\n  bi: number,\n  cx: number,\n  cy: number,\n  nx: number,\n  ny: number,\n  depth: number,\n) {\n  if (state.used >= state.contacts.length) return;\n  const contact = state.contacts[state.used];\n  const A = state.chips[ai];\n  const B = bi >= 0 ? state.chips[bi] : null;\n\n  const rax = cx - A.x;\n  const ray = cy - A.y;\n  const rbx = B ? cx - B.x : 0;\n  const rby = B ? cy - B.y : 0;\n\n  const invB = B ? B.invMass : 0;\n  const invIB = B ? B.invInertia : 0;\n\n  const crossAN = rax * ny - ray * nx;\n  const crossBN = rbx * ny - rby * nx;\n  const crossAT = rax * nx + ray * ny;\n  const crossBT = rbx * nx + rby * ny;\n\n  const vax = A.vx - A.spin * ray;\n  const vay = A.vy + A.spin * rax;\n  const vbx = B ? B.vx - B.spin * rby : 0;\n  const vby = B ? B.vy + B.spin * rbx : 0;\n  const approach = (vbx - vax) * nx + (vby - vay) * ny;\n\n  contact.a = ai;\n  contact.b = bi;\n  contact.nx = nx;\n  contact.ny = ny;\n  contact.rax = rax;\n  contact.ray = ray;\n  contact.rbx = rbx;\n  contact.rby = rby;\n  // The overlap is pushed out over several steps rather than in one, and the last\n  // half pixel is left alone — a contact solved to exactly zero re-separates, loses\n  // its contact next step, falls back in, and buzzes.\n  contact.target =\n    (BIAS / STEP) * Math.max(0, depth - SLOP) +\n    (approach < -REST_CUT ? -REST * approach : 0);\n  contact.kn =\n    A.invMass + invB + A.invInertia * crossAN * crossAN + invIB * crossBN * crossBN;\n  contact.kt =\n    A.invMass + invB + A.invInertia * crossAT * crossAT + invIB * crossBT * crossBT;\n  contact.pn = 0;\n  contact.pt = 0;\n  state.used += 1;\n}\n\n/** Two spine points that are near enough to touch, as a contact. */\nfunction emit(\n  state: PileState,\n  ai: number,\n  bi: number,\n  ax: number,\n  ay: number,\n  bx: number,\n  by: number,\n) {\n  let nx = bx - ax;\n  let ny = by - ay;\n  let distance = Math.hypot(nx, ny);\n  if (distance >= RADIUS * 2) return;\n  if (distance > 1e-6) {\n    nx /= distance;\n    ny /= distance;\n  } else {\n    // Spines exactly coincident. Any normal will separate them; down is as good as\n    // any, and the next step will have a real one.\n    nx = 0;\n    ny = 1;\n    distance = 0;\n  }\n  // Midway between the two spines is midway between the two surfaces, because both\n  // capsules have the same radius.\n  add(\n    state,\n    ai,\n    bi,\n    ax + nx * distance * 0.5,\n    ay + ny * distance * 0.5,\n    nx,\n    ny,\n    RADIUS * 2 - distance,\n  );\n}\n\n/**\n * Contacts between two chips.\n *\n * The parallel branch is the whole reason a stack here is stable. Two capsules lying\n * across each other meet at one point, and one point exerts no torque about itself —\n * so a chip resting on another would be free to rotate about the touch and would rock\n * until damping killed it, which reads as a pile of wobbling jelly. When the spines\n * are within about five degrees of parallel their overlap is an interval rather than a\n * point, so it is clipped to that interval and a contact is emitted at each end.\n * Between them they *do* resist rotation, and the chip lies still.\n */\nfunction collide(state: PileState, ai: number, bi: number) {\n  const A = state.chips[ai];\n  const B = state.chips[bi];\n\n  const reach = A.half + B.half + RADIUS * 2;\n  const gapX = B.x - A.x;\n  const gapY = B.y - A.y;\n  if (gapX * gapX + gapY * gapY > reach * reach) return;\n\n  const ca = Math.cos(A.angle);\n  const sa = Math.sin(A.angle);\n  const cb = Math.cos(B.angle);\n  const sb = Math.sin(B.angle);\n  const px = A.x - ca * A.half;\n  const py = A.y - sa * A.half;\n  const dx = ca * A.half * 2;\n  const dy = sa * A.half * 2;\n  const qx = B.x - cb * B.half;\n  const qy = B.y - sb * B.half;\n  const ex = cb * B.half * 2;\n  const ey = sb * B.half * 2;\n\n  const lenA = A.half * 2;\n  const lenB = B.half * 2;\n  if (Math.abs(dx * ey - dy * ex) < PARALLEL * lenA * lenB) {\n    const along = lenA * lenA;\n    let from = ((qx - px) * dx + (qy - py) * dy) / along;\n    let to = ((qx + ex - px) * dx + (qy + ey - py) * dy) / along;\n    if (from > to) {\n      const swap = from;\n      from = to;\n      to = swap;\n    }\n    const lo = Math.max(0, from);\n    const hi = Math.min(1, to);\n    // Only worth two contacts if the shared length is more than a chip is thick.\n    if ((hi - lo) * lenA > RADIUS) {\n      span(state, ai, bi, px, py, dx, dy, qx, qy, ex, ey, lo);\n      span(state, ai, bi, px, py, dx, dy, qx, qy, ex, ey, hi);\n      return;\n    }\n  }\n\n  closest(px, py, dx, dy, qx, qy, ex, ey);\n  emit(\n    state,\n    ai,\n    bi,\n    px + dx * pair[0],\n    py + dy * pair[0],\n    qx + ex * pair[1],\n    qy + ey * pair[1],\n  );\n}\n\n/** One end of a clipped parallel overlap: a point on A, and the nearest point on B. */\nfunction span(\n  state: PileState,\n  ai: number,\n  bi: number,\n  px: number,\n  py: number,\n  dx: number,\n  dy: number,\n  qx: number,\n  qy: number,\n  ex: number,\n  ey: number,\n  at: number,\n) {\n  const ax = px + dx * at;\n  const ay = py + dy * at;\n  const along = ex * ex + ey * ey;\n  const t = Math.max(0, Math.min(1, ((ax - qx) * ex + (ay - qy) * ey) / along));\n  emit(state, ai, bi, ax, ay, qx + ex * t, qy + ey * t);\n}\n\n/**\n * Contacts against the desk and the two walls. Both ends of the spine are tested and\n * both are allowed to report, which is what stops a chip lying flat on the desk from\n * pivoting about its middle — the same two-contact requirement as the parallel case,\n * arrived at from the same direction.\n */\nfunction surfaces(state: PileState, index: number) {\n  const chip = state.chips[index];\n  const cos = Math.cos(chip.angle) * chip.half;\n  const sin = Math.sin(chip.angle) * chip.half;\n\n  for (let end = -1; end <= 1; end += 2) {\n    const x = chip.x + cos * end;\n    const y = chip.y + sin * end;\n\n    const under = RADIUS - (state.floor - y);\n    if (under > 0) add(state, index, -1, x, state.floor, 0, 1, under);\n\n    const past = RADIUS - (x - state.left);\n    if (past > 0) add(state, index, -1, state.left, y, -1, 0, past);\n\n    const over = RADIUS - (state.right - x);\n    if (over > 0) add(state, index, -1, state.right, y, 1, 0, over);\n  }\n}\n\n/** An impulse at an arm, as the linear and angular change it is. */\nfunction push(chip: Chip, rx: number, ry: number, ix: number, iy: number, sign: number) {\n  chip.vx += ix * chip.invMass * sign;\n  chip.vy += iy * chip.invMass * sign;\n  chip.spin += (rx * iy - ry * ix) * chip.invInertia * sign;\n}\n\n/**\n * One pass over one contact: the normal, then friction inside the cone the normal has\n * earned. The accumulated impulse is what is clamped, not the increment — a contact\n * that has already been pushed hard is allowed to pull back this pass, which is how\n * ten cheap passes converge on the answer a matrix solve would give.\n */\nfunction resolve(state: PileState, contact: Contact) {\n  const A = state.chips[contact.a];\n  const B = contact.b >= 0 ? state.chips[contact.b] : null;\n  const { nx, ny, rax, ray, rbx, rby } = contact;\n\n  if (contact.kn > 1e-12) {\n    const vax = A.vx - A.spin * ray;\n    const vay = A.vy + A.spin * rax;\n    const vbx = B ? B.vx - B.spin * rby : 0;\n    const vby = B ? B.vy + B.spin * rbx : 0;\n    const vn = (vbx - vax) * nx + (vby - vay) * ny;\n\n    const want = Math.max(0, contact.pn + (contact.target - vn) / contact.kn);\n    const change = want - contact.pn;\n    contact.pn = want;\n    push(A, rax, ray, nx * change, ny * change, -1);\n    if (B) push(B, rbx, rby, nx * change, ny * change, 1);\n  }\n\n  if (contact.kt > 1e-12) {\n    const tx = -ny;\n    const ty = nx;\n    const vax = A.vx - A.spin * ray;\n    const vay = A.vy + A.spin * rax;\n    const vbx = B ? B.vx - B.spin * rby : 0;\n    const vby = B ? B.vy + B.spin * rbx : 0;\n    const vt = (vbx - vax) * tx + (vby - vay) * ty;\n\n    // Coulomb, against the live normal impulse. A chip on a slope holds until the\n    // tangential demand exceeds μ times what is holding it up, and then slides —\n    // which is one rule producing both behaviours instead of a rule for each.\n    const cone = MU * contact.pn;\n    const want = Math.max(-cone, Math.min(cone, contact.pt - vt / contact.kt));\n    const change = want - contact.pt;\n    contact.pt = want;\n    push(A, rax, ray, tx * change, ty * change, -1);\n    if (B) push(B, rbx, rby, tx * change, ty * change, 1);\n  }\n}\n\n/**\n * The hand, as a velocity constraint on the point that was grabbed.\n *\n * A position assignment would drag a chip straight through the desk and through its\n * neighbours, because nothing downstream can argue with a position. A velocity target\n * is answered by the contact solver on the same pass, so the desk still wins and the\n * pile still resists — the chip has to be dug out.\n */\nfunction hand(state: PileState, handX: number, handY: number) {\n  const chip = state.chips[state.held];\n  const cos = Math.cos(chip.angle);\n  const sin = Math.sin(chip.angle);\n  const rx = cos * state.grabX - sin * state.grabY;\n  const ry = sin * state.grabX + cos * state.grabY;\n\n  const wantX = Math.max(-GRAB_MAX, Math.min(GRAB_MAX, (handX - (chip.x + rx)) * GRAB_K));\n  const wantY = Math.max(-GRAB_MAX, Math.min(GRAB_MAX, (handY - (chip.y + ry)) * GRAB_K));\n\n  const vx = chip.vx - chip.spin * ry;\n  const vy = chip.vy + chip.spin * rx;\n\n  // Effective mass of the grabbed point along each axis, which is what turns the\n  // velocity error into an impulse: a chip held by its end swings, one held at its\n  // centre does not.\n  const kx = chip.invMass + chip.invInertia * ry * ry;\n  const ky = chip.invMass + chip.invInertia * rx * rx;\n  push(chip, rx, ry, (wantX - vx) / kx, (wantY - vy) / ky, 1);\n}\n\nfunction advance(state: PileState, handX: number, handY: number) {\n  const chips = state.chips;\n\n  for (const chip of chips) {\n    chip.vy += G * STEP;\n    chip.vx -= chip.vx * LINEAR_DAMP * STEP;\n    chip.vy -= chip.vy * LINEAR_DAMP * STEP;\n    chip.spin -= chip.spin * ANGULAR_DAMP * STEP;\n  }\n\n  state.used = 0;\n  for (let i = 0; i < chips.length; i++) {\n    surfaces(state, i);\n    for (let j = i + 1; j < chips.length; j++) collide(state, i, j);\n  }\n\n  for (let pass = 0; pass < ITERATIONS; pass++) {\n    if (state.held >= 0) hand(state, handX, handY);\n    for (let c = 0; c < state.used; c++) resolve(state, state.contacts[c]);\n  }\n\n  for (const chip of chips) {\n    chip.x += chip.vx * STEP;\n    chip.y += chip.vy * STEP;\n    chip.angle += chip.spin * STEP;\n  }\n}\n\n/** Chips lifted above the stage and dropped, from a seed. */\nfunction scatter(state: PileState, seed: number) {\n  let bits = seed >>> 0;\n  const random = () => {\n    bits = (bits * 1664525 + 1013904223) >>> 0;\n    return bits / 4294967296;\n  };\n\n  state.chips.forEach((chip, i) => {\n    const margin = chip.half + RADIUS + 2;\n    const room = Math.max(1, state.right - state.left - margin * 2);\n    chip.x = state.left + margin + random() * room;\n    // Stacked up out of sight and released together. They arrive in order, so the\n    // pile is built one chip at a time rather than resolved out of one heap.\n    chip.y = -RADIUS - i * 44 - random() * 26;\n    chip.vx = (random() - 0.5) * 120;\n    chip.vy = 60 + random() * 90;\n    chip.angle = (random() - 0.5) * 1.4;\n    chip.spin = (random() - 0.5) * 5;\n  });\n\n  state.held = -1;\n}\n\nconst FILL = [\n  'rgba(234,239,247,0.95)',\n  'rgba(122,206,215,0.93)',\n  'rgba(255,196,124,0.93)',\n  'rgba(166,155,242,0.92)',\n];\nconst EDGE = [\n  'rgba(255,255,255,0.55)',\n  'rgba(198,247,252,0.5)',\n  'rgba(255,231,190,0.5)',\n  'rgba(214,208,255,0.5)',\n];\nconst INK = ['#13161d', '#04222a', '#2b1a05', '#130f38'];\n\nfunction build({ context, width, height }: SceneSetupContext, reduced: boolean): PileState {\n  // The chip is as wide as its word. Measured here rather than guessed at, so the\n  // pile is right at whatever size the reader's font resolves to.\n  context.font = FONT;\n  const chips: Chip[] = LABELS.map((label, i) => {\n    const half = context.measureText(label).width / 2 + PAD;\n    const mass = (half * 2 * RADIUS * 2 + Math.PI * RADIUS * RADIUS) * DENSITY;\n    const length = half * 2;\n    // A capsule's moment about its centre: the rod, plus the two caps, which together\n    // are a disc of the same radius.\n    const inertia = mass * ((length * length) / 12 + (RADIUS * RADIUS) / 2);\n    return {\n      label,\n      tone: i % FILL.length,\n      half,\n      invMass: 1 / mass,\n      invInertia: 1 / inertia,\n      x: 0,\n      y: 0,\n      vx: 0,\n      vy: 0,\n      angle: 0,\n      spin: 0,\n    };\n  });\n\n  /*\n   * The contact pool, allocated once. Fourteen chips can report six surface contacts\n   * each and two per pair, so the ceiling is a shade over two hundred and sixty; the\n   * point of the pool is that a physics step never allocates, because a collection\n   * pause inside a solver is a visible stutter.\n   */\n  const contacts: Contact[] = [];\n  for (let i = 0; i < 384; i++) {\n    contacts.push({\n      a: 0,\n      b: -1,\n      nx: 0,\n      ny: 0,\n      rax: 0,\n      ray: 0,\n      rbx: 0,\n      rby: 0,\n      target: 0,\n      kn: 0,\n      kt: 0,\n      pn: 0,\n      pt: 0,\n    });\n  }\n\n  const state: PileState = {\n    chips,\n    contacts,\n    used: 0,\n    left: WALL,\n    right: width - WALL,\n    floor: height - DESK,\n    held: -1,\n    grabX: 0,\n    grabY: 0,\n    toss: 0,\n    carry: 0,\n    clock: 0,\n    snap: reduced,\n  };\n\n  scatter(state, 20260904);\n\n  // With motion reduced the loop never runs, so the pile is settled here instead: the\n  // still is the real solution three and a half seconds in, not a hand-placed guess.\n  if (reduced) for (let i = 0; i < WARM; i++) advance(state, 0, 0);\n\n  return state;\n}\n\nfunction paint({ context, width, height, state, pointer }: SceneDrawContext<PileState>) {\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  // The box, from the live size, so a resize moves the walls instead of leaving the\n  // pile standing on a desk that is no longer there.\n  state.left = WALL;\n  state.right = width - WALL;\n  state.floor = height - DESK;\n\n  if (!pointer.down) state.held = -1;\n  else if (state.held < 0 && pointer.inside) {\n    /*\n     * Take hold of the nearest chip, by distance to its spine rather than to its\n     * centre — a long chip has to be grabbable by its end, and where along it you\n     * grabbed is what decides whether it comes up flat or swinging.\n     */\n    let best = -1;\n    let nearest = GRAB_R;\n    state.chips.forEach((chip, i) => {\n      const cos = Math.cos(chip.angle);\n      const sin = Math.sin(chip.angle);\n      const along = Math.max(\n        -chip.half,\n        Math.min(chip.half, (pointer.x - chip.x) * cos + (pointer.y - chip.y) * sin),\n      );\n      const gap =\n        Math.hypot(pointer.x - (chip.x + cos * along), pointer.y - (chip.y + sin * along)) -\n        RADIUS;\n      if (gap < nearest) {\n        nearest = gap;\n        best = i;\n      }\n    });\n\n    if (best >= 0) {\n      const chip = state.chips[best];\n      const cos = Math.cos(chip.angle);\n      const sin = Math.sin(chip.angle);\n      const dx = pointer.x - chip.x;\n      const dy = pointer.y - chip.y;\n      state.held = best;\n      state.grabX = dx * cos + dy * sin;\n      state.grabY = -dx * sin + dy * cos;\n    }\n  }\n\n  if (!state.snap) {\n    state.carry += elapsed;\n    let steps = 0;\n    while (state.carry >= STEP && steps < 8) {\n      advance(state, pointer.x, pointer.y);\n      state.carry -= STEP;\n      steps += 1;\n    }\n    if (state.carry > STEP * 8) state.carry = 0;\n  }\n\n  context.clearRect(0, 0, width, height);\n\n  // The desk, as the line the contacts are actually against.\n  const shade = context.createLinearGradient(0, state.floor - 22, 0, state.floor);\n  shade.addColorStop(0, 'rgba(148,176,214,0)');\n  shade.addColorStop(1, 'rgba(148,176,214,0.09)');\n  context.fillStyle = shade;\n  context.fillRect(0, state.floor - 22, width, 22);\n  context.strokeStyle = 'rgba(196,214,238,0.16)';\n  context.lineWidth = 1;\n  context.beginPath();\n  context.moveTo(0, Math.round(state.floor) + 0.5);\n  context.lineTo(width, Math.round(state.floor) + 0.5);\n  context.stroke();\n\n  context.font = FONT;\n  context.textAlign = 'center';\n  context.textBaseline = 'middle';\n\n  for (const chip of state.chips) {\n    context.save();\n    context.translate(chip.x, chip.y);\n    context.rotate(chip.angle);\n\n    /*\n     * The capsule, drawn as the capsule the solver collides: two caps and two edges.\n     * A rounded rectangle whose radius is half its height is the same shape, so there\n     * is no gap anywhere between what is seen and what is simulated.\n     */\n    context.beginPath();\n    context.arc(-chip.half, 0, RADIUS, Math.PI * 0.5, Math.PI * 1.5);\n    context.lineTo(chip.half, -RADIUS);\n    context.arc(chip.half, 0, RADIUS, Math.PI * -0.5, Math.PI * 0.5);\n    context.closePath();\n    context.fillStyle = FILL[chip.tone];\n    context.fill();\n    context.strokeStyle = EDGE[chip.tone];\n    context.lineWidth = 1;\n    context.stroke();\n\n    // Turned with the chip, but never upside down: past a quarter turn the label reads\n    // the other way up, which is what a printed word on a physical chip does.\n    if (Math.cos(chip.angle) < 0) context.rotate(Math.PI);\n    context.fillStyle = INK[chip.tone];\n    context.fillText(chip.label, 0, 0);\n    context.restore();\n  }\n}\n\n/** `compact` is the 298x240 catalogue card: the same pile, with the copy layer cut to\n *  one line along the desk margin. Presentation only — see `chip-pile.css`. */\nexport type ChipPileProps = { compact?: boolean };\n\n/**\n * The desk is the stage and takes every pointer event; the copy is a later sibling\n * that paints over it with `pointer-events: none`, and only the toss button takes its\n * clicks back. The same fourteen labels are also a plain list, visually hidden, so the\n * content of this section does not depend on being able to see a canvas.\n */\nexport function ChipPile({ compact = false }: ChipPileProps) {\n  const reduced = useReducedMotion();\n  const [toss, setToss] = useState(0);\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<PileState>({\n    setup: (scene) => build(scene, reduced),\n    draw: (scene) => {\n      scene.state.snap = reduced;\n      if (scene.state.toss !== toss) {\n        scene.state.toss = toss;\n        scatter(scene.state, 20260904 + toss * 7919);\n        // Nothing will integrate this pile if the loop is stopped, so it is settled\n        // here and the button still does something under reduced motion.\n        if (reduced) for (let i = 0; i < WARM; i++) advance(scene.state, 0, 0);\n      }\n      paint(scene);\n    },\n  });\n\n  useEffect(() => {\n    requestRender();\n  }, [toss, requestRender]);\n\n  return (\n    <div className=\"chip-pile-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"chip-pile-desk\" aria-hidden=\"true\">\n        <canvas ref={canvasRef} />\n      </div>\n\n      <div className=\"chip-pile-face\">\n        <p className=\"chip-pile-eyebrow\">Stack</p>\n        <h2>Fourteen rigid bodies, and the words are the widths.</h2>\n        <p className=\"chip-pile-copy\">\n          Every chip is as wide as its own label measured in your font, and as heavy as\n          it is wide. Pick one up and the rest of the pile answers for it.\n        </p>\n\n        {/* Still clickable 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        <button\n          type=\"button\"\n          className=\"chip-pile-toss\"\n          tabIndex={compact ? -1 : undefined}\n          onClick={() => setToss((n) => n + 1)}\n        >\n          Toss again\n        </button>\n\n        <ul className=\"chip-pile-list\">\n          {LABELS.map((label) => (\n            <li key={label}>{label}</li>\n          ))}\n        </ul>\n      </div>\n\n      <p className=\"chip-pile-hint\">Drag a chip</p>\n    </div>\n  );\n}\n\nexport default ChipPile;\n","type":"registry:ui"},{"path":"components/ui/chip-pile.css","target":"components/ui/chip-pile.css","content":".chip-pile-stage {\n  position: relative;\n  width: 100%;\n  min-height: 27rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  background: radial-gradient(120% 100% at 22% 0%, #131824 0%, #0a0d15 58%, #07090f 100%);\n  color: #eef2f9;\n}\n\n.chip-pile-desk {\n  position: absolute;\n  inset: 0;\n  cursor: grab;\n  touch-action: none;\n}\n\n.chip-pile-desk:active {\n  cursor: grabbing;\n}\n\n.chip-pile-desk canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer, so a chip can be grabbed through the copy above it.\n   Only the toss button takes its clicks back. */\n.chip-pile-face {\n  position: relative;\n  max-width: 30rem;\n  padding: 2.75rem 2.5rem 3rem;\n  pointer-events: none;\n}\n\n.chip-pile-eyebrow {\n  margin: 0 0 0.875rem;\n  font: 500 0.6875rem/1 ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n  letter-spacing: 0.16em;\n  text-transform: uppercase;\n  color: rgba(150, 214, 222, 0.8);\n}\n\n.chip-pile-face h2 {\n  margin: 0 0 0.875rem;\n  font-size: clamp(1.5rem, 2.9vw, 2.125rem);\n  font-weight: 500;\n  line-height: 1.14;\n  letter-spacing: -0.02em;\n  text-wrap: balance;\n  color: rgba(245, 249, 255, 0.92);\n}\n\n.chip-pile-copy {\n  margin: 0 0 1.5rem;\n  max-width: 25rem;\n  font-size: 0.9375rem;\n  line-height: 1.6;\n  color: rgba(238, 242, 249, 0.5);\n}\n\n.chip-pile-toss {\n  appearance: none;\n  margin: 0;\n  padding: 0.5rem 1rem;\n  border: 1px solid rgba(255, 255, 255, 0.16);\n  border-radius: 999px;\n  background: rgba(10, 16, 26, 0.5);\n  font: inherit;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  color: rgba(238, 242, 249, 0.78);\n  cursor: pointer;\n  pointer-events: auto;\n  backdrop-filter: blur(6px);\n  transition:\n    border-color 160ms ease,\n    background-color 160ms ease,\n    color 160ms ease;\n}\n\n.chip-pile-toss:hover {\n  border-color: rgba(150, 224, 232, 0.45);\n  color: #f4fdff;\n}\n\n.chip-pile-toss:focus-visible {\n  outline: 2px solid rgba(150, 224, 232, 0.8);\n  outline-offset: 2px;\n}\n\n/*\n * The labels, for anything that is not looking at a canvas. Clipped to a single pixel\n * rather than `display: none`, because a hidden element is not read out and the point\n * of the list is that it is.\n */\n.chip-pile-list {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n  list-style: none;\n}\n\n.chip-pile-hint {\n  position: absolute;\n  right: 0.875rem;\n  bottom: 0.75rem;\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  color: rgba(238, 242, 249, 0.26);\n  pointer-events: none;\n}\n\n/*\n * With the loop stopped the pile is solved in `setup` instead — three and a half\n * seconds of the real solver, run before the first paint — so the still is a settled\n * pile rather than a placed one, and pressing the button re-solves a new one. What is\n * gone is the fall, which is the part that was asked to go. Dragging goes with it,\n * because a chip that follows the pointer is motion arriving through another door.\n */\n@media (prefers-reduced-motion: reduce) {\n  .chip-pile-desk {\n    cursor: default;\n  }\n\n  .chip-pile-toss {\n    transition: none;\n  }\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * The pile itself is already right — it is solved against the live canvas box — so all\n * this does is get the copy out of its way, down to a single strip along the bottom.\n */\n.chip-pile-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* The card frame rounds and clips already. */\n  border-radius: 0;\n}\n\n/*\n * A full-bleed drag surface that claims every touch traps the page inside a scrolling\n * grid. `pan-y` hands the vertical gesture back to the document; a horizontal drag\n * still reaches a chip, which is the gesture this mechanism is about.\n */\n.chip-pile-stage[data-compact='true'] .chip-pile-desk {\n  touch-action: pan-y;\n}\n\n/*\n * The strip sits in the band the solver already keeps empty: `DESK` in the tsx holds\n * the floor 26px above the bottom edge, so nothing ever rests below it. In pixels,\n * because that constant is in pixels. `pointer-events: none` is inherited from the\n * rule above and left alone, so a press on the strip still lands on a chip; the toss\n * button goes on taking its own clicks back.\n */\n.chip-pile-stage[data-compact='true'] .chip-pile-face {\n  position: absolute;\n  inset: auto 0 0 0;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 0.75rem;\n  height: 26px;\n  padding: 0 0.6875rem;\n}\n\n/* One line of text, at the fixed 0.6875rem it was already set in. */\n.chip-pile-stage[data-compact='true'] .chip-pile-eyebrow {\n  margin: 0;\n}\n\n/* The heading is a `vw` size and the paragraph is three lines; the hint would be a\n   second line of text. All of it is the card's own title's job. */\n.chip-pile-stage[data-compact='true'] .chip-pile-face h2,\n.chip-pile-stage[data-compact='true'] .chip-pile-copy,\n.chip-pile-stage[data-compact='true'] .chip-pile-hint {\n  display: none;\n}\n\n/* 18px tall, so it clears the floor line at either end of the strip. It is the one\n   thing in here that re-runs the fall, which is worth keeping in a card. */\n.chip-pile-stage[data-compact='true'] .chip-pile-toss {\n  flex: none;\n  padding: 0.1875rem 0.5rem;\n  font-size: 0.625rem;\n  line-height: 1;\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","particles","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/chip-pile"}}