{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wax-seal",
  "title": "Wax Seal",
  "description": "A press-and-hold button with a wax seal, in two variants. Seal pools molten wax from the centre while held and presses a stamp into it, leaving an embossed impression. Open starts stamped: holding strains the wax until a crack runs across it and it snaps in two. Letting go early undoes the hold.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/wax-seal/wax-seal.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type WaxSealColor = \"oxblood\" | \"black\" | \"gold\" | \"teal\"\n\n/** Base wax colours. Highlights and shadows are mixed from these. */\nexport const waxSealColors: Record<WaxSealColor, string> = {\n  oxblood: \"#7a2a23\",\n  black: \"#2a2522\",\n  gold: \"#b8893a\",\n  teal: \"#387478\",\n}\n\n/** Seal radius and resting dot radius, in viewBox units (the viewBox is 100 wide). */\nconst R = 33\nconst DOT = 4.5\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\"\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0\n    let t = a\n    t = Math.imul(t ^ (t >>> 15), t | 1)\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n  }\n}\n\n/** Mixes a #rrggbb colour toward white (amount > 0) or black (amount < 0). */\nfunction shade(hex: string, amount: number) {\n  const clean = hex.trim().replace(/^#/, \"\")\n  const full =\n    clean.length === 3\n      ? clean\n          .split(\"\")\n          .map((c) => c + c)\n          .join(\"\")\n      : clean.slice(0, 6)\n  const n = Number.parseInt(full, 16)\n  if (full.length !== 6 || Number.isNaN(n)) return hex\n  const target = amount > 0 ? 255 : 0\n  const t = Math.abs(amount)\n  const ch = (v: number) =>\n    Math.round(v + (target - v) * t)\n      .toString(16)\n      .padStart(2, \"0\")\n  return `#${ch((n >> 16) & 255)}${ch((n >> 8) & 255)}${ch(n & 255)}`\n}\n\ntype Blob = {\n  waves: { k: number; amp: number; phase: number; drift: number }[]\n  lumps: { angle: number; amp: number; width: number }[]\n}\n\nfunction makeBlob(seed: number): Blob {\n  const rand = mulberry32(seed)\n  return {\n    waves: [2, 3, 4, 5, 7].map((k) => ({\n      k,\n      amp: 0.008 + rand() * 0.014,\n      phase: rand() * Math.PI * 2,\n      drift: (rand() - 0.5) * 3,\n    })),\n    lumps: Array.from({ length: 5 }, () => ({\n      angle: rand() * Math.PI * 2,\n      amp: 0.04 + rand() * 0.08,\n      width: 0.16 + rand() * 0.2,\n    })),\n  }\n}\n\n/**\n * The wax outline as a smooth closed path. The low waves drift with `p`, so lobes lead and lag while\n * the pool spreads instead of a fixed shape scaling up. `spread` adds the overflow squeezed out by the stamp.\n */\nfunction blobPath(blob: Blob, radius: number, p: number, spread: number) {\n  const n = 36\n  const pts: [number, number][] = []\n  for (let i = 0; i < n; i++) {\n    const a = (i / n) * Math.PI * 2\n    let m = 1 + 0.06 * spread\n    for (const w of blob.waves) m += w.amp * Math.sin(w.k * a + w.phase + w.drift * p)\n    for (const l of blob.lumps) {\n      const d = Math.atan2(Math.sin(a - l.angle), Math.cos(a - l.angle)) / l.width\n      m += spread * l.amp * Math.exp(-d * d)\n    }\n    pts.push([50 + Math.cos(a) * radius * m, 50 + Math.sin(a) * radius * m])\n  }\n  const f = (v: number) => v.toFixed(2)\n  let d = `M${f(pts[0][0])} ${f(pts[0][1])}`\n  // Closed Catmull-Rom through the points, written as cubic Beziers.\n  for (let i = 0; i < n; i++) {\n    const p0 = pts[(i - 1 + n) % n]\n    const p1 = pts[i]\n    const p2 = pts[(i + 1) % n]\n    const p3 = pts[(i + 2) % n]\n    d += `C${f(p1[0] + (p2[0] - p0[0]) / 6)} ${f(p1[1] + (p2[1] - p0[1]) / 6)} ${f(p2[0] - (p3[0] - p1[0]) / 6)} ${f(p2[1] - (p3[1] - p1[1]) / 6)} ${f(p2[0])} ${f(p2[1])}`\n  }\n  return d + \"Z\"\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  mq.addEventListener(\"change\", cb)\n  return () => mq.removeEventListener(\"change\", cb)\n}\n\nfunction useReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false\n  )\n}\n\n/** The beaded ring stamped around the impression, at fixed positions so server and client agree. */\nconst BEADS = Array.from({ length: 26 }, (_, i) => {\n  const a = (i / 26) * Math.PI * 2\n  return [Number((50 + Math.cos(a) * 27.6).toFixed(2)), Number((50 + Math.sin(a) * 27.6).toFixed(2))] as const\n})\n\ntype Pt = [number, number]\n\n/**\n * A fracture across the seal: two jagged arms running out from just off centre, where the thumb presses,\n * plus a couple of short side cracks. Points are in viewBox units, which match percentages of the box.\n */\nfunction makeCrack(seed: number) {\n  const rand = mulberry32(seed * 31 + 5)\n  const c: Pt = [50 + (rand() - 0.5) * 4, 50 + (rand() - 0.5) * 6]\n  const walk = (from: Pt, dir: number, length: number, steps: number) => {\n    const pts: Pt[] = [from]\n    let [x, y] = from\n    for (let i = 0; i < steps; i++) {\n      const a = dir + (rand() - 0.5) * 0.9\n      x += (Math.cos(a) * length) / steps\n      y += (Math.sin(a) * length) / steps\n      pts.push([Number(x.toFixed(2)), Number(y.toFixed(2))])\n    }\n    return pts\n  }\n  const tilt = (rand() - 0.5) * 0.5\n  const left = walk(c, Math.PI + tilt, 54, 7)\n  const right = walk(c, tilt, 54, 7)\n  const side = (arm: Pt[], dir: number) =>\n    walk(arm[3], dir + (rand() > 0.5 ? 1 : -1) * (0.7 + rand() * 0.4), 9 + rand() * 5, 3)\n  const branches = [side(left, Math.PI + tilt), side(right, tilt)]\n  const line = [...left].reverse().concat(right.slice(1))\n  const [l, r] = [line[0], line[line.length - 1]]\n  const polygon = (pts: Pt[]) => `polygon(${pts.map(([x, y]) => `${x}% ${y}%`).join(\", \")})`\n  const toPath = (pts: Pt[]) => pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x} ${y}`).join(\"\")\n  return {\n    arms: [toPath(left), toPath(right)],\n    branches: branches.map(toPath),\n    top: polygon([[-10, l[1]], ...line, [110, r[1]], [110, -10], [-10, -10]]),\n    bottom: polygon([[-10, l[1]], ...line, [110, r[1]], [110, 110], [-10, 110]]),\n    // Each half hinges on the far end of the crack, so the break opens like a pair of scissors.\n    hinges: [`${r[0]}% ${r[1]}%`, `${l[0]}% ${l[1]}%`],\n    crumbs: [line[3], line[5], line[7], line[9], line[11]].map(([x, y]) => ({\n      x,\n      y,\n      r: 1.1 + rand() * 1.1,\n      dx: (rand() - 0.5) * 16,\n      dy: 22 + rand() * 22,\n      turn: (rand() - 0.5) * 540,\n      delay: rand() * 60,\n    })),\n  }\n}\n\n/** Gradients and blur shared by every copy of the seal art in one instance, plus the wax outline as a clip. */\nfunction SealDefs({ id, base, clip }: { id: string; base: string; clip?: string }) {\n  return (\n    <svg aria-hidden className=\"pointer-events-none absolute size-0 overflow-hidden\" focusable=\"false\">\n      <defs>\n        <radialGradient id={`${id}-body`} cx=\"0.36\" cy=\"0.3\" r=\"0.78\">\n          <stop offset=\"0\" stopColor={shade(base, 0.22)} />\n          <stop offset=\"0.5\" stopColor={base} />\n          <stop offset=\"1\" stopColor={shade(base, -0.45)} />\n        </radialGradient>\n        <linearGradient id={`${id}-face`} x1=\"0.15\" y1=\"0.1\" x2=\"0.85\" y2=\"0.95\">\n          <stop offset=\"0\" stopColor={shade(base, -0.38)} />\n          <stop offset=\"0.55\" stopColor={shade(base, -0.12)} />\n          <stop offset=\"1\" stopColor={shade(base, 0.06)} />\n        </linearGradient>\n        <radialGradient id={`${id}-bead`} cx=\"0.35\" cy=\"0.3\" r=\"0.8\">\n          <stop offset=\"0\" stopColor={shade(base, 0.35)} />\n          <stop offset=\"0.6\" stopColor={base} />\n          <stop offset=\"1\" stopColor={shade(base, -0.4)} />\n        </radialGradient>\n        <filter id={`${id}-soft`} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n          <feGaussianBlur stdDeviation=\"1.4\" />\n        </filter>\n        {clip && (\n          <clipPath id={`${id}-wax`}>\n            <path d={clip} />\n          </clipPath>\n        )}\n      </defs>\n    </svg>\n  )\n}\n\ninterface SealFaceProps {\n  id: string\n  base: string\n  size: number\n  /** Wax outline and gloss transform for the first paint. */\n  d: string\n  gloss: string\n  impression: React.ReactNode\n  /** Dashed ring marking where the wax will land. */\n  outline?: boolean\n  faceClassName?: string\n  impressionClassName?: string\n  waxRef?: React.Ref<SVGGElement>\n  glossRef?: React.Ref<SVGGElement>\n  pathRef?: (index: number) => (el: SVGPathElement | null) => void\n  /** Extra SVG drawn over the seal, such as cracks. */\n  children?: React.ReactNode\n}\n\n/** The wax, the stamped face and the impression. Fills its positioned parent. */\nfunction SealFace({\n  id,\n  base,\n  size,\n  d,\n  gloss,\n  impression,\n  outline,\n  faceClassName,\n  impressionClassName,\n  waxRef,\n  glossRef,\n  pathRef,\n  children,\n}: SealFaceProps) {\n  const relief = Math.max(1, size * 0.012)\n  return (\n    <>\n      <svg viewBox=\"0 0 100 100\" className=\"absolute inset-0 size-full overflow-visible\" focusable=\"false\">\n        {outline && (\n          <circle\n            cx=\"50\"\n            cy=\"50\"\n            r={R}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"0.8\"\n            strokeDasharray=\"1.5 3\"\n            className=\"opacity-35 transition-opacity duration-200 group-hover/wax-seal:opacity-60 group-data-[state=sealed]/wax-seal:opacity-0\"\n          />\n        )}\n\n        <g ref={waxRef}>\n          <path\n            ref={pathRef?.(0)}\n            d={d}\n            fill=\"#000\"\n            opacity=\"0.3\"\n            transform=\"translate(0.6 1.8)\"\n            filter={`url(#${id}-soft)`}\n          />\n          <path ref={pathRef?.(1)} d={d} fill={`url(#${id}-body)`} />\n          <path\n            ref={pathRef?.(2)}\n            d={d}\n            fill=\"none\"\n            stroke={shade(base, -0.5)}\n            strokeOpacity=\"0.45\"\n            strokeWidth=\"0.6\"\n          />\n          <g ref={glossRef} transform={gloss}>\n            <path\n              d=\"M22 42.5A29 29 0 0 1 42.5 22\"\n              fill=\"none\"\n              stroke=\"#fff\"\n              strokeOpacity=\"0.38\"\n              strokeWidth=\"2.6\"\n              strokeLinecap=\"round\"\n              filter={`url(#${id}-soft)`}\n            />\n            <circle cx=\"29.5\" cy=\"33\" r=\"1.3\" fill=\"#fff\" fillOpacity=\"0.55\" />\n          </g>\n        </g>\n\n        <g className={faceClassName}>\n          <circle cx=\"50\" cy=\"50\" r=\"25\" fill=\"none\" stroke={shade(base, -0.5)} strokeOpacity=\"0.35\" strokeWidth=\"0.8\" />\n          {BEADS.map(([x, y], i) => (\n            <circle key={i} cx={x} cy={y} r=\"1.35\" fill={`url(#${id}-bead)`} />\n          ))}\n          <circle cx=\"50\" cy=\"50\" r=\"22\" fill={`url(#${id}-face)`} />\n          {/* The stamp wall: shadowed where light can't reach, lit on the far side. */}\n          <circle cx=\"50\" cy=\"50\" r=\"22\" fill=\"none\" stroke={shade(base, -0.6)} strokeOpacity=\"0.55\" strokeWidth=\"1.2\" />\n          <path\n            d=\"M65.6 65.6A22 22 0 0 1 34.4 65.6\"\n            fill=\"none\"\n            stroke={shade(base, 0.4)}\n            strokeOpacity=\"0.45\"\n            strokeWidth=\"0.7\"\n            transform=\"translate(0 0.9)\"\n          />\n        </g>\n\n        {children}\n      </svg>\n\n      <span\n        className={cn(\"absolute inset-0 flex items-center justify-center leading-none\", impressionClassName)}\n        style={{\n          color: shade(base, -0.32),\n          fontSize: size * 0.3,\n          transitionTimingFunction: EASE_OUT,\n          // Debossed: the top edge of each stroke falls in shadow, the bottom edge catches light.\n          filter: `drop-shadow(0 ${-relief}px 0 ${shade(base, -0.62)}) drop-shadow(0 ${relief}px 0 ${shade(base, 0.3)})`,\n        }}\n      >\n        {impression}\n      </span>\n    </>\n  )\n}\n\ntype Phase = \"idle\" | \"holding\" | \"releasing\" | \"pressing\" | \"sealed\"\n\nexport interface WaxSealProps extends Omit<React.ComponentProps<\"button\">, \"children\" | \"color\"> {\n  /**\n   * \"seal\" starts bare: holding pools wax and stamps it. \"open\" starts stamped: holding strains the wax\n   * until it cracks and snaps in two. Default \"seal\".\n   */\n  variant?: \"seal\" | \"open\"\n  /** Called once when a hold completes and the seal is stamped. */\n  onSeal?: () => void\n  /** How long the press must be held, in ms. Default 1100. */\n  holdDuration?: number\n  /** A named wax colour or any #rrggbb hex. Default \"oxblood\". */\n  color?: WaxSealColor | (string & {})\n  /** Controlled sealed state. Pair with `onSealedChange`. */\n  sealed?: boolean\n  /** Initial sealed state when uncontrolled. Default false. */\n  defaultSealed?: boolean\n  /** Called with `true` when a hold completes, and with `false` when the seal is broken. */\n  onSealedChange?: (sealed: boolean) => void\n  /** Let a click on a sealed button break the seal. Default false. */\n  resetOnClick?: boolean\n  /** The impression the stamp leaves: a letter, monogram or icon. Text is sized at about a third of `size`. */\n  children?: React.ReactNode\n  /** Called once when a hold completes and the seal breaks. \"open\" only. */\n  onOpen?: () => void\n  /** Visible label before the hold completes. Default \"Hold to seal\", or \"Hold to open\". */\n  label?: React.ReactNode\n  /** Visible label once sealed. Also announced to screen readers. Default \"Sealed\". */\n  sealedLabel?: string\n  /** Visible label once the seal is broken. Also announced to screen readers. Default \"Opened\". \"open\" only. */\n  openedLabel?: string\n  /** Read by screen readers. Default \"Press and hold to confirm\", or \"Press and hold to break the seal\". */\n  hint?: string\n  /** Diameter of the seal area in px. Default 88. */\n  size?: number\n  /** Seed for the shape of the wax. Default 7. */\n  seed?: number\n  /** Buzz the device on seal or break where the Vibration API exists. Default true. */\n  haptics?: boolean\n}\n\n/**\n * A press-and-hold confirm button with a wax seal, in two variants. \"seal\" pools molten wax from the\n * centre and stamps an embossed impression into it; \"open\" starts stamped and cracks the wax in two.\n * Releasing early undoes the hold either way.\n */\nexport function WaxSeal({\n  variant = \"seal\",\n  onOpen,\n  openedLabel,\n  onSeal,\n  resetOnClick,\n  sealedLabel,\n  ...props\n}: WaxSealProps) {\n  if (variant === \"open\") return <OpenSeal onOpen={onOpen} openedLabel={openedLabel} {...props} />\n  return <StampSeal onSeal={onSeal} resetOnClick={resetOnClick} sealedLabel={sealedLabel} {...props} />\n}\n\ntype SharedProps = Omit<WaxSealProps, \"variant\" | \"onOpen\" | \"openedLabel\" | \"onSeal\" | \"resetOnClick\" | \"sealedLabel\">\n\nfunction StampSeal({\n  onSeal,\n  holdDuration = 1100,\n  color = \"oxblood\",\n  sealed: sealedProp,\n  defaultSealed = false,\n  onSealedChange,\n  resetOnClick = false,\n  children,\n  label = \"Hold to seal\",\n  sealedLabel = \"Sealed\",\n  hint = \"Press and hold to confirm\",\n  size = 88,\n  seed = 7,\n  haptics = true,\n  disabled,\n  className,\n  style,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onPointerCancel,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  onClick,\n  onContextMenu,\n  ...props\n}: SharedProps & Pick<WaxSealProps, \"onSeal\" | \"resetOnClick\" | \"sealedLabel\">) {\n  const id = React.useId().replace(/[^\\w-]/g, \"\")\n  const reduced = useReducedMotion()\n  const [innerSealed, setInnerSealed] = React.useState(defaultSealed)\n  const sealed = sealedProp ?? innerSealed\n  const [holding, setHolding] = React.useState(false)\n\n  const base = color in waxSealColors ? waxSealColors[color as WaxSealColor] : color\n  const blob = React.useMemo(() => makeBlob(seed), [seed])\n\n  const stampRef = React.useRef<HTMLSpanElement>(null)\n  const waxRef = React.useRef<SVGGElement>(null)\n  const glossRef = React.useRef<SVGGElement>(null)\n  const pathRefs = React.useRef<(SVGPathElement | null)[]>([])\n  const pressShadowRef = React.useRef<SVGCircleElement>(null)\n  const sim = React.useRef({\n    phase: (sealed ? \"sealed\" : \"idle\") as Phase,\n    p: sealed ? 1 : 0,\n    spread: sealed ? 1 : 0,\n    pressT: 0,\n  })\n  const raf = React.useRef(0)\n  const last = React.useRef(0)\n  const pressBeganSealed = React.useRef(false)\n\n  // Handlers run outside render; they read the latest props from here.\n  const setSealed = React.useCallback(\n    (next: boolean) => {\n      if (sealedProp === undefined) setInnerSealed(next)\n      onSealedChange?.(next)\n    },\n    [sealedProp, onSealedChange]\n  )\n  const latest = React.useRef({ blob, reduced, holdDuration, haptics, onSeal, setSealed })\n  React.useEffect(() => {\n    latest.current = { blob, reduced, holdDuration, haptics, onSeal, setSealed }\n  })\n\n  // The first frame comes from render so the server markup already shows the right state; after that\n  // the loop owns these attributes and React never touches them again.\n  const [initial] = React.useState(() => ({\n    d: blobPath(blob, sealed ? R : DOT, sealed ? 1 : 0, sealed ? 1 : 0),\n    gloss: `translate(50 50) scale(${sealed ? 1.06 : DOT / R}) translate(-50 -50)`,\n  }))\n\n  const draw = React.useCallback(() => {\n    const s = sim.current\n    const { blob, reduced } = latest.current\n    // A pool fed at a steady rate grows in area, not radius, so the edge races out then slows.\n    const radius = reduced ? (s.p > 0 ? R : DOT) : DOT + (R - DOT) * Math.sqrt(s.p)\n    const d = blobPath(blob, radius, s.p, s.spread)\n    for (const path of pathRefs.current) path?.setAttribute(\"d\", d)\n    glossRef.current?.setAttribute(\n      \"transform\",\n      `translate(50 50) scale(${((radius / R) * (1 + 0.06 * s.spread)).toFixed(4)}) translate(-50 -50)`\n    )\n    // Reduced motion: the seal is drawn full size and fills in by opacity instead of spreading.\n    if (waxRef.current) waxRef.current.style.opacity = reduced && s.p > 0 ? String(0.2 + 0.8 * s.p) : \"1\"\n  }, [])\n\n  const stamp = React.useCallback(() => {\n    const s = sim.current\n    const { reduced, haptics, onSeal, setSealed } = latest.current\n    s.phase = reduced ? \"sealed\" : \"pressing\"\n    s.p = 1\n    s.pressT = 0\n    if (reduced) s.spread = 1\n    setHolding(false)\n    setSealed(true)\n    onSeal?.()\n    if (haptics) {\n      try {\n        navigator.vibrate?.([14, 50, 22])\n      } catch {}\n    }\n    if (reduced) return\n    // Squash as the stamp lands, a small rebound as it lifts.\n    stampRef.current?.animate(\n      [\n        { transform: \"scale(1)\" },\n        { transform: \"scale(0.9, 0.87)\", offset: 0.32 },\n        { transform: \"scale(1.03)\", offset: 0.66 },\n        { transform: \"scale(1)\" },\n      ],\n      { duration: 460, easing: \"ease-out\" }\n    )\n    pressShadowRef.current?.animate([{ opacity: 0 }, { opacity: 0.4, offset: 0.3 }, { opacity: 0 }], {\n      duration: 420,\n      easing: \"ease-out\",\n    })\n  }, [])\n\n  const tick = React.useCallback(\n    (now: number) => {\n      const s = sim.current\n      // The first rAF timestamp can predate the performance.now() taken when the loop was kicked.\n      const dt = Math.min(48, Math.max(0, now - last.current))\n      last.current = now\n      if (s.phase === \"holding\") {\n        // Linear: this is progress, and progress shouldn't ease.\n        s.p = Math.min(1, s.p + dt / Math.max(100, latest.current.holdDuration))\n        if (s.p >= 1) stamp()\n      } else if (s.phase === \"releasing\") {\n        // Exponential decay is an ease-out that can be caught and reversed at any point.\n        const k = Math.exp(-dt / 70)\n        s.p *= k\n        s.spread *= k\n        if (s.p < 0.002) {\n          s.p = 0\n          s.spread = 0\n          s.phase = \"idle\"\n        }\n      } else if (s.phase === \"pressing\") {\n        s.pressT = Math.min(1, s.pressT + dt / 300)\n        s.spread = 1 - Math.pow(1 - s.pressT, 3)\n        if (s.pressT >= 1) s.phase = \"sealed\"\n      }\n      draw()\n      const busy = s.phase === \"holding\" || s.phase === \"releasing\" || s.phase === \"pressing\"\n      raf.current = busy ? requestAnimationFrame(tick) : 0\n    },\n    [draw, stamp]\n  )\n\n  const kick = React.useCallback(() => {\n    if (raf.current) return\n    last.current = performance.now()\n    raf.current = requestAnimationFrame(tick)\n  }, [tick])\n\n  React.useEffect(() => () => cancelAnimationFrame(raf.current), [])\n\n  // Redraw when the shape or motion preference changes while nothing is animating.\n  React.useEffect(() => {\n    if (!raf.current) draw()\n  }, [blob, reduced, draw])\n\n  // Follow outside changes to `sealed`: a reset retracts the wax, an outside seal snaps it in place.\n  React.useEffect(() => {\n    const s = sim.current\n    const down = s.phase === \"pressing\" || s.phase === \"sealed\"\n    if (sealed && !down) {\n      Object.assign(s, { phase: \"sealed\", p: 1, spread: 1 })\n      if (!raf.current) draw()\n    } else if (!sealed && down) {\n      s.phase = \"releasing\"\n      kick()\n    }\n  }, [sealed, draw, kick])\n\n  const start = () => {\n    const s = sim.current\n    if (disabled || s.phase === \"pressing\" || s.phase === \"sealed\") return\n    s.phase = \"holding\"\n    setHolding(true)\n    kick()\n  }\n\n  const release = () => {\n    const s = sim.current\n    if (s.phase !== \"holding\") return\n    s.phase = \"releasing\"\n    setHolding(false)\n    kick()\n  }\n\n  React.useEffect(() => {\n    if (disabled && sim.current.phase === \"holding\") {\n      sim.current.phase = \"releasing\"\n      setHolding(false)\n      kick()\n    }\n  }, [disabled, kick])\n\n  const isKey = (e: React.KeyboardEvent) => e.key === \" \" || e.key === \"Enter\"\n  const inert = sealed && !resetOnClick\n\n  return (\n    <>\n      <SealDefs id={id} base={base} />\n      <button\n        type=\"button\"\n        data-slot=\"wax-seal\"\n        data-state={sealed ? \"sealed\" : \"open\"}\n        data-holding={holding || undefined}\n        disabled={disabled}\n        aria-disabled={inert || undefined}\n        aria-describedby={`${id}-hint`}\n        className={cn(\n          \"group/wax-seal relative inline-flex touch-manipulation flex-col items-center gap-2 rounded-xl p-2 outline-none select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent]\",\n          \"focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n          inert ? \"cursor-default\" : \"cursor-pointer\",\n          className\n        )}\n        style={style}\n        onPointerDown={(e) => {\n          onPointerDown?.(e)\n          if (e.button !== 0 || !e.isPrimary) return\n          pressBeganSealed.current = sealed\n          if (!sealed) start()\n        }}\n        onPointerUp={(e) => {\n          onPointerUp?.(e)\n          // Breaking the seal needs its own press, so the press that sealed it can't also break it.\n          if (pressBeganSealed.current && sealed && resetOnClick) setSealed(false)\n          pressBeganSealed.current = false\n          release()\n        }}\n        onPointerLeave={(e) => {\n          onPointerLeave?.(e)\n          pressBeganSealed.current = false\n          release()\n        }}\n        onPointerCancel={(e) => {\n          // Fires when a touch turns into a scroll; that must never seal.\n          onPointerCancel?.(e)\n          pressBeganSealed.current = false\n          release()\n        }}\n        onKeyDown={(e) => {\n          onKeyDown?.(e)\n          if (!isKey(e)) return\n          // Stop the native click (and page scroll on Space); holding is handled here.\n          e.preventDefault()\n          if (e.repeat) return\n          if (sealed) {\n            if (resetOnClick) setSealed(false)\n          } else start()\n        }}\n        onKeyUp={(e) => {\n          onKeyUp?.(e)\n          if (!isKey(e)) return\n          e.preventDefault()\n          release()\n        }}\n        onBlur={(e) => {\n          onBlur?.(e)\n          release()\n        }}\n        onClick={(e) => {\n          onClick?.(e)\n          // detail 0 with keys suppressed above means assistive tech activated the button.\n          if (e.detail === 0 && sealed && resetOnClick) setSealed(false)\n        }}\n        onContextMenu={(e) => {\n          onContextMenu?.(e)\n          // A long press on touch opens the context menu otherwise.\n          e.preventDefault()\n        }}\n        {...props}\n      >\n        <span\n          aria-hidden\n          className=\"relative block transition-transform duration-150 ease-out motion-safe:group-data-[holding]/wax-seal:scale-[0.97]\"\n          style={{ width: size, height: size }}\n        >\n          <span ref={stampRef} className=\"absolute inset-0 block\">\n            <SealFace\n              id={id}\n              base={base}\n              size={size}\n              d={initial.d}\n              gloss={initial.gloss}\n              impression={children}\n              outline\n              waxRef={waxRef}\n              glossRef={glossRef}\n              pathRef={(i) => (el) => {\n                pathRefs.current[i] = el\n              }}\n              faceClassName=\"opacity-0 transition-opacity duration-150 group-data-[state=sealed]/wax-seal:opacity-100 group-data-[state=sealed]/wax-seal:delay-150 group-data-[state=sealed]/wax-seal:duration-300\"\n              impressionClassName=\"opacity-0 transition-[opacity,transform] duration-150 group-data-[state=sealed]/wax-seal:opacity-100 group-data-[state=sealed]/wax-seal:delay-150 group-data-[state=sealed]/wax-seal:duration-300 motion-safe:scale-105 motion-safe:group-data-[state=sealed]/wax-seal:scale-100\"\n            >\n              <circle ref={pressShadowRef} cx=\"50\" cy=\"50\" r=\"24\" fill=\"#000\" opacity=\"0\" />\n            </SealFace>\n          </span>\n        </span>\n\n        <span className=\"font-mono text-[10px] tracking-[0.18em] uppercase opacity-70\">\n          {sealed ? sealedLabel : label}\n        </span>\n      </button>\n      {/* Outside the button so neither ends up in its accessible name. */}\n      <span id={`${id}-hint`} className=\"sr-only\">\n        {sealed ? (resetOnClick ? \"Press to break the seal\" : \"\") : hint}\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {sealed ? sealedLabel : \"\"}\n      </span>\n    </>\n  )\n}\n\ntype OpenPhase = \"intact\" | \"straining\" | \"relaxing\" | \"broken\"\n\n/** The halves part with a quick snap that settles, like wax giving way under a thumb. */\nconst BREAK = [\n  { transform: \"translate(-1%, -2.5%) rotate(-8deg)\" },\n  { transform: \"translate(1.5%, 2.5%) rotate(7deg)\" },\n]\n\nfunction OpenSeal({\n  onOpen,\n  holdDuration = 1100,\n  color = \"oxblood\",\n  sealed: sealedProp,\n  defaultSealed = true,\n  onSealedChange,\n  children,\n  label = \"Hold to open\",\n  openedLabel = \"Opened\",\n  hint = \"Press and hold to break the seal\",\n  size = 88,\n  seed = 7,\n  haptics = true,\n  disabled,\n  className,\n  style,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onPointerCancel,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  onContextMenu,\n  ...props\n}: SharedProps & Pick<WaxSealProps, \"onOpen\" | \"openedLabel\">) {\n  const id = React.useId().replace(/[^\\w-]/g, \"\")\n  const reduced = useReducedMotion()\n  const [innerSealed, setInnerSealed] = React.useState(defaultSealed)\n  const sealed = sealedProp ?? innerSealed\n  const [holding, setHolding] = React.useState(false)\n\n  const base = color in waxSealColors ? waxSealColors[color as WaxSealColor] : color\n  const d = React.useMemo(() => blobPath(makeBlob(seed), R, 1, 1), [seed])\n  const crack = React.useMemo(() => makeCrack(seed), [seed])\n  const gloss = \"translate(50 50) scale(1.06) translate(-50 -50)\"\n\n  const wholeRef = React.useRef<HTMLSpanElement>(null)\n  const crackRef = React.useRef<SVGGElement>(null)\n  const crumbRefs = React.useRef<(SVGGElement | null)[]>([])\n  const sim = React.useRef({ phase: (sealed ? \"intact\" : \"broken\") as OpenPhase, p: 0 })\n  const raf = React.useRef(0)\n  const last = React.useRef(0)\n\n  const setSealed = React.useCallback(\n    (next: boolean) => {\n      if (sealedProp === undefined) setInnerSealed(next)\n      onSealedChange?.(next)\n    },\n    [sealedProp, onSealedChange]\n  )\n  const latest = React.useRef({ reduced, holdDuration, haptics, onOpen, setSealed })\n  React.useEffect(() => {\n    latest.current = { reduced, holdDuration, haptics, onOpen, setSealed }\n  })\n\n  const draw = React.useCallback((now: number) => {\n    const { phase, p } = sim.current\n    const { reduced } = latest.current\n    const group = crackRef.current\n    if (group) {\n      // The crack creeps while pressure builds, then races out at the end, as a fracture does.\n      const arms = reduced ? 1 : Math.pow(p, 1.4)\n      const side = reduced ? 1 : Math.min(1, Math.max(0, (p - 0.6) / 0.4))\n      group.style.opacity = reduced ? String(p) : \"1\"\n      group.querySelectorAll<SVGPathElement>(\"[data-kind=arm]\").forEach((el) => {\n        el.style.strokeDashoffset = String(1 - arms)\n      })\n      group.querySelectorAll<SVGPathElement>(\"[data-kind=branch]\").forEach((el) => {\n        el.style.strokeDashoffset = String(1 - side)\n      })\n    }\n    // A strained seal trembles, more as the wax nears its limit.\n    if (wholeRef.current) {\n      const shake = !reduced && phase === \"straining\" ? (Math.sin(now / 37) * 0.7 + Math.sin(now / 23) * 0.5) * p * p : 0\n      wholeRef.current.style.transform = shake ? `rotate(${shake.toFixed(3)}deg)` : \"\"\n    }\n  }, [])\n\n  const snap = React.useCallback(() => {\n    const s = sim.current\n    const { reduced, haptics, onOpen, setSealed } = latest.current\n    s.phase = \"broken\"\n    s.p = 1\n    setHolding(false)\n    setSealed(false)\n    onOpen?.()\n    if (haptics) {\n      try {\n        navigator.vibrate?.([10, 30, 26])\n      } catch {}\n    }\n    if (reduced) return\n    // Crumbs of wax shaken loose by the break: they fall, so they speed up.\n    crack.crumbs.forEach((c, i) => {\n      crumbRefs.current[i]?.animate(\n        [\n          { opacity: 1, transform: \"translate(0px, 0px) rotate(0deg)\" },\n          { opacity: 1, offset: 0.7 },\n          { opacity: 0, transform: `translate(${c.dx}px, ${c.dy}px) rotate(${c.turn}deg)` },\n        ],\n        { duration: 620, delay: c.delay, easing: \"cubic-bezier(0.55, 0, 1, 0.45)\" }\n      )\n    })\n  }, [crack])\n\n  const tick = React.useCallback(\n    (now: number) => {\n      const s = sim.current\n      const dt = Math.min(48, Math.max(0, now - last.current))\n      last.current = now\n      if (s.phase === \"straining\") {\n        s.p = Math.min(1, s.p + dt / Math.max(100, latest.current.holdDuration))\n        if (s.p >= 1) snap()\n      } else if (s.phase === \"relaxing\") {\n        s.p *= Math.exp(-dt / 80)\n        if (s.p < 0.002) {\n          s.p = 0\n          s.phase = \"intact\"\n        }\n      }\n      draw(now)\n      raf.current = s.phase === \"straining\" || s.phase === \"relaxing\" ? requestAnimationFrame(tick) : 0\n    },\n    [draw, snap]\n  )\n\n  const kick = React.useCallback(() => {\n    if (raf.current) return\n    last.current = performance.now()\n    raf.current = requestAnimationFrame(tick)\n  }, [tick])\n\n  React.useEffect(() => () => cancelAnimationFrame(raf.current), [])\n\n  React.useEffect(() => {\n    if (!raf.current) draw(performance.now())\n  }, [crack, reduced, draw])\n\n  // Follow outside changes to `sealed`: a reset mends the wax, an outside break snaps it quietly.\n  React.useEffect(() => {\n    const s = sim.current\n    if (sealed && s.phase === \"broken\") {\n      Object.assign(s, { phase: \"intact\", p: 0 })\n      if (!raf.current) draw(performance.now())\n    } else if (!sealed && s.phase !== \"broken\") {\n      cancelAnimationFrame(raf.current)\n      raf.current = 0\n      Object.assign(s, { phase: \"broken\", p: 1 })\n      setHolding(false)\n      draw(performance.now())\n    }\n  }, [sealed, draw])\n\n  const start = () => {\n    const s = sim.current\n    if (disabled || s.phase === \"broken\") return\n    s.phase = \"straining\"\n    setHolding(true)\n    kick()\n  }\n\n  const release = () => {\n    const s = sim.current\n    if (s.phase !== \"straining\") return\n    s.phase = \"relaxing\"\n    setHolding(false)\n    kick()\n  }\n\n  React.useEffect(() => {\n    if (disabled && sim.current.phase === \"straining\") {\n      sim.current.phase = \"relaxing\"\n      setHolding(false)\n      kick()\n    }\n  }, [disabled, kick])\n\n  const isKey = (e: React.KeyboardEvent) => e.key === \" \" || e.key === \"Enter\"\n  const crackStroke = shade(base, -0.68)\n  const lipStroke = shade(base, 0.32)\n  const halves = [crack.top, crack.bottom]\n\n  const face = (revealed: boolean) => (\n    <g\n      ref={revealed ? undefined : crackRef}\n      clipPath={`url(#${id}-wax)`}\n      fill=\"none\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      {/* A pale lip under each dark crack: the broken edge catching light. */}\n      {[...crack.arms.map((a) => [a, \"arm\"]), ...crack.branches.map((b) => [b, \"branch\"])].map(([path, kind], i) => (\n        <g key={i}>\n          <path\n            d={path}\n            pathLength={1}\n            strokeDasharray=\"1 1\"\n            strokeDashoffset={revealed ? 0 : 1}\n            stroke={lipStroke}\n            strokeOpacity=\"0.7\"\n            strokeWidth={kind === \"arm\" ? 0.9 : 0.55}\n            transform=\"translate(0 0.7)\"\n            data-kind={kind}\n          />\n          <path\n            d={path}\n            pathLength={1}\n            strokeDasharray=\"1 1\"\n            strokeDashoffset={revealed ? 0 : 1}\n            stroke={crackStroke}\n            strokeWidth={kind === \"arm\" ? 1.5 : 0.8}\n            data-kind={kind}\n          />\n        </g>\n      ))}\n    </g>\n  )\n\n  return (\n    <>\n      <SealDefs id={id} base={base} clip={d} />\n      <button\n        type=\"button\"\n        data-slot=\"wax-seal\"\n        data-variant=\"open\"\n        data-state={sealed ? \"sealed\" : \"open\"}\n        data-holding={holding || undefined}\n        disabled={disabled}\n        aria-disabled={!sealed || undefined}\n        aria-describedby={`${id}-hint`}\n        className={cn(\n          \"group/wax-seal relative inline-flex touch-manipulation flex-col items-center gap-2 rounded-xl p-2 outline-none select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent]\",\n          \"focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n          sealed ? \"cursor-pointer\" : \"cursor-default\",\n          className\n        )}\n        style={style}\n        onPointerDown={(e) => {\n          onPointerDown?.(e)\n          if (e.button !== 0 || !e.isPrimary) return\n          start()\n        }}\n        onPointerUp={(e) => {\n          onPointerUp?.(e)\n          release()\n        }}\n        onPointerLeave={(e) => {\n          onPointerLeave?.(e)\n          release()\n        }}\n        onPointerCancel={(e) => {\n          // Fires when a touch turns into a scroll; that must never break the seal.\n          onPointerCancel?.(e)\n          release()\n        }}\n        onKeyDown={(e) => {\n          onKeyDown?.(e)\n          if (!isKey(e)) return\n          e.preventDefault()\n          if (!e.repeat) start()\n        }}\n        onKeyUp={(e) => {\n          onKeyUp?.(e)\n          if (!isKey(e)) return\n          e.preventDefault()\n          release()\n        }}\n        onBlur={(e) => {\n          onBlur?.(e)\n          release()\n        }}\n        onContextMenu={(e) => {\n          onContextMenu?.(e)\n          e.preventDefault()\n        }}\n        {...props}\n      >\n        <span\n          aria-hidden\n          className=\"relative block transition-transform duration-150 ease-out motion-safe:group-data-[holding]/wax-seal:scale-[0.97]\"\n          style={{ width: size, height: size }}\n        >\n          {/* Whole while intact. Swapping to two clipped halves only at the break avoids a hairline seam. */}\n          <span ref={wholeRef} className={cn(\"absolute inset-0 block\", !sealed && \"invisible\")}>\n            <SealFace id={id} base={base} size={size} d={d} gloss={gloss} impression={children}>\n              {face(false)}\n            </SealFace>\n          </span>\n          {halves.map((clip, i) => (\n            <span\n              key={i}\n              className={cn(\"absolute inset-0 block\", sealed && \"invisible\")}\n              style={{\n                clipPath: clip,\n                transformOrigin: crack.hinges[i],\n                transform: sealed ? \"none\" : BREAK[i].transform,\n                transition: reduced || sealed ? \"none\" : `transform 420ms ${EASE_OUT}`,\n              }}\n            >\n              <SealFace id={id} base={base} size={size} d={d} gloss={gloss} impression={children}>\n                {face(true)}\n              </SealFace>\n            </span>\n          ))}\n          <svg viewBox=\"0 0 100 100\" className=\"absolute inset-0 size-full overflow-visible\" focusable=\"false\">\n            {crack.crumbs.map((c, i) => (\n              <g\n                key={i}\n                ref={(el) => {\n                  crumbRefs.current[i] = el\n                }}\n                opacity=\"0\"\n                style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n              >\n                <circle cx={c.x} cy={c.y} r={c.r} fill={shade(base, -0.15)} />\n                <circle cx={c.x - c.r * 0.3} cy={c.y - c.r * 0.3} r={c.r * 0.35} fill={shade(base, 0.3)} />\n              </g>\n            ))}\n          </svg>\n        </span>\n\n        <span className=\"font-mono text-[10px] tracking-[0.18em] uppercase opacity-70\">\n          {sealed ? label : openedLabel}\n        </span>\n      </button>\n      <span id={`${id}-hint`} className=\"sr-only\">\n        {sealed ? hint : \"\"}\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {sealed ? \"\" : openedLabel}\n      </span>\n    </>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-27"
  },
  "type": "registry:component"
}