{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"destructible-membrane","type":"registry:ui","title":"Destructible Membrane","description":"A canvas sheet you tear with the pointer. Springs hold the cloth, the rupture propagates to neighbouring cells, and the noise buffer is rebuilt on resize.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/destructible-membrane.tsx","target":"components/ui/destructible-membrane.tsx","content":"'use client';\n\nimport './destructible-membrane.css';\n\nimport { useEffect, useRef } from 'react';\n\nimport {\n  useCanvasScene,\n  useReducedMotion,\n  type SceneSetupContext,\n} from '@/hooks/use-canvas-scene';\n\n/**\n * A grain-textured membrane stretched over a pinned spring lattice. Drag it and\n * the links strain, snap and fray; the cells they held tear loose, flap open and\n * expose the message printed underneath.\n *\n * `useCanvasScene` owns the canvas. What is left here is the lattice, the damage\n * model and the tear propagation.\n */\n\nconst COLUMNS = 24;\nconst ROWS = 15;\nconst PAD_X = 30;\nconst PAD_Y = 42;\n/**\n * The frame a card nails the sheet to. The authored 30/42 spends 84 of a card's 240\n * pixels on bare backing and leaves the membrane 156 of them — a small grey panel\n * inside a wide orange border. 14 hands it 270x212 and keeps a rim of about one cell\n * on every side. The cells come out 11.7 by 15.1 rather than square, which the model\n * does not mind: rest lengths are measured per axis, and the grain is a repeating\n * pattern rather than a stretched bitmap.\n */\nconst CARD_PAD = 14;\n/**\n * Every radius in the damage model is an absolute pixel count, tuned against the stage\n * the item page gives this component: a 24x15 lattice over roughly 880 to 1040 by 360,\n * so a mean pitch near 30px and a 60px brush that reaches two cells. A card's pitch is\n * 13.4, where the same brush reaches five or six in every direction and one drag takes\n * most of the sheet off in a single pass instead of opening a gash. This is that pitch\n * ratio, and it scales every length the damage model measures in. Pointer thresholds\n * are left alone: how far a hand has to pull is a human distance, not a lattice one.\n */\nconst CARD_REACH = 0.45;\n/** Relaxation passes per frame. Three is the least that holds the weave taut. */\nconst ITERATIONS = 3;\n/** How far a tear front runs before it burns out. */\nconst TEAR_STEPS = 13;\n\ninterface MembraneNode {\n  x: number;\n  y: number;\n  /** Rest position. Both the tether target and where the grain is sampled from. */\n  readonly ox: number;\n  readonly oy: number;\n  vx: number;\n  vy: number;\n  /** Edge nodes are pinned: the sheet is nailed to its frame. */\n  readonly pinned: boolean;\n}\n\ninterface Link {\n  readonly a: number;\n  readonly b: number;\n  readonly rest: number;\n  health: number;\n  broken: boolean;\n  /** Diagonals resist shear; they are weaker and softer than the axis links. */\n  readonly diagonal: boolean;\n  readonly seed: number;\n}\n\ninterface Cell {\n  /** The four lattice nodes, clockwise from the top-left. */\n  readonly ids: readonly [number, number, number, number];\n  damage: number;\n  torn: boolean;\n  /** How far the flap has peeled, in pixels. */\n  flap: number;\n  flapVelocity: number;\n  direction: number;\n  readonly seed: number;\n  curl: number;\n}\n\n/** A tear travelling through the lattice, damaging what it passes. */\ninterface TearFront {\n  readonly x: number;\n  readonly y: number;\n  readonly dx: number;\n  readonly dy: number;\n  readonly energy: number;\n  step: number;\n  life: number;\n}\n\ninterface MembraneState {\n  readonly nodes: MembraneNode[];\n  readonly links: Link[];\n  readonly cells: Cell[];\n  fronts: TearFront[];\n  /** The node under the pointer, held out of the physics while dragged. */\n  dragging: MembraneNode | null;\n  wasDown: boolean;\n  /** Where the current drag began — the throw direction is measured from it. */\n  pressX: number;\n  pressY: number;\n  /**\n   * Scales the lengths that decide how much of the sheet a tear takes: damage and break\n   * radii, how far a tear front steps and sways, how hard it kicks the weave, how far a\n   * flap peels. 1 at full size, `CARD_REACH` in a card.\n   *\n   * Not the marks a tear leaves — the damage inset, the outline jitter, the frayed\n   * strands, the pale torn edge. Those are one to three pixels as authored, and through\n   * `reach` they would land under a pixel in the frame that needs them most: a card's\n   * cell is 12 wide, and the 1.45px the inset reaches by the 0.58 damage a cell tears at\n   * is the only cue there is that the weave is going before anything moves.\n   */\n  readonly reach: number;\n  readonly grain: CanvasPattern | null;\n}\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, value));\nconst random = (min: number, max: number) => min + Math.random() * (max - min);\nconst nodeId = (x: number, y: number) => y * COLUMNS + x;\n\n/**\n * A 72px tile of paper grain, repeated as a pattern. One small tile beats a\n * full-stage noise buffer: it is rebuilt per resize but sampled per cell.\n */\nfunction makeGrain(context: CanvasRenderingContext2D): CanvasPattern | null {\n  const tile = document.createElement('canvas');\n  tile.width = 72;\n  tile.height = 72;\n\n  const brush = tile.getContext('2d');\n  if (!brush) return null;\n\n  brush.fillStyle = '#242321';\n  brush.fillRect(0, 0, 72, 72);\n  for (let index = 0; index < 420; index++) {\n    brush.fillStyle = Math.random() > 0.5 ? 'rgba(255,255,255,.055)' : 'rgba(0,0,0,.12)';\n    brush.fillRect(\n      Math.random() * 72,\n      Math.random() * 72,\n      Math.random() * 1.5 + 0.25,\n      Math.random() * 0.7 + 0.18,\n    );\n  }\n\n  return context.createPattern(tile, 'repeat');\n}\n\nfunction build(\n  { context, width, height }: SceneSetupContext,\n  compact: boolean,\n): MembraneState {\n  const padX = compact ? CARD_PAD : PAD_X;\n  const padY = compact ? CARD_PAD : PAD_Y;\n  const spaceX = (width - padX * 2) / (COLUMNS - 1);\n  const spaceY = (height - padY * 2) / (ROWS - 1);\n\n  const nodes: MembraneNode[] = [];\n  for (let y = 0; y < ROWS; y++) {\n    for (let x = 0; x < COLUMNS; x++) {\n      const px = padX + x * spaceX;\n      const py = padY + y * spaceY;\n      nodes.push({\n        x: px,\n        y: py,\n        ox: px,\n        oy: py,\n        vx: 0,\n        vy: 0,\n        pinned: x === 0 || x === COLUMNS - 1 || y === 0 || y === ROWS - 1,\n      });\n    }\n  }\n\n  const links: Link[] = [];\n  const link = (a: number, b: number, rest: number, diagonal = false) =>\n    links.push({\n      a,\n      b,\n      rest,\n      health: 1,\n      broken: false,\n      diagonal,\n      seed: Math.random() * 100,\n    });\n\n  const diagonal = Math.hypot(spaceX, spaceY);\n  for (let y = 0; y < ROWS; y++) {\n    for (let x = 0; x < COLUMNS; x++) {\n      if (x < COLUMNS - 1) link(nodeId(x, y), nodeId(x + 1, y), spaceX);\n      if (y < ROWS - 1) link(nodeId(x, y), nodeId(x, y + 1), spaceY);\n      // Both diagonals, so a quad cannot fold flat without breaking something.\n      if (x < COLUMNS - 1 && y < ROWS - 1) {\n        link(nodeId(x, y), nodeId(x + 1, y + 1), diagonal, true);\n      }\n      if (x > 0 && y < ROWS - 1) link(nodeId(x, y), nodeId(x - 1, y + 1), diagonal, true);\n    }\n  }\n\n  const cells: Cell[] = [];\n  for (let y = 0; y < ROWS - 1; y++) {\n    for (let x = 0; x < COLUMNS - 1; x++) {\n      cells.push({\n        ids: [nodeId(x, y), nodeId(x + 1, y), nodeId(x + 1, y + 1), nodeId(x, y + 1)],\n        damage: 0,\n        torn: false,\n        flap: 0,\n        flapVelocity: 0,\n        direction: 0,\n        seed: Math.random() * 100,\n        curl: random(-1, 1),\n      });\n    }\n  }\n\n  return {\n    nodes,\n    links,\n    cells,\n    fronts: [],\n    dragging: null,\n    wasDown: false,\n    pressX: 0,\n    pressY: 0,\n    reach: compact ? CARD_REACH : 1,\n    grain: makeGrain(context),\n  };\n}\n\n/** Returns a cell's four nodes, or null if the lattice does not contain them. */\nfunction cellNodes(state: MembraneState, cell: Cell): MembraneNode[] | null {\n  const result: MembraneNode[] = [];\n  for (const id of cell.ids) {\n    const node = state.nodes[id];\n    if (!node) return null;\n    result.push(node);\n  }\n  return result;\n}\n\n/** Mean position of a cell's nodes. */\nfunction centreOf(nodes: readonly MembraneNode[]) {\n  let x = 0;\n  let y = 0;\n  for (const node of nodes) {\n    x += node.x;\n    y += node.y;\n  }\n  return { x: x / nodes.length, y: y / nodes.length };\n}\n\n/**\n * Accumulates damage on every cell inside `radius`, tearing the ones that pass\n * the threshold. Tearing is one-way: a cell that has come loose stays loose.\n *\n * Callers pass the radius they were tuned with; `state.reach` is what makes the same\n * brush cover the same share of a card's smaller lattice.\n */\nfunction damageAt(\n  state: MembraneState,\n  x: number,\n  y: number,\n  amount: number,\n  radius = 54,\n  direction = 0,\n) {\n  const span = radius * state.reach;\n\n  for (const cell of state.cells) {\n    const nodes = cellNodes(state, cell);\n    if (!nodes) continue;\n\n    const centre = centreOf(nodes);\n    const distance = Math.hypot(centre.x - x, centre.y - y);\n    if (distance >= span) continue;\n\n    cell.damage = clamp(cell.damage + amount * (1 - distance / span), 0, 1);\n    if (cell.damage > 0.58 && !cell.torn) {\n      cell.torn = true;\n      cell.flapVelocity = random(0.35, 0.85) + amount;\n      cell.direction = direction + random(-0.38, 0.38);\n      cell.curl = random(-1, 1);\n    }\n  }\n}\n\n/**\n * Wears down the links near a point. Links running across the tear direction\n * take the most: a tear travels along the weave rather than through it.\n */\nfunction breakLinks(\n  state: MembraneState,\n  x: number,\n  y: number,\n  force: number,\n  radius = 60,\n  direction = 0,\n) {\n  const span = radius * state.reach;\n\n  for (const link of state.links) {\n    if (link.broken) continue;\n\n    const a = state.nodes[link.a];\n    const b = state.nodes[link.b];\n    if (!a || !b) continue;\n\n    const mx = (a.x + b.x) / 2;\n    const my = (a.y + b.y) / 2;\n    const distance = Math.hypot(mx - x, my - y);\n    if (distance >= span) continue;\n\n    const angle = Math.atan2(b.y - a.y, b.x - a.x);\n    const alignment = 0.42 + Math.abs(Math.sin(angle - direction)) * 0.72;\n    link.health -= force * (1 - distance / span) * alignment * (link.diagonal ? 0.76 : 1);\n\n    if (link.health <= 0) {\n      link.health = 0;\n      link.broken = true;\n      // A snapped link damages what it was holding, which is how one break\n      // cascades into a tear.\n      damageAt(state, mx, my, 0.34, 44, direction);\n    }\n  }\n}\n\n/** Starts a tear at a point, travelling in a direction. */\nfunction rupture(\n  state: MembraneState,\n  x: number,\n  y: number,\n  dx: number,\n  dy: number,\n  energy: number,\n) {\n  const length = Math.hypot(dx, dy) || 1;\n  state.fronts.push({\n    x,\n    y,\n    dx: dx / length,\n    dy: dy / length,\n    energy,\n    step: 0,\n    life: 1,\n  });\n\n  const direction = Math.atan2(dy, dx);\n  damageAt(state, x, y, energy * 0.75, 72, direction);\n  breakLinks(state, x, y, energy, 68, direction);\n}\n\n/** Advances every live tear one step, damaging and kicking what it passes. */\nfunction advanceFronts(state: MembraneState) {\n  // The stride, the wander and the shove a front gives the weave are all lengths, so\n  // all three travel with `reach`. A card's front covers 140px in its thirteen steps\n  // rather than 312, which is the same share of a sheet a third of the width.\n  const stride = 24 * state.reach;\n  const wander = 13 * state.reach;\n  const kick = 62 * state.reach;\n\n  for (const front of state.fronts) {\n    if (front.step > TEAR_STEPS) {\n      front.life *= 0.86;\n      continue;\n    }\n\n    // The sway is what keeps a tear from being a straight line.\n    const sway = Math.sin(front.step * 1.77 + front.x * 0.013) * wander;\n    const px = front.x + front.dx * front.step * stride - front.dy * sway;\n    const py = front.y + front.dy * front.step * stride + front.dx * sway;\n    const direction = Math.atan2(front.dy, front.dx);\n\n    damageAt(state, px, py, front.energy * (0.48 - front.step * 0.015), 50, direction);\n    breakLinks(state, px, py, front.energy * (0.72 - front.step * 0.026), 58, direction);\n\n    for (const node of state.nodes) {\n      if (node.pinned) continue;\n      const distance = Math.hypot(node.x - px, node.y - py);\n      if (distance >= kick) continue;\n      // Kicked sideways, not along the tear: the sheet parts around it.\n      const impulse = (1 - distance / kick) * front.energy * state.reach;\n      node.vx += (-front.dy + front.dx * 0.35) * impulse * 1.8;\n      node.vy += (front.dx + front.dy * 0.35) * impulse * 1.8;\n    }\n\n    front.step++;\n  }\n\n  if (state.fronts.some(front => front.life <= 0.06)) {\n    state.fronts = state.fronts.filter(front => front.life > 0.06);\n  }\n}\n\n/**\n * One relaxation pass: pull the links back to rest, wear out the overstretched\n * ones, then integrate. Run several times per frame — a single pass leaves the\n * lattice rubbery.\n */\nfunction relax(state: MembraneState) {\n  for (const link of state.links) {\n    if (link.broken) continue;\n\n    const a = state.nodes[link.a];\n    const b = state.nodes[link.b];\n    if (!a || !b) continue;\n\n    const dx = b.x - a.x;\n    const dy = b.y - a.y;\n    const distance = Math.hypot(dx, dy) || 1;\n    const difference = (distance - link.rest) / distance;\n    const strain = Math.abs(distance - link.rest) / link.rest;\n    const stiffness = link.diagonal ? 0.034 : 0.062;\n\n    // Fatigue: held past 14% strain a link fails on its own, so a slow pull\n    // tears as surely as a fast one.\n    if (strain > 0.14) {\n      link.health -= Math.pow(strain - 0.12, 1.35) * 0.035;\n      if (link.health <= 0) {\n        link.health = 0;\n        link.broken = true;\n        damageAt(state, (a.x + b.x) / 2, (a.y + b.y) / 2, 0.38, 46, Math.atan2(dy, dx));\n      }\n    }\n\n    if (a !== state.dragging && !a.pinned) {\n      a.vx += dx * difference * stiffness;\n      a.vy += dy * difference * stiffness;\n    }\n    if (b !== state.dragging && !b.pinned) {\n      b.vx -= dx * difference * stiffness;\n      b.vy -= dy * difference * stiffness;\n    }\n  }\n\n  for (const node of state.nodes) {\n    if (node === state.dragging) continue;\n    // Pinned nodes are tethered hard to the frame; the rest sag slightly.\n    const tether = node.pinned ? 0.14 : 0.006;\n    node.vx += (node.ox - node.x) * tether;\n    node.vy += (node.oy - node.y) * tether + (node.pinned ? 0 : 0.006);\n    node.vx *= 0.91;\n    node.vy *= 0.91;\n    node.x += node.vx;\n    node.y += node.vy;\n  }\n}\n\n/** The nearest unpinned node within grabbing distance of a point. */\nfunction nearestNode(state: MembraneState, x: number, y: number): MembraneNode | null {\n  let best = 42;\n  let found: MembraneNode | null = null;\n\n  for (const node of state.nodes) {\n    if (node.pinned) continue;\n    const distance = Math.hypot(node.x - x, node.y - y);\n    if (distance >= best) continue;\n    best = distance;\n    found = node;\n  }\n\n  return found;\n}\n\n/**\n * Heals the sheet in place. The lattice geometry is unchanged, so this is a\n * reset rather than a rebuild — but the seeds are re-rolled, so the next tear\n * frays differently.\n */\nfunction resetMembrane(state: MembraneState) {\n  for (const node of state.nodes) {\n    node.x = node.ox;\n    node.y = node.oy;\n    node.vx = 0;\n    node.vy = 0;\n  }\n  for (const link of state.links) {\n    link.health = 1;\n    link.broken = false;\n  }\n  for (const cell of state.cells) {\n    cell.damage = 0;\n    cell.torn = false;\n    cell.flap = 0;\n    cell.flapVelocity = 0;\n    cell.direction = 0;\n    cell.curl = random(-1, 1);\n  }\n  state.fronts = [];\n  state.dragging = null;\n}\n\n/** Traces a closed outline into the current path. */\nfunction tracePath(\n  context: CanvasRenderingContext2D,\n  points: ReadonlyArray<{ x: number; y: number }>,\n) {\n  const first = points[0];\n  if (!first) return;\n\n  context.beginPath();\n  context.moveTo(first.x, first.y);\n  for (let index = 1; index < points.length; index++) {\n    const point = points[index];\n    if (!point) continue;\n    context.lineTo(point.x, point.y);\n  }\n  context.closePath();\n}\n\n/** The message the membrane is hiding, plus the hazard stripes behind it. */\nfunction paintBacking(context: CanvasRenderingContext2D, width: number, height: number) {\n  context.clearRect(0, 0, width, height);\n  context.fillStyle = '#ff5a40';\n  context.fillRect(0, 0, width, height);\n\n  context.fillStyle = 'rgba(10,10,10,.13)';\n  for (let x = -height; x < width + height; x += 30) {\n    context.save();\n    context.translate(x, 0);\n    context.rotate(-0.18);\n    context.fillRect(0, -60, 9, height + 120);\n    context.restore();\n  }\n\n  const size = Math.min(72, width * 0.13);\n  context.fillStyle = '#0c0c0c';\n  context.font = `900 ${size}px Arial`;\n  context.textBaseline = 'top';\n  context.fillText('YOU', 42, height * 0.28);\n  context.fillText('BROKE', 42, height * 0.28 + size * 0.88);\n  context.fillText('THE UI.', 42, height * 0.28 + size * 1.76);\n}\n\n/**\n * Draws one cell of the sheet. Intact cells shrink slightly as they take damage\n * — the gaps between them are the tear becoming visible before anything moves.\n */\nfunction drawCell(\n  context: CanvasRenderingContext2D,\n  state: MembraneState,\n  cell: Cell,\n  animate: boolean,\n) {\n  const nodes = cellNodes(state, cell);\n  if (!nodes) return;\n\n  const centre = centreOf(nodes);\n  const pull = cell.damage * 2.5;\n  const outline = nodes.map((node, index) => {\n    const dx = node.x - centre.x;\n    const dy = node.y - centre.y;\n    const distance = Math.hypot(dx, dy) || 1;\n    const jitter = Math.sin(cell.seed + index * 5.3) * cell.damage * 2.2;\n    return {\n      x: node.x - (dx / distance) * pull + (dy / distance) * jitter,\n      y: node.y - (dy / distance) * pull - (dx / distance) * jitter,\n    };\n  });\n\n  const fill = state.grain ?? '#242321';\n\n  if (!cell.torn) {\n    context.fillStyle = fill;\n    tracePath(context, outline);\n    context.fill();\n    // A hairline appears well before the cell lets go: the surface shows fatigue.\n    if (cell.damage > 0.12) {\n      context.strokeStyle = `rgba(245,238,222,${clamp((cell.damage - 0.12) * 0.5, 0, 0.36)})`;\n      context.lineWidth = 0.7;\n      context.stroke();\n    }\n    return;\n  }\n\n  if (animate) {\n    cell.flapVelocity += 0.007;\n    cell.flapVelocity *= 0.982;\n    // A flap is a length too: 60px of peel is a cell and a half at full size and four\n    // cells in a card, which reads as confetti rather than a sheet coming apart.\n    const ceiling = (18 + cell.damage * 42) * state.reach;\n    cell.flap = clamp(cell.flap + cell.flapVelocity, 0, ceiling);\n  }\n\n  const lift = Math.sin(Math.min(Math.PI * 0.78, cell.flap * 0.035)) * 16 * state.reach;\n  const flapX = Math.cos(cell.direction) * cell.flap;\n  const flapY = Math.sin(cell.direction) * cell.flap + cell.flap * 0.16;\n\n  context.save();\n  context.translate(centre.x, centre.y);\n  context.rotate(cell.curl * Math.min(0.22, cell.flap * 0.004));\n  context.translate(-centre.x, -centre.y);\n\n  // Only the far edge lifts, so the flap hinges instead of sliding.\n  const peeled = outline.map((point, index) => ({\n    x: point.x + flapX + (index > 1 ? cell.curl * lift : 0),\n    y: point.y + flapY - (index === 1 || index === 2 ? lift : 0),\n  }));\n\n  // The fixed part of the drop shadow travels with `reach`; the part the flap drives\n  // already has. A 12px blur on a 12x15 flake would be wider than the flake.\n  context.shadowColor = 'rgba(0,0,0,.58)';\n  context.shadowBlur = 12 * state.reach + lift * 0.55;\n  context.shadowOffsetX = 6 * state.reach + flapX * 0.08;\n  context.shadowOffsetY = 8 * state.reach + flapY * 0.08;\n  context.fillStyle = 'rgba(0,0,0,.34)';\n  tracePath(context, peeled);\n  context.fill();\n\n  context.shadowColor = 'transparent';\n  context.shadowOffsetX = 0;\n  context.shadowOffsetY = 0;\n  context.fillStyle = fill;\n  tracePath(context, peeled);\n  context.fill();\n\n  // The pale torn edge, drawn along the two highest points of the flap.\n  const edge = [...peeled].sort((a, b) => a.y - b.y);\n  const start = edge[0];\n  const end = edge[1];\n  if (start && end) {\n    context.strokeStyle = 'rgba(247,239,219,.88)';\n    context.lineWidth = 1.25;\n    context.beginPath();\n    context.moveTo(start.x, start.y);\n    context.lineTo((start.x + end.x) / 2 + cell.curl * 3, (start.y + end.y) / 2 + 2);\n    context.lineTo(end.x, end.y);\n    context.stroke();\n  }\n\n  context.restore();\n}\n\n/** Broken links, drawn as three frayed strands parted at the middle. */\nfunction drawFrays(context: CanvasRenderingContext2D, state: MembraneState) {\n  for (const link of state.links) {\n    if (!link.broken) continue;\n\n    const a = state.nodes[link.a];\n    const b = state.nodes[link.b];\n    if (!a || !b) continue;\n\n    const mx = (a.x + b.x) / 2;\n    const my = (a.y + b.y) / 2;\n    const dx = b.x - a.x;\n    const dy = b.y - a.y;\n    const distance = Math.hypot(dx, dy) || 1;\n    const nx = -dy / distance;\n    const ny = dx / distance;\n\n    for (let strand = -1; strand <= 1; strand++) {\n      const offset = strand * 1.1;\n      const fray = Math.sin(link.seed + strand * 2.2) * 4;\n      context.strokeStyle = `rgba(244,235,214,${0.5 - Math.abs(strand) * 0.08})`;\n      context.lineWidth = 0.34;\n      context.beginPath();\n      context.moveTo(a.x + nx * offset, a.y + ny * offset);\n      context.quadraticCurveTo(\n        mx + nx * (fray + offset),\n        my + ny * (fray + offset),\n        mx - (dx / distance) * 3 + nx * offset,\n        my - (dy / distance) * 3 + ny * offset,\n      );\n      context.moveTo(\n        mx + (dx / distance) * 3 + nx * offset,\n        my + (dy / distance) * 3 + ny * offset,\n      );\n      context.quadraticCurveTo(\n        mx - nx * (fray - offset),\n        my - ny * (fray - offset),\n        b.x + nx * offset,\n        b.y + ny * offset,\n      );\n      context.stroke();\n    }\n  }\n}\n\n/**\n * The drag forces were tuned against pointer events, of which there are a\n * handful per frame; applied once per frame instead, they need making up.\n */\nconst PER_FRAME_GAIN = 2;\n\n/** `compact` is the 298x240 catalogue card: the same sheet, the same damage model and\n *  the same lattice, given the whole frame instead of a 30/42 border of backing, with\n *  every length inside it scaled to the smaller pitch and one rupture spent on arrival\n *  so the card shows what the component does. See `destructible-membrane.css`. */\nexport type DestructibleMembraneProps = { compact?: boolean };\n\nexport function DestructibleMembrane({ compact = false }: DestructibleMembraneProps) {\n  const reduced = useReducedMotion();\n  /**\n   * A rupture or a reset asked for between frames. Applied inside `draw`, where\n   * the lattice is in hand — a resize replaces it, so an event handler has no\n   * business holding a reference to it.\n   */\n  const commandRef = useRef<'rupture' | 'reset' | null>(null);\n  const buttonRef = useRef<HTMLButtonElement | null>(null);\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<MembraneState>({\n    setup: (scene) => build(scene, compact),\n    draw: ({ context, width, height, state, pointer, frame }) => {\n      const asked = commandRef.current;\n      commandRef.current = null;\n      /*\n       * A card tears itself once, on the first painted frame of the scene. An intact\n       * membrane is a still image — it only moves when something pulls on it — and a\n       * catalogue card showing a blank grey panel says nothing about what this is. The\n       * seed lands on the mount paint, and the front then advances only in painted\n       * frames, so a card that mounts below the fold opens its gash as it scrolls into\n       * view rather than while nobody is looking. `frame` returns to 0 on the rebuild a\n       * resize forces, which is what re-arms it.\n       */\n      const command = asked ?? (compact && frame === 0 ? 'rupture' : null);\n      if (command === 'reset') resetMembrane(state);\n      // Thrown left in a card: the message underneath is set from the left margin, and\n      // the same tear sent right opens onto the blank half of the sheet.\n      if (command === 'rupture') {\n        rupture(state, width * 0.48, height * 0.48, compact ? -1 : 1, 0.22, 0.82);\n      }\n\n      if (pointer.down && !state.wasDown) {\n        state.pressX = pointer.x;\n        state.pressY = pointer.y;\n        state.dragging = nearestNode(state, pointer.x, pointer.y);\n      } else if (!pointer.down && state.wasDown) {\n        const held = state.dragging;\n        if (held) {\n          // Measured from the node, not the pointer: a release followed by the\n          // pointer leaving would otherwise throw it at the reset sentinel.\n          const dx = held.x - state.pressX;\n          const dy = held.y - state.pressY;\n          const stretch = Math.hypot(held.x - held.ox, held.y - held.oy);\n          held.vx = dx * 0.055;\n          held.vy = dy * 0.055;\n          if (stretch > 36) {\n            rupture(state, held.x, held.y, dx, dy, clamp(stretch / 150, 0.35, 0.96));\n          }\n        }\n        state.dragging = null;\n      }\n      state.wasDown = pointer.down;\n\n      const held = state.dragging;\n      if (held && pointer.inside) {\n        const stretch = Math.hypot(pointer.x - held.ox, pointer.y - held.oy);\n        const speed = Math.hypot(pointer.x - pointer.lastX, pointer.y - pointer.lastY);\n        const angle = Math.atan2(pointer.y - state.pressY, pointer.x - state.pressX);\n\n        // The held node follows the pointer exactly; the physics leaves it alone.\n        held.x = pointer.x;\n        held.y = pointer.y;\n        held.vx = 0;\n        held.vy = 0;\n\n        if (stretch > 22) {\n          const force = clamp((stretch - 22) / 82, 0, 0.19) * PER_FRAME_GAIN;\n          damageAt(state, pointer.x, pointer.y, force * 0.32, 60, angle);\n          breakLinks(state, pointer.x, pointer.y, force, 64, angle);\n        }\n        // A fast swipe cuts even when the sheet is barely stretched.\n        if (speed > 8) {\n          const force = clamp(speed / 130, 0, 0.13) * PER_FRAME_GAIN;\n          breakLinks(state, pointer.x, pointer.y, force, 48, angle);\n        }\n      }\n\n      if (reduced) {\n        if (command === 'rupture') {\n          // No frames to propagate across, so the tear is run to its end and the\n          // lattice settled in one pass.\n          for (let step = 0; step <= TEAR_STEPS; step++) advanceFronts(state);\n          state.fronts = [];\n          for (let pass = 0; pass < 24; pass++) relax(state);\n          for (const cell of state.cells) {\n            if (cell.torn) cell.flap = (18 + cell.damage * 42) * state.reach;\n          }\n        }\n      } else {\n        // Tears advance on alternate frames: a front that moved 24px every frame\n        // outruns the lattice it is tearing.\n        if (frame % 2 === 0) advanceFronts(state);\n        for (let pass = 0; pass < ITERATIONS; pass++) relax(state);\n      }\n\n      paintBacking(context, width, height);\n      for (const cell of state.cells) drawCell(context, state, cell, !reduced);\n      drawFrays(context, state);\n    },\n  });\n\n  useEffect(() => {\n    const button = buttonRef.current;\n    if (!button) return;\n\n    /*\n     * React dispatches from the root container, so a synthetic\n     * `stopPropagation` would run only after the stage's own native listener\n     * had already started a drag — and taken pointer capture with it. Stopping\n     * the native event is what keeps the reset button from grabbing the sheet.\n     */\n    const stop = (event: PointerEvent) => event.stopPropagation();\n    button.addEventListener('pointerdown', stop);\n    return () => button.removeEventListener('pointerdown', stop);\n  }, []);\n\n  return (\n    <div className=\"destructible-membrane-stage\" data-compact={compact ? 'true' : undefined}>\n      {/* The sheet is a control at full size: Enter and Space rupture it. In a card it\n          keeps the pointer and loses the tab stop — the frame around it is aria-hidden,\n          and a focusable node inside one is a trap with no name. */}\n      <div\n        ref={stageRef}\n        className=\"destructible-membrane\"\n        role=\"button\"\n        tabIndex={compact ? -1 : 0}\n        aria-label=\"Breakable membrane. Drag the surface to tear it and expose the message below.\"\n        onKeyDown={event => {\n          if (event.key !== 'Enter' && event.key !== ' ') return;\n          event.preventDefault();\n          commandRef.current = 'rupture';\n          requestRender();\n        }}\n      >\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n        {/* Still clickable in a card, and it is the only way back to an intact sheet\n            once the card has torn itself, but out of the tab order for the same\n            reason. */}\n        <button\n          ref={buttonRef}\n          className=\"membrane-control\"\n          type=\"button\"\n          aria-label=\"Rebuild membrane\"\n          tabIndex={compact ? -1 : undefined}\n          onClick={() => {\n            commandRef.current = 'reset';\n            requestRender();\n          }}\n        >\n          <i />\n          <i />\n        </button>\n      </div>\n\n      {/* Rendered only in a card, where the sheet is the whole frame and nothing else\n          says the surface can be pulled. The full stage is untouched. */}\n      {compact && <p className=\"destructible-membrane-hint\">Drag to tear</p>}\n    </div>\n  );\n}\n","type":"registry:ui"},{"path":"components/ui/destructible-membrane.css","target":"components/ui/destructible-membrane.css","content":"/* The surface the membrane is designed to sit on. Size it from the parent. */\n.destructible-membrane-stage {\n  position: relative;\n  display: grid;\n  place-items: center;\n  width: 100%;\n  height: 100%;\n  min-height: 240px;\n  container-type: inline-size;\n  overflow: hidden;\n  background: #111;\n  color: #f2efe7;\n}\n\n.destructible-membrane {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background: #111;\n  cursor: grab;\n  touch-action: none;\n}\n\n.destructible-membrane:active {\n  cursor: grabbing;\n}\n\n.destructible-membrane:focus-visible {\n  outline: 3px solid #d8ff43;\n  outline-offset: -5px;\n}\n\n.destructible-membrane canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n/*\n * The rebuild button is scoped like every other rule here rather than shipping\n * `.membrane-control` as a global: it is a generic name a host app could easily\n * own too, and the markup always nests the button inside the membrane, so the\n * descendant selector costs nothing and keeps the sheet inert outside it.\n */\n.destructible-membrane .membrane-control {\n  position: absolute;\n  left: 17px;\n  bottom: 17px;\n  width: 42px;\n  height: 42px;\n  border: 1px solid #d8ff43;\n  background: #111;\n  cursor: pointer;\n}\n\n.destructible-membrane .membrane-control i:first-child {\n  position: absolute;\n  inset: 9px;\n  border: 1.5px solid #d8ff43;\n  border-left-color: transparent;\n  border-radius: 50%;\n}\n\n.destructible-membrane .membrane-control i:last-child {\n  position: absolute;\n  left: 7px;\n  top: 8px;\n  width: 8px;\n  height: 8px;\n  border-top: 1.5px solid #d8ff43;\n  border-left: 1.5px solid #d8ff43;\n  transform: rotate(-16deg);\n}\n\n.destructible-membrane .membrane-control:hover {\n  background: #d8ff43;\n}\n\n.destructible-membrane .membrane-control:hover i:first-child {\n  border-color: #111;\n  border-left-color: transparent;\n}\n\n.destructible-membrane .membrane-control:hover i:last-child {\n  border-color: #111;\n}\n\n.destructible-membrane .membrane-control:focus-visible {\n  outline: 3px solid #3155e7;\n  outline-offset: 3px;\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n *\n * There is no copy to drop here — this stage was never a marketing section, it was only\n * ever the sheet — so the composition work is on the other side of the canvas and lives\n * in the .tsx: `CARD_PAD` cuts the 30/42 border of bare backing that left the membrane\n * 156 of the 240 pixels, and `CARD_REACH` brings the damage radii down to the smaller\n * lattice pitch. What is left for CSS is the frame's own sizing, the touch gesture, and\n * the two pieces of chrome sitting on top of the sheet.\n */\n.destructible-membrane-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n}\n\n/*\n * `pan-y`, not the `none` a drag surface wants: full-bleed and swallowing the vertical\n * gesture, this card would trap the page in a scrolling grid on a phone. Tearing is a\n * sideways pull anyway — wear is weighted by how squarely a link crosses the pull, so a\n * horizontal drag is the one that breaks the most of them — and that gesture survives\n * the trade intact.\n */\n.destructible-membrane-stage[data-compact='true'] .destructible-membrane {\n  touch-action: pan-y;\n}\n\n/*\n * 42px of lime in the corner of a 298x240 frame is a control arguing with the mechanism,\n * so the whole glyph comes down by 0.76 — box, ring, arrowhead and inset together, which\n * is why every number moved and not just the box. 32px clears the 24px minimum target\n * size, not the comfortable 44px, and in a card that is the right way round: it is the\n * only way back to an intact sheet once the card has torn itself, so it has to stay, and\n * it is a detail rather than the subject, so it has to be small. The 1.5px strokes are\n * left alone — scaled with the rest they would land on 1.14 and go soft.\n */\n.destructible-membrane-stage[data-compact='true'] .membrane-control {\n  left: 10px;\n  bottom: 10px;\n  width: 32px;\n  height: 32px;\n}\n\n.destructible-membrane-stage[data-compact='true'] .membrane-control i:first-child {\n  inset: 7px;\n}\n\n.destructible-membrane-stage[data-compact='true'] .membrane-control i:last-child {\n  left: 5px;\n  top: 6px;\n  width: 6px;\n  height: 6px;\n}\n\n/*\n * The one line of text, and it earns the room: the sheet holds still until something\n * pulls on it, and the grab cursor that says so on a desktop says nothing on a phone.\n * Bottom right, on the rebuild button's own centre line — 20px up plus half of 11 is\n * the 26 a 32px box at inset 10 centres on — and clear of it across the card: that box\n * ends 42px in from the left edge, and the type takes about 90 of the 298 back from the\n * right. Absolute, so it takes no grid row from the sheet; deaf to the pointer, so a\n * drag that starts on the words still takes hold of the surface underneath. The\n * shadow is what keeps it legible on both grounds it can end up over — the grain at\n * rest, the orange backing once the corner beneath it has torn away.\n */\n.destructible-membrane-stage[data-compact='true'] .destructible-membrane-hint {\n  position: absolute;\n  inset: auto 12px 20px auto;\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(242, 239, 231, 0.74);\n  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);\n  pointer-events: none;\n}\n\n/*\n * `useCanvasScene` never starts the loop under reduced motion, so the sheet is already\n * still by the time this matters — the only thing left to correct is the grab cursor,\n * which would otherwise promise a drag the halted scene cannot answer. The control keeps\n * its pointer cursor: it is a real button and pressing it still does something.\n */\n@media (prefers-reduced-motion: reduce) {\n  .destructible-membrane,\n  .destructible-membrane:active {\n    cursor: default;\n  }\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","noise"],"docs":"https://ui.artbloom.tech/artbloom/animations/destructible-membrane"}}