{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"pressure-button","type":"registry:ui","title":"Pressure Button","description":"A button whose face is a pressure vessel. Holding it compresses the gas inside, the shell deforms under the real load, and letting go vents it — so the press has weight instead of a scale transform.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/pressure-button.tsx","target":"components/ui/pressure-button.tsx","content":"'use client';\n\nimport './pressure-button.css';\n\nimport { useEffect, useRef, useState, type KeyboardEvent } from 'react';\n\nimport { useCanvasScene, useReducedMotion, type SceneDrawContext, type SceneSetupContext } from '@/hooks/use-canvas-scene';\n\n/**\n * A primary call to action whose body is a pressurised softbody, so the pill resists a press\n * instead of merely animating one.\n *\n * Integrated per node: edge springs around a closed ring, weak anchor springs that remember the\n * pill, and a gas force on every edge — f = (P - P0) * L * n, with P = nRT / A and A the shoelace\n * area of the ring. The gas term is the component. Edge springs conserve perimeter, not area: the\n * folded, zero-area ring costs a loop of springs nothing, so a plate pressing the cap keeps\n * flattening it and the ring stays shut. P = nRT / A diverges as A goes to zero, so the more area\n * a press squeezes out the harder the inside shoves back, and it leaves by the only wall that is\n * free — which is why the waist bulges sideways while the cap is pinned. That bulge is off the\n * axis you pressed, and no easing curve on a scale transform will produce it.\n */\n\nconst STEP = 1 / 120;\n\n/* Unit node mass throughout, so every constant here is already an acceleration. */\nconst NODES = 44;\nconst EDGE_K = 2600;\nconst EDGE_DAMP = 26;\n/* Shape memory. Springs plus gas alone maximise area for a fixed perimeter, i.e. they round the\n   pill into a circle within a second; these hold the stadium and nothing else. */\nconst ANCHOR_K = 700;\nconst DRAG = 12;\n/* P0 = nRT / A0: the ambient the balloon sits in balance with, so the rest ring is exactly the\n   pill and the net force is zero until something moves. Scaled by A0 in setup, which keeps the\n   feel identical when the button is measured at a different size. */\nconst REST_PRESSURE = 1150;\nconst AREA_FLOOR = 0.22;\nconst PRESS_DEPTH = 9;\nconst PLATE_LIFTED = -1e6;\nconst PLATE_FRICTION = 0.98;\nconst POKE_RADIUS = 78;\nconst POKE_FORCE = 2700;\n/* 0.2 s held then 0.1 s free, so the first painted frame is already at the top of a rebound. */\nconst WARM_HELD = 24;\nconst WARM_FREE = 12;\nconst RELAX_STEPS = 320;\n\ninterface State {\n  clock: number;\n  carry: number;\n  px: Float64Array;\n  py: Float64Array;\n  vx: Float64Array;\n  vy: Float64Array;\n  ax: Float64Array;\n  ay: Float64Array;\n  rx: Float64Array;\n  ry: Float64Array;\n  len: Float64Array;\n  /** nRT, plus the rest area and cap the whole press is measured against. */\n  gas: number;\n  area0: number;\n  cap: number;\n  /** Rest centroid and half-extents, so the label transform is exactly identity at rest. */\n  cx: number;\n  cy: number;\n  halfW: number;\n  halfH: number;\n  over: number;\n}\n\nconst clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));\n\n/**\n * Evenly spaced by arc length around a stadium, so no edge starts shorter than its neighbours —\n * unequal rest lengths make one side of the ring stiffer and the balloon bulges lopsided.\n */\nconst ringPoint = (t: number, x: number, y: number, w: number, h: number, r: number, out: Float64Array): void => {\n  const flat = Math.max(w - 2 * r, 0);\n  const arc = Math.PI * r;\n  if (t < flat) {\n    out[0] = x + r + t;\n    out[1] = y;\n  } else if (t < flat + arc) {\n    const a = (t - flat) / r - Math.PI / 2;\n    out[0] = x + w - r + Math.cos(a) * r;\n    out[1] = y + r + Math.sin(a) * r;\n  } else if (t < 2 * flat + arc) {\n    out[0] = x + w - r - (t - flat - arc);\n    out[1] = y + h;\n  } else {\n    const a = (t - 2 * flat - arc) / r + Math.PI / 2;\n    out[0] = x + r + Math.cos(a) * r;\n    out[1] = y + r + Math.sin(a) * r;\n  }\n};\n\nconst resetRing = (s: State): void => {\n  for (let i = 0; i < s.px.length; i += 1) {\n    s.px[i] = s.rx[i];\n    s.py[i] = s.ry[i];\n    s.vx[i] = 0;\n    s.vy[i] = 0;\n  }\n};\n\n/** One fixed substep of the ring: springs, gas, the plate contact, then semi-implicit Euler. */\nconst advance = (s: State, plate: number, poking: boolean, pokeX: number, pokeY: number): void => {\n  const { px, py, vx, vy, ax, ay, rx, ry, len } = s;\n  const count = px.length;\n\n  let signed = 0;\n  for (let i = 0; i < count; i += 1) {\n    const j = i + 1 === count ? 0 : i + 1;\n    signed += px[i] * py[j] - px[j] * py[i];\n  }\n  signed *= 0.5;\n  /* The area is the one scalar every node reads, so it is guarded before the per-node repair below\n     rather than after: a single non-finite coordinate poisons the sum, and `over` is written from it\n     and handed to addColorStop as an alpha, which throws on NaN and takes the whole frame with it. */\n  if (!Number.isFinite(signed)) {\n    signed = s.area0;\n  }\n  /* Sign of the shoelace sum, not an assumed winding: it is what keeps the normals pointing out\n     even if a hard press turns the ring inside out for a frame. */\n  const orient = signed >= 0 ? 1 : -1;\n  const area = Math.max(Math.abs(signed), s.area0 * AREA_FLOOR);\n  const dp = s.gas / area - s.gas / s.area0;\n  s.over = (dp * s.area0) / s.gas;\n\n  ax.fill(0);\n  ay.fill(0);\n\n  for (let i = 0; i < count; i += 1) {\n    const j = i + 1 === count ? 0 : i + 1;\n    const dx = px[j] - px[i];\n    const dy = py[j] - py[i];\n    const d = Math.sqrt(dx * dx + dy * dy) || 1e-6;\n    const nx = dx / d;\n    const ny = dy / d;\n    const along = (vx[j] - vx[i]) * nx + (vy[j] - vy[i]) * ny;\n    const f = EDGE_K * (d - len[i]) + EDGE_DAMP * along;\n    ax[i] += f * nx;\n    ay[i] += f * ny;\n    ax[j] -= f * nx;\n    ay[j] -= f * ny;\n    /* (P - P0) * L * n, split between the two ends. The L cancels the 1/L inside the unit normal,\n       so the gas term never divides by an edge length and a collapsed edge cannot produce a NaN. */\n    const gx = dp * orient * dy * 0.5;\n    const gy = dp * orient * -dx * 0.5;\n    ax[i] += gx;\n    ay[i] += gy;\n    ax[j] += gx;\n    ay[j] += gy;\n  }\n\n  for (let i = 0; i < count; i += 1) {\n    ax[i] += (rx[i] - px[i]) * ANCHOR_K - vx[i] * DRAG;\n    ay[i] += (ry[i] - py[i]) * ANCHOR_K - vy[i] * DRAG;\n    if (poking) {\n      const dx = px[i] - pokeX;\n      const dy = py[i] - pokeY;\n      const d2 = dx * dx + dy * dy;\n      if (d2 < POKE_RADIUS * POKE_RADIUS && d2 > 1e-4) {\n        const d = Math.sqrt(d2);\n        const push = (POKE_FORCE * (1 - d / POKE_RADIUS)) / d;\n        ax[i] += dx * push;\n        ay[i] += dy * push;\n      }\n    }\n\n    vx[i] += ax[i] * STEP;\n    vy[i] += ay[i] * STEP;\n    px[i] += vx[i] * STEP;\n    py[i] += vy[i] * STEP;\n\n    /* The finger is a rigid plate, so contact is a projection: the node stops at the plate and\n       loses the velocity it was carrying into it. Nothing is added, so no press can inject energy.\n       While lifted the plate sits far above the stage, which is what lets the cap overshoot. */\n    if (py[i] < plate) {\n      py[i] = plate;\n      if (vy[i] < 0) {\n        vy[i] = 0;\n      }\n      vx[i] *= PLATE_FRICTION;\n    }\n\n    /* One bad frame — a resize mid-substep, a tab restored after an hour — would otherwise leave a\n       NaN in the ring that every later step multiplies forward. Snap that node home instead. */\n    if (!Number.isFinite(px[i]) || !Number.isFinite(py[i])) {\n      px[i] = rx[i];\n      py[i] = ry[i];\n      vx[i] = 0;\n      vy[i] = 0;\n    }\n  }\n};\n\n/** `compact` is the 298x240 catalogue card: the section copy goes and the pill is\n *  centred as the whole subject. Presentation only — see `pressure-button.css`. */\nexport type PressureButtonProps = { compact?: boolean };\n\nexport function PressureButton({ compact = false }: PressureButtonProps) {\n  const reduced = useReducedMotion();\n  const [held, setHeld] = useState(false);\n  const [queued, setQueued] = useState(false);\n  const ctaRef = useRef<HTMLButtonElement | null>(null);\n  const labelRef = useRef<HTMLSpanElement | null>(null);\n\n  const setup = (c: SceneSetupContext): State => {\n    /* Measured every time, never cached: setup re-runs on resize, and the whole point is that the\n       ring wraps the real button box wherever the hero copy above it happens to push it. The stage\n       carries no border, so its client rect and the canvas origin are the same point. */\n    const el = ctaRef.current;\n    const host = el ? el.closest('.pressure-button-stage') : null;\n    let bw = Math.max(96, Math.min(216, c.width - 56));\n    let bh = 52;\n    let bx = (c.width - bw) / 2;\n    let by = c.height * 0.6;\n    if (el && host) {\n      const box = el.getBoundingClientRect();\n      const frame = host.getBoundingClientRect();\n      if (box.width > 8 && box.height > 8) {\n        bw = box.width;\n        bh = box.height;\n        bx = box.left - frame.left;\n        by = box.top - frame.top;\n      }\n    }\n\n    const r = Math.max(1, Math.min(bw, bh) / 2);\n    const per = 2 * Math.max(bw - 2 * r, 0) + 2 * Math.PI * r;\n    const px = new Float64Array(NODES);\n    const py = new Float64Array(NODES);\n    const vx = new Float64Array(NODES);\n    const vy = new Float64Array(NODES);\n    const ax = new Float64Array(NODES);\n    const ay = new Float64Array(NODES);\n    const rx = new Float64Array(NODES);\n    const ry = new Float64Array(NODES);\n    const len = new Float64Array(NODES);\n    const out = new Float64Array(2);\n    for (let i = 0; i < NODES; i += 1) {\n      ringPoint((per * i) / NODES, bx, by, bw, bh, r, out);\n      rx[i] = out[0];\n      ry[i] = out[1];\n      px[i] = out[0];\n      py[i] = out[1];\n    }\n\n    /* A0 comes from the sampled polygon, not from the ideal stadium: the solver only ever sees the\n       shoelace area of these 44 points, and a mismatch of even a percent would leave the ring\n       breathing outward on the first frame with nothing touching it. */\n    let signed = 0;\n    let cx = 0;\n    let cy = 0;\n    for (let i = 0; i < NODES; i += 1) {\n      const j = i + 1 === NODES ? 0 : i + 1;\n      signed += rx[i] * ry[j] - rx[j] * ry[i];\n      len[i] = Math.hypot(rx[j] - rx[i], ry[j] - ry[i]);\n      cx += rx[i];\n      cy += ry[i];\n    }\n    const area0 = Math.max(Math.abs(signed * 0.5), 1);\n\n    const state: State = {\n      clock: 0,\n      carry: 0,\n      px, py, vx, vy, ax, ay, rx, ry, len,\n      gas: REST_PRESSURE * area0,\n      area0,\n      cap: by,\n      cx: cx / NODES,\n      cy: cy / NODES,\n      halfW: Math.max(bw, 1) / 2,\n      halfH: Math.max(bh, 1) / 2,\n      over: 0,\n    };\n\n    for (let i = 0; i < WARM_HELD; i += 1) {\n      advance(state, by + PRESS_DEPTH, false, 0, 0);\n    }\n    for (let i = 0; i < WARM_FREE; i += 1) {\n      advance(state, PLATE_LIFTED, false, 0, 0);\n    }\n    return state;\n  };\n\n  const draw = ({ context, width, height, state, pointer }: SceneDrawContext<State>) => {\n    if (!context) {\n      return;\n    }\n    const now = performance.now() / 1000;\n    const dt = state.clock === 0 ? 0 : Math.min(0.05, now - state.clock);\n    state.clock = now;\n    const plate = held ? state.cap + PRESS_DEPTH : PLATE_LIFTED;\n\n    if (reduced) {\n      /* No loop runs, so this frame has to be the whole truth: relax the real ring from rest until\n         the gas and the springs stop arguing, and paint that equilibrium. */\n      resetRing(state);\n      for (let i = 0; i < RELAX_STEPS; i += 1) {\n        advance(state, plate, false, 0, 0);\n      }\n    } else {\n      state.carry += dt;\n      let n = 0;\n      while (state.carry >= STEP && n < 8) {\n        advance(state, plate, pointer.down && pointer.inside, pointer.x, pointer.y);\n        state.carry -= STEP;\n        n += 1;\n      }\n      if (n === 8) {\n        state.carry = 0;\n      }\n    }\n\n    const { px, py } = state;\n    let cx = 0;\n    let cy = 0;\n    let minX = px[0];\n    let maxX = px[0];\n    let minY = py[0];\n    let maxY = py[0];\n    for (let i = 0; i < NODES; i += 1) {\n      cx += px[i];\n      cy += py[i];\n      minX = Math.min(minX, px[i]);\n      maxX = Math.max(maxX, px[i]);\n      minY = Math.min(minY, py[i]);\n      maxY = Math.max(maxY, py[i]);\n    }\n    cx /= NODES;\n    cy /= NODES;\n    /* A0 / A - 1, straight off the solver: the only thing driving the colour is the compression. */\n    const glow = clamp(state.over * 2.6, 0, 1);\n    const span = Math.max(maxY - minY, 1);\n\n    context.clearRect(0, 0, width, height);\n\n    /* Contact shadow. It tightens as the ring compresses because the cap is closer to the page —\n       same number driving it as the fill, so the two can never disagree. */\n    const shadowY = maxY + 9;\n    const shadowR = Math.max((maxX - minX) * 0.56, 1);\n    const shade = context.createRadialGradient(cx, shadowY, 0, cx, shadowY, shadowR);\n    shade.addColorStop(0, `rgba(1, 9, 7, ${0.5 + glow * 0.22})`);\n    shade.addColorStop(1, 'rgba(1, 9, 7, 0)');\n    context.save();\n    context.translate(cx, shadowY);\n    context.scale(1, Math.max(0.14, 0.3 - glow * 0.12));\n    context.translate(-cx, -shadowY);\n    context.beginPath();\n    context.arc(cx, shadowY, shadowR, 0, Math.PI * 2);\n    context.fillStyle = shade;\n    context.fill();\n    context.restore();\n\n    /* Quadratics through the edge midpoints: 44 nodes drawn as straight chords read as a faceted\n       gem when the waist bulges, and the facets flicker as nodes cross. */\n    context.beginPath();\n    context.moveTo((px[NODES - 1] + px[0]) * 0.5, (py[NODES - 1] + py[0]) * 0.5);\n    for (let i = 0; i < NODES; i += 1) {\n      const j = i + 1 === NODES ? 0 : i + 1;\n      context.quadraticCurveTo(px[i], py[i], (px[i] + px[j]) * 0.5, (py[i] + py[j]) * 0.5);\n    }\n    context.closePath();\n\n    const body = context.createLinearGradient(0, minY, 0, minY + span);\n    body.addColorStop(0, `rgba(172, 255, 228, ${0.3 + glow * 0.34})`);\n    body.addColorStop(1, `rgba(88, 220, 186, ${0.12 + glow * 0.26})`);\n    context.fillStyle = body;\n    context.fill();\n    context.lineWidth = 1.4;\n    context.strokeStyle = `rgba(198, 255, 236, ${0.5 + glow * 0.42})`;\n    context.stroke();\n\n    context.save();\n    context.clip();\n    const sheen = context.createLinearGradient(0, minY, 0, minY + span * 0.62);\n    sheen.addColorStop(0, 'rgba(255, 255, 255, 0.2)');\n    sheen.addColorStop(1, 'rgba(255, 255, 255, 0)');\n    context.fillStyle = sheen;\n    context.fillRect(minX - 2, minY - 2, maxX - minX + 4, span + 4);\n    context.restore();\n\n    /* The label rides the shape rather than the state: it takes the ring's own centroid and its own\n       extents, so it sinks and squashes with the cap and springs back on the overshoot. */\n    const label = labelRef.current;\n    if (label) {\n      const sx = clamp((maxX - minX) / (state.halfW * 2), 0.72, 1.24);\n      const sy = clamp(span / (state.halfH * 2), 0.72, 1.24);\n      const dx = (cx - state.cx).toFixed(2);\n      const dy = (cy - state.cy).toFixed(2);\n      label.style.transform = `translate(${dx}px, ${dy}px) scale(${sx.toFixed(3)}, ${sy.toFixed(3)})`;\n    }\n  };\n\n  const { stageRef, canvasRef, requestRender } = useCanvasScene<State>({ setup, draw });\n  useEffect(() => requestRender(), [held, reduced, requestRender]);\n\n  const release = () => setHeld(false);\n  /* Space and Enter are the press, not just the activation: a native button fires click for both,\n     but neither gives you a held state, and without one a keyboard user sees no squash at all. */\n  const press = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === ' ' || event.key === 'Enter') {\n      setHeld(true);\n    }\n  };\n\n  return (\n    <div className=\"pressure-button-stage\" data-compact={compact ? 'true' : undefined}>\n      <div ref={stageRef} className=\"pressure-button-surface\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" />\n      </div>\n      <div className=\"pressure-button-content\">\n        <p className=\"pressure-button-eyebrow\">Release 4.2</p>\n        <h3 className=\"pressure-button-title\">Push the build to the edge.</h3>\n        <p className=\"pressure-button-copy\">\n          Three regions, one press. Hold it and the gas inside the button pushes back — the cap\n          flattens, the waist bulges out, and the label goes down with it.\n        </p>\n        <button\n          ref={ctaRef}\n          type=\"button\"\n          className=\"pressure-button-cta\"\n          // The only focusable node in here, and in a card it leaves the tab order:\n          // the frame is aria-hidden, and a focusable node inside one is a trap with\n          // no name. Press, hold, drag and click all still land — only Tab is gone.\n          tabIndex={compact ? -1 : undefined}\n          onPointerDown={() => setHeld(true)}\n          onPointerUp={release}\n          onPointerLeave={release}\n          onPointerCancel={release}\n          onKeyDown={press}\n          onKeyUp={release}\n          onBlur={release}\n          onClick={() => setQueued((on) => !on)}\n        >\n          <span ref={labelRef} className=\"pressure-button-label\">\n            {queued ? 'Cancel deploy' : 'Deploy to edge'}\n          </span>\n        </button>\n        <p className=\"pressure-button-note\" role=\"status\" data-queued={queued ? 'true' : 'false'}>\n          {queued ? 'Queued · 3 regions · 12s' : 'No deploy queued.'}\n        </p>\n      </div>\n      <p className=\"pressure-button-hint\">press and hold · drag to dent</p>\n    </div>\n  );\n}\n\nexport default PressureButton;\n","type":"registry:ui"},{"path":"components/ui/pressure-button.css","target":"components/ui/pressure-button.css","content":".pressure-button-stage {\n  position: relative;\n  display: grid;\n  place-content: center;\n  width: 100%;\n  min-height: 20rem;\n  padding: 2.75rem 1.75rem;\n  overflow: hidden;\n  border-radius: 0.75rem;\n  /* An inset ring rather than a border. The canvas host below is `inset: 0`, which\n     resolves against this element's padding box, so a border here would slide the\n     solver's origin a pixel off the button its ring is wrapped around. */\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);\n  background: radial-gradient(120% 105% at 20% 0%, #0d1b18 0%, #070e0d 58%, #040807 100%);\n  color: #eafff8;\n  font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;\n}\n\n.pressure-button-surface {\n  position: absolute;\n  inset: 0;\n  touch-action: none;\n}\n\n.pressure-button-surface canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n/* Transparent to the pointer as a whole, so a drag anywhere across the hero still\n   dents the balloon. Only the call to action takes events back. */\n.pressure-button-content {\n  position: relative;\n  width: min(25rem, 100%);\n  pointer-events: none;\n}\n\n.pressure-button-eyebrow {\n  margin: 0 0 0.6875rem;\n  font: 500 0.6875rem/1 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.14em;\n  text-transform: uppercase;\n  color: rgba(151, 240, 214, 0.74);\n}\n\n.pressure-button-title {\n  margin: 0 0 0.625rem;\n  font-size: 1.75rem;\n  font-weight: 500;\n  line-height: 1.1;\n  letter-spacing: -0.025em;\n}\n\n.pressure-button-copy {\n  margin: 0 0 1.5rem;\n  max-width: 22rem;\n  font-size: 0.875rem;\n  line-height: 1.5;\n  color: rgba(234, 255, 248, 0.6);\n}\n\n/* No background, no border, no shadow. The pill you see is the softbody the canvas\n   paints; any CSS box here would sit on top of it as a ghost that refuses to\n   deform. The hit area stays the undeformed rectangle, which is the one part of a\n   button that should never move under the cursor. */\n.pressure-button-cta {\n  display: grid;\n  place-items: center;\n  width: min(13.5rem, 100%);\n  height: 3.25rem;\n  margin: 0;\n  padding: 0;\n  appearance: none;\n  border: 0;\n  border-radius: 999px;\n  background: none;\n  font: inherit;\n  font-size: 0.9375rem;\n  font-weight: 500;\n  letter-spacing: 0.01em;\n  color: #f2fff9;\n  cursor: pointer;\n  pointer-events: auto;\n  touch-action: none;\n}\n\n.pressure-button-label {\n  display: block;\n  transform-origin: 50% 50%;\n  text-shadow: 0 1px 12px rgba(4, 18, 14, 0.72);\n  will-change: transform;\n}\n\n.pressure-button-cta:focus-visible {\n  outline: 2px solid rgba(151, 240, 214, 0.82);\n  outline-offset: 5px;\n}\n\n.pressure-button-note {\n  margin: 0.9375rem 0 0;\n  min-height: 1.125rem;\n  font: 500 0.75rem/1.5 ui-monospace, 'SFMono-Regular', Menlo, monospace;\n  letter-spacing: 0.02em;\n  color: rgba(234, 255, 248, 0.44);\n  transition: color 200ms ease;\n}\n\n.pressure-button-note[data-queued='true'] {\n  color: rgba(151, 240, 214, 0.9);\n}\n\n.pressure-button-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(234, 255, 248, 0.26);\n  pointer-events: none;\n}\n\n/*\n * With the loop stopped the same solver is still the source of the picture: it is\n * relaxed to the equilibrium of whichever state the button is in — free, or held\n * down against the plate — and that one settled frame is painted. The label still\n * rides the squashed cap, because the shape is real. What is switched off is the\n * rebound in between, the pointer dent, and the colour fade on the status line.\n */\n@media (prefers-reduced-motion: reduce) {\n  .pressure-button-note {\n    transition: none;\n  }\n\n  .pressure-button-label {\n    will-change: auto;\n  }\n}\n\n/*\n * The card variant: the same button authored for the 298x240 catalogue frame rather\n * than scaled into it. The section copy goes, the pill is centred as the whole\n * subject with the solver's ring wrapped around it, and one line of type sits along\n * the bottom edge. Fixed rem sizes only below — this frame is 298px wide and the\n * viewport is not.\n */\n.pressure-button-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /* Every layer below is absolute, so this only states the intent: the whole box\n     belongs to the mechanism. The card frame rounds and clips already. */\n  padding: 0;\n  border-radius: 0;\n}\n\n/*\n * `pan-y`, not `none`. This surface is the full 298x240, and one that swallows every\n * vertical touch traps the page inside a scrolling grid of cards — the worse failure\n * by far. A horizontal drag still reaches the balloon and dents it, and a stationary\n * press-and-hold is never a pan, so both gestures the button is about survive on a\n * phone; only a vertical drag is handed back to the document.\n */\n.pressure-button-stage[data-compact='true'] .pressure-button-surface {\n  touch-action: pan-y;\n}\n\n/*\n * The copy layer stops being a column of text with a button at the end of it and\n * becomes the pill's centring layer. The bottom inset is the contact shadow: it\n * hangs below the cap, so the box the pill is centred in stops short of the type\n * strip and the pill *plus* its shadow reads as centred rather than the pill alone.\n * `pointer-events: none` is inherited from the base rule and left alone, so a drag\n * anywhere across the card reaches the canvas; the button takes its own back.\n */\n.pressure-button-stage[data-compact='true'] .pressure-button-content {\n  position: absolute;\n  inset: 0 0 1.25rem;\n  width: auto;\n  display: grid;\n  place-content: center;\n}\n\n/* Eyebrow, headline and paragraph are the section's, and the card's own title link\n   below the frame already names the item. The status line goes with them: the label\n   on the pill turns over on the same click, so it would be the same news twice. */\n.pressure-button-stage[data-compact='true'] .pressure-button-eyebrow,\n.pressure-button-stage[data-compact='true'] .pressure-button-title,\n.pressure-button-stage[data-compact='true'] .pressure-button-copy,\n.pressure-button-stage[data-compact='true'] .pressure-button-note {\n  display: none;\n}\n\n/*\n * 216 x 52 of the 298 x 240, and a flat length rather than the section's `min()` so\n * the ring `setup()` measures is the same box on every card. That leaves 41px at\n * each side for the waist to bulge into and room under the cap for the contact\n * shadow, and the plate still presses its authored 9px — a shorter button would only\n * make the squash deeper, not the pill bigger.\n */\n.pressure-button-stage[data-compact='true'] .pressure-button-cta {\n  width: 13.5rem;\n  /* The same trade as the surface: 216x52 is a good part of the card, and a thumb\n     that lands on the button should still be able to scroll past it. */\n  touch-action: pan-y;\n}\n\n/*\n * The one line of type that stays, off the balloon's back and onto the bottom edge.\n * It is the hint and not the headline because nothing about a pill tells you it can\n * be held down or dented from the side, and the fixed 0.6875rem it was already set\n * in is the right size for this box. Lifted out of the hero's whisper — as the only\n * text in the card it has to be readable — and still deaf to the pointer, so a drag\n * across it takes hold of the balloon.\n */\n.pressure-button-stage[data-compact='true'] .pressure-button-hint {\n  inset: auto 0 0 0;\n  padding: 0.75rem;\n  text-align: center;\n  white-space: nowrap;\n  color: rgba(234, 255, 248, 0.48);\n}\n\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":["micro","springs"],"docs":"https://ui.artbloom.tech/artbloom/animations/pressure-button"}}