{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tear-reveal",
  "title": "Tear Reveal",
  "description": "A sheet of paper over any content that you tear open by dragging from a notch. The tear follows your path with a jagged edge and a pale fibrous rim, the halves fall away with gravity and spin past a threshold, and an early release springs it shut. Enter or Space tears it in one pass.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/tear-reveal/tear-reveal.tsx",
      "content": "\"use client\"\r\n\r\nimport * as React from \"react\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport type TearDirection = \"down\" | \"across\" | \"corner\"\r\n\r\ntype Pt = { x: number; y: number }\r\ntype Sample = Pt & { nx: number; ny: number; s: number }\r\n\r\n/** The sheet at rest: a rectangle with a small V notch cut where the tear starts. */\r\nconst REST: Record<TearDirection, string> = {\r\n  down: \"polygon(0 0, calc(50% - 7px) 0, 50% 9px, calc(50% + 7px) 0, 100% 0, 100% 100%, 0 100%)\",\r\n  across: \"polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 calc(50% + 7px), 9px 50%, 0 calc(50% - 7px))\",\r\n  corner: \"polygon(0 0, calc(100% - 16px) 0, calc(100% - 13px) 13px, 100% 16px, 100% 100%, 0 100%)\",\r\n}\r\n\r\nconst HANDLE: Record<TearDirection, string> = {\r\n  down: \"left-1/2 top-0 h-12 w-28 -translate-x-1/2\",\r\n  across: \"left-0 top-1/2 h-28 w-12 -translate-y-1/2\",\r\n  corner: \"right-0 top-0 size-16\",\r\n}\r\n\r\nconst GUIDE: Record<TearDirection, string> = {\r\n  down: \"left-1/2 top-3 h-[26%] border-l [mask-image:linear-gradient(to_bottom,#000,transparent)]\",\r\n  across: \"left-3 top-1/2 w-[26%] border-t [mask-image:linear-gradient(to_right,#000,transparent)]\",\r\n  corner:\r\n    \"right-[13px] top-[13px] w-[min(34%,110px)] origin-right -rotate-45 border-t [mask-image:linear-gradient(to_left,#000,transparent)]\",\r\n}\r\n\r\n/** Half the crack width at its mouth, in px. */\r\nconst MAX_GAP = 12\r\nconst GRAVITY = 2600\r\nconst FALL_MS = 900\r\n\r\n/** Paper tooth: dark and light specks on transparent, so it needs no blend mode. */\r\nconst PAPER_TOOTH = `url(\"data:image/svg+xml;utf8,${encodeURIComponent(\r\n  \"<svg xmlns='http://www.w3.org/2000/svg' width='320' height='320'>\" +\r\n    \"<filter id='p' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>\" +\r\n    \"<feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' seed='4' stitchTiles='stitch' result='t'/>\" +\r\n    \"<feColorMatrix in='t' type='matrix' values='0 0 0 0 0.2 0 0 0 0 0.16 0 0 0 0 0.1 -2 0 0 0 1' result='d'/>\" +\r\n    \"<feColorMatrix in='t' type='matrix' values='0 0 0 0 1 0 0 0 0 0.99 0 0 0 0 0.95 2 0 0 0 -1' result='l'/>\" +\r\n    \"<feMerge><feMergeNode in='d'/><feMergeNode in='l'/></feMerge>\" +\r\n    \"</filter><rect width='100%' height='100%' filter='url(#p)'/></svg>\"\r\n)}\")`\r\n\r\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\r\n\r\nfunction cubicBezier(x1: number, y1: number, x2: number, y2: number) {\r\n  const at = (u: number, a: number, b: number) => ((1 - 3 * b + 3 * a) * u + (3 * b - 6 * a)) * u * u + 3 * a * u\r\n  return (t: number) => {\r\n    if (t <= 0) return 0\r\n    if (t >= 1) return 1\r\n    let lo = 0\r\n    let hi = 1\r\n    let u = t\r\n    for (let i = 0; i < 20; i++) {\r\n      u = (lo + hi) / 2\r\n      if (at(u, x1, x2) < t) lo = u\r\n      else hi = u\r\n    }\r\n    return at(u, y1, y2)\r\n  }\r\n}\r\n\r\nconst EASE_OUT = cubicBezier(0.23, 1, 0.32, 1)\r\nconst EASE_IN_OUT = cubicBezier(0.77, 0, 0.175, 1)\r\n\r\nfunction hash(i: number, seed: number) {\r\n  let h = Math.imul(i + 374761393, 668265263) ^ Math.imul(seed + 1, 2246822519)\r\n  h = Math.imul(h ^ (h >>> 13), 1274126177)\r\n  h ^= h >>> 16\r\n  return ((h >>> 0) / 4294967295) * 2 - 1\r\n}\r\n\r\n/** Smooth 1D value noise in -1 to 1. Keyed by arc length, so a growing tear never reshuffles its edge. */\r\nfunction noise(x: number, seed: number) {\r\n  const i = Math.floor(x)\r\n  const f = x - i\r\n  const u = f * f * (3 - 2 * f)\r\n  return hash(i, seed) * (1 - u) + hash(i + 1, seed) * u\r\n}\r\n\r\nfunction mulberry32(seed: number) {\r\n  let a = seed >>> 0\r\n  return () => {\r\n    a = (a + 0x6d2b79f5) >>> 0\r\n    let t = a\r\n    t = Math.imul(t ^ (t >>> 15), t | 1)\r\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\r\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\r\n  }\r\n}\r\n\r\n/** Dark ink on light paper, light ink on dark paper. */\r\nfunction inkFor(paper: string) {\r\n  const hex = paper.trim().replace(/^#/, \"\")\r\n  const full = hex.length === 3 ? hex.split(\"\").map((c) => c + c).join(\"\") : hex.slice(0, 6)\r\n  const n = Number.parseInt(full, 16)\r\n  if (full.length !== 6 || Number.isNaN(n)) return \"#241d17\"\r\n  const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255\r\n  return lum > 0.55 ? \"#241d17\" : \"#f4f1ea\"\r\n}\r\n\r\ninterface Geo {\r\n  W: number\r\n  H: number\r\n  P: number\r\n  /** Notch tip, where every tear starts. */\r\n  S: Pt\r\n  /** Direction the tear runs. */\r\n  d: Pt\r\n  /** Perimeter positions of the notch corners on the A and B side. */\r\n  tA: number\r\n  tB: number\r\n  /** Distance from the notch to the far edge along `d`. */\r\n  reach: number\r\n}\r\n\r\nfunction rayLength({ W, H }: Geo, p: Pt, d: Pt) {\r\n  let t = Infinity\r\n  if (d.x > 1e-6) t = Math.min(t, (W - p.x) / d.x)\r\n  if (d.x < -1e-6) t = Math.min(t, -p.x / d.x)\r\n  if (d.y > 1e-6) t = Math.min(t, (H - p.y) / d.y)\r\n  if (d.y < -1e-6) t = Math.min(t, -p.y / d.y)\r\n  return Number.isFinite(t) ? Math.max(0, t) : 0\r\n}\r\n\r\nfunction makeGeo(direction: TearDirection, W: number, H: number): Geo {\r\n  let S: Pt\r\n  let d: Pt\r\n  let tA: number\r\n  let tB: number\r\n  if (direction === \"across\") {\r\n    const t = 2 * W + 1.5 * H\r\n    S = { x: 9, y: H / 2 }\r\n    d = { x: 1, y: 0 }\r\n    tA = t - 7\r\n    tB = t + 7\r\n  } else if (direction === \"corner\") {\r\n    const len = Math.hypot(W, H)\r\n    S = { x: W - 13, y: 13 }\r\n    d = { x: -W / len, y: H / len }\r\n    tA = W - 16\r\n    tB = W + 16\r\n  } else {\r\n    S = { x: W / 2, y: 9 }\r\n    d = { x: 0, y: 1 }\r\n    tA = W / 2 - 7\r\n    tB = W / 2 + 7\r\n  }\r\n  const geo: Geo = { W, H, P: 2 * (W + H), S, d, tA, tB, reach: 0 }\r\n  geo.reach = rayLength(geo, S, d)\r\n  return geo\r\n}\r\n\r\n/** Perimeter position (clockwise from the top-left corner) to a point. */\r\nfunction pointAt({ W, H, P }: Geo, t: number): Pt {\r\n  const u = ((t % P) + P) % P\r\n  if (u <= W) return { x: u, y: 0 }\r\n  if (u <= W + H) return { x: W, y: u - W }\r\n  if (u <= 2 * W + H) return { x: W - (u - W - H), y: H }\r\n  return { x: 0, y: H - (u - 2 * W - H) }\r\n}\r\n\r\nfunction perimeterOf({ W, H }: Geo, p: Pt) {\r\n  const m = Math.min(p.y, W - p.x, H - p.y, p.x)\r\n  if (m === p.y) return clamp(p.x, 0, W)\r\n  if (m === W - p.x) return W + clamp(p.y, 0, H)\r\n  if (m === H - p.y) return W + H + (W - clamp(p.x, 0, W))\r\n  return 2 * W + H + (H - clamp(p.y, 0, H))\r\n}\r\n\r\n/** The sheet's outline between two perimeter positions, corners included. */\r\nfunction walk(geo: Geo, from: number, to: number, clockwise: boolean): Pt[] {\r\n  const { W, H, P } = geo\r\n  const along = (t: number) => (clockwise ? (((t - from) % P) + P) % P : (((from - t) % P) + P) % P)\r\n  const span = along(to)\r\n  const out = [0, W, W + H, 2 * W + H]\r\n    .filter((c) => along(c) > 0.01 && along(c) < span)\r\n    .sort((a, b) => along(a) - along(b))\r\n    .map((c) => pointAt(geo, c))\r\n  out.push(pointAt(geo, to))\r\n  return out\r\n}\r\n\r\nfunction polyLength(pts: Pt[]) {\r\n  let len = 0\r\n  for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)\r\n  return len\r\n}\r\n\r\n/** Resamples the tear path every `step` px, after one pass of corner cutting so pointer jitter reads as paper, not polyline. */\r\nfunction sampleEdge(plan: Pt[], step: number) {\r\n  let path = plan\r\n  if (plan.length > 2) {\r\n    path = [plan[0]]\r\n    for (let i = 0; i < plan.length - 1; i++) {\r\n      const a = plan[i]\r\n      const b = plan[i + 1]\r\n      if (i > 0) path.push({ x: a.x * 0.75 + b.x * 0.25, y: a.y * 0.75 + b.y * 0.25 })\r\n      if (i < plan.length - 2) path.push({ x: a.x * 0.25 + b.x * 0.75, y: a.y * 0.25 + b.y * 0.75 })\r\n    }\r\n    path.push(plan[plan.length - 1])\r\n  }\r\n  const samples: Sample[] = []\r\n  let s0 = 0\r\n  let nx = 0\r\n  let ny = 0\r\n  for (let i = 0; i < path.length - 1; i++) {\r\n    const a = path[i]\r\n    const b = path[i + 1]\r\n    const len = Math.hypot(b.x - a.x, b.y - a.y)\r\n    if (len < 1e-3) continue\r\n    const tx = (b.x - a.x) / len\r\n    const ty = (b.y - a.y) / len\r\n    nx = -ty\r\n    ny = tx\r\n    for (let s = Math.ceil(s0 / step) * step; s < s0 + len; s += step) {\r\n      const k = s - s0\r\n      samples.push({ x: a.x + tx * k, y: a.y + ty * k, nx, ny, s })\r\n    }\r\n    s0 += len\r\n  }\r\n  const end = path[path.length - 1]\r\n  samples.push({ x: end.x, y: end.y, nx, ny, s: s0 })\r\n  return { samples, total: s0 }\r\n}\r\n\r\n/** Jagged offset of the torn edge, by arc length. Faded in from the notch so the tear starts at its tip. */\r\nfunction jag(s: number, seed: number) {\r\n  const k = Math.min(1, s / 14)\r\n  return k * (3.6 * noise(s / 38, seed) + 1.8 * noise(s / 11, seed + 7) + 1.1 * noise(s / 2.6, seed + 13))\r\n}\r\n\r\nconst xy = (x: number, y: number) => `${x.toFixed(1)}px ${y.toFixed(1)}px`\r\nconst polygon = (edge: string[], rest: Pt[]) => `polygon(${edge.concat(rest.map((p) => xy(p.x, p.y))).join(\",\")})`\r\n\r\n/**\r\n * Clip paths for both halves. Side A is to the right of the walking direction, side B to the left. Each half has\r\n * a face, a lighter core showing past the face as the fibrous rim, a soft shade cast into the crack, and a darker\r\n * band where the torn edge curls up.\r\n */\r\nfunction shapes(geo: Geo, samples: Sample[], torn: number, gapK: number, seed: number) {\r\n  const G = gapK * Math.min(MAX_GAP, 1.5 + torn * 0.05)\r\n  const face: [string[], string[]] = [[], []]\r\n  const core: [string[], string[]] = [[], []]\r\n  const shade: [string[], string[]] = [[], []]\r\n  const curl: [string[], string[]] = [[], []]\r\n  const curlIn: [string[], string[]] = [[], []]\r\n  let cx = [0, 0]\r\n  let cy = [0, 0]\r\n\r\n  for (const p of samples) {\r\n    const j = jag(p.s, seed)\r\n    const x = p.x + p.nx * j\r\n    const y = p.y + p.ny * j\r\n    const live = p.s < torn\r\n    // Rims and shade thin out over the last few px, so the crack ends in a sharp point.\r\n    const fade = live ? Math.min(1, (torn - p.s) / 14) : 0\r\n    const g = live ? G * Math.pow(1 - p.s / torn, 0.8) : 0\r\n    // Real tears are lopsided: one side keeps a wide white bevel, the other barely any.\r\n    const rim = [\r\n      fade * Math.max(0.3, 1.1 + 0.9 * noise(p.s / 7, seed + 21) + 1.1 * noise(p.s / 1.3, seed + 22)),\r\n      fade * Math.max(0.6, 2.2 + 1.4 * noise(p.s / 9, seed + 31) + 1.5 * noise(p.s / 1.2, seed + 32)),\r\n    ]\r\n    for (let k = 0; k < 2; k++) {\r\n      const side = k === 0 ? 1 : -1\r\n      const fx = x + side * p.nx * g\r\n      const fy = y + side * p.ny * g\r\n      const out = rim[k]\r\n      const cast = out + fade * (2.5 + g * 0.9)\r\n      face[k].push(xy(fx, fy))\r\n      core[k].push(xy(fx - side * p.nx * out, fy - side * p.ny * out))\r\n      shade[k].push(xy(fx - side * p.nx * cast, fy - side * p.ny * cast))\r\n      if (live) {\r\n        const c = Math.min(3.5, g * 0.25 + 0.6) * fade * (0.6 + 0.4 * noise(p.s / 5, seed + 41))\r\n        curl[k].push(xy(fx, fy))\r\n        curlIn[k].push(xy(fx + side * p.nx * c, fy + side * p.ny * c))\r\n      }\r\n      cx[k] += fx\r\n      cy[k] += fy\r\n    }\r\n  }\r\n\r\n  const end = samples[samples.length - 1]\r\n  const tX = perimeterOf(geo, end)\r\n  const wa = walk(geo, tX, geo.tA, true)\r\n  const wb = walk(geo, tX, geo.tB, false)\r\n  const walls = [wa, wb]\r\n  const n = samples.length\r\n  cx = cx.map((v, k) => (v + walls[k].reduce((a, p) => a + p.x * 6, 0)) / (n + walls[k].length * 6))\r\n  cy = cy.map((v, k) => (v + walls[k].reduce((a, p) => a + p.y * 6, 0)) / (n + walls[k].length * 6))\r\n\r\n  const band = (k: number) =>\r\n    curl[k].length > 1 ? `polygon(${curl[k].concat(curlIn[k].slice().reverse()).join(\",\")})` : \"inset(50%)\"\r\n\r\n  return {\r\n    faceA: polygon(face[0], wa),\r\n    faceB: polygon(face[1], wb),\r\n    coreA: polygon(core[0], wa),\r\n    coreB: polygon(core[1], wb),\r\n    shadeA: polygon(shade[0], wa),\r\n    shadeB: polygon(shade[1], wb),\r\n    curlA: band(0),\r\n    curlB: band(1),\r\n    centre: [\r\n      { x: cx[0], y: cy[0] },\r\n      { x: cx[1], y: cy[1] },\r\n    ],\r\n  }\r\n}\r\n\r\nconst LAYERS = [\"shadeA\", \"shadeB\", \"coreA\", \"coreB\", \"faceA\", \"curlA\", \"faceB\", \"curlB\"] as const\r\ntype LayerName = (typeof LAYERS)[number]\r\ntype Layers = Record<LayerName, [outer: HTMLElement, inner: HTMLElement]>\r\nconst PIECES: LayerName[][] = [\r\n  [\"shadeA\", \"coreA\", \"faceA\", \"curlA\"],\r\n  [\"shadeB\", \"coreB\", \"faceB\", \"curlB\"],\r\n]\r\n\r\ninterface EngineOptions {\r\n  direction: TearDirection\r\n  seed: number\r\n  threshold: number\r\n  onCommit: (byUser: boolean) => void\r\n  onDone: () => void\r\n}\r\n\r\ninterface Tween {\r\n  t0: number\r\n  dur: number\r\n  ease: (t: number) => number\r\n  torn: [number, number]\r\n  gap: [number, number]\r\n  done: () => void\r\n}\r\n\r\ninterface Piece {\r\n  x: number\r\n  y: number\r\n  r: number\r\n  rx: number\r\n  ry: number\r\n  vx: number\r\n  vy: number\r\n  vr: number\r\n  vrx: number\r\n  vry: number\r\n}\r\n\r\n/**\r\n * Everything that moves per frame lives here, outside React. Pointer events and the rAF loop write clip paths and\r\n * transforms straight to the layers; React only mounts and unmounts the cover.\r\n */\r\nfunction createTearEngine(getCover: () => HTMLDivElement | null, getOptions: () => EngineOptions) {\r\n  const s = {\r\n    mode: \"rest\" as \"rest\" | \"drag\" | \"tween\" | \"fall\" | \"fade\",\r\n    geo: null as Geo | null,\r\n    layers: null as Layers | null,\r\n    visible: false,\r\n    pts: [] as Pt[],\r\n    live: null as Pt | null,\r\n    samples: [] as Sample[],\r\n    total: 0,\r\n    torn: 0,\r\n    gapK: 1,\r\n    tease: 0,\r\n    tween: null as Tween | null,\r\n    pieces: [] as Piece[],\r\n    fallStart: 0,\r\n    pointer: null as number | null,\r\n    moved: 0,\r\n    hist: [] as { t: number; x: number; y: number }[],\r\n    raf: 0,\r\n    last: 0,\r\n    timer: 0,\r\n  }\r\n\r\n  const reduced = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\r\n\r\n  function measure() {\r\n    const cover = getCover()\r\n    if (!cover) return false\r\n    const W = cover.offsetWidth\r\n    const H = cover.offsetHeight\r\n    if (!W || !H) return false\r\n    const layers = {} as Layers\r\n    for (const name of LAYERS) {\r\n      const outer = cover.querySelector<HTMLElement>(`[data-tear=\"${name}\"]`)\r\n      const inner = outer?.firstElementChild as HTMLElement | null\r\n      if (!outer || !inner) return false\r\n      layers[name] = [outer, inner]\r\n    }\r\n    s.layers = layers\r\n    s.geo = makeGeo(getOptions().direction, W, H)\r\n    return true\r\n  }\r\n\r\n  function toLocal(e: PointerEvent): Pt {\r\n    const cover = getCover()!\r\n    const r = cover.getBoundingClientRect()\r\n    // Layout px, not screen px, in case an ancestor is scaled.\r\n    return {\r\n      x: ((e.clientX - r.left) * cover.offsetWidth) / (r.width || 1),\r\n      y: ((e.clientY - r.top) * cover.offsetHeight) / (r.height || 1),\r\n    }\r\n  }\r\n\r\n  function setPlan(plan: Pt[]) {\r\n    const { samples, total } = sampleEdge(plan, 2)\r\n    s.samples = samples\r\n    s.total = total\r\n  }\r\n\r\n  function straightPlan() {\r\n    const { S, d } = s.geo!\r\n    const len = rayLength(s.geo!, S, d)\r\n    setPlan([S, { x: S.x + d.x * len, y: S.y + d.y * len }])\r\n  }\r\n\r\n  function show(on: boolean) {\r\n    if (!s.layers || s.visible === on) return\r\n    s.visible = on\r\n    for (const name of LAYERS) {\r\n      if (name !== \"faceA\") s.layers[name][0].style.visibility = on ? \"visible\" : \"hidden\"\r\n    }\r\n    if (!on) s.layers.faceA[1].style.clipPath = REST[getOptions().direction]\r\n  }\r\n\r\n  function paint() {\r\n    if (!s.layers || !s.geo || !s.samples.length) return\r\n    const sh = shapes(s.geo, s.samples, s.torn, s.gapK, getOptions().seed)\r\n    for (const name of LAYERS) s.layers[name][1].style.clipPath = sh[name]\r\n    return sh\r\n  }\r\n\r\n  function frame(now: number) {\r\n    s.raf = 0\r\n    const dt = Math.min(0.034, Math.max(0, (now - s.last) / 1000))\r\n    s.last = now\r\n    let again = false\r\n\r\n    if (s.mode === \"rest\") {\r\n      s.torn += (s.tease - s.torn) * (1 - Math.exp(-dt * 16))\r\n      if (Math.abs(s.tease - s.torn) < 0.15) s.torn = s.tease\r\n      if (s.torn <= 0.01) {\r\n        s.torn = 0\r\n        show(false)\r\n      } else {\r\n        show(true)\r\n        paint()\r\n      }\r\n      again = s.torn !== s.tease\r\n    } else if (s.mode === \"drag\") {\r\n      paint()\r\n    } else if (s.mode === \"tween\" && s.tween) {\r\n      const tw = s.tween\r\n      const t = clamp((now - tw.t0) / tw.dur, 0, 1)\r\n      const e = tw.ease(t)\r\n      s.torn = tw.torn[0] + (tw.torn[1] - tw.torn[0]) * e\r\n      s.gapK = tw.gap[0] + (tw.gap[1] - tw.gap[0]) * e\r\n      paint()\r\n      again = true\r\n      if (t >= 1) {\r\n        s.tween = null\r\n        // Whatever comes next (fall, fade, rest) schedules its own frames.\r\n        again = false\r\n        tw.done()\r\n      }\r\n    } else if (s.mode === \"fall\") {\r\n      const t = (now - s.fallStart) / FALL_MS\r\n      const drag = Math.exp(-dt * 0.8)\r\n      const opacity = 1 - clamp((t - 0.3) / 0.7, 0, 1) ** 2\r\n      const scale = 1 + 0.03 * Math.sin(Math.min(1, t * 3) * (Math.PI / 2)) - 0.06 * t\r\n      s.pieces.forEach((p, k) => {\r\n        p.vy += GRAVITY * dt\r\n        p.vx *= drag\r\n        p.vy *= drag\r\n        p.x += p.vx * dt\r\n        p.y += p.vy * dt\r\n        p.r += p.vr * dt\r\n        p.rx += p.vrx * dt\r\n        p.ry += p.vry * dt\r\n        const transform = `translate3d(${p.x.toFixed(1)}px, ${p.y.toFixed(1)}px, 0) rotate(${p.r.toFixed(2)}deg) rotateX(${p.rx.toFixed(2)}deg) rotateY(${p.ry.toFixed(2)}deg) scale(${scale.toFixed(4)})`\r\n        for (const name of PIECES[k]) {\r\n          const el = s.layers![name][0]\r\n          el.style.transform = transform\r\n          // The contact shadow belongs to the surface, not the piece. It goes first.\r\n          el.style.opacity = String(name.startsWith(\"shade\") ? Math.max(0, 1 - t * 8) : opacity)\r\n        }\r\n      })\r\n      again = t < 1\r\n      if (!again) finish()\r\n    } else if (s.mode === \"fade\") {\r\n      const t = clamp((now - s.fallStart) / 200, 0, 1)\r\n      const cover = getCover()\r\n      if (cover) cover.style.opacity = String(1 - EASE_OUT(t))\r\n      again = t < 1\r\n      if (!again) finish()\r\n    }\r\n\r\n    if (again) s.raf = requestAnimationFrame(frame)\r\n  }\r\n\r\n  function kick() {\r\n    if (s.raf) return\r\n    s.last = performance.now()\r\n    s.raf = requestAnimationFrame(frame)\r\n  }\r\n\r\n  function finish() {\r\n    s.mode = \"rest\"\r\n    s.torn = 0\r\n    s.tease = 0\r\n    s.visible = false\r\n    getOptions().onDone()\r\n  }\r\n\r\n  function track(p: Pt) {\r\n    const geo = s.geo!\r\n    const { S, d, W, H, reach } = geo\r\n    let q = { x: clamp(p.x, 3, W - 3), y: clamp(p.y, 3, H - 3) }\r\n    let a = (q.x - S.x) * d.x + (q.y - S.y) * d.y\r\n    // Keep the tip between the notch and the far edge; it never runs backwards past the path behind it.\r\n    const shift = a < 0 ? -a : a > reach - 3 ? reach - 3 - a : 0\r\n    if (shift) {\r\n      q = { x: clamp(q.x + d.x * shift, 3, W - 3), y: clamp(q.y + d.y * shift, 3, H - 3) }\r\n      a = (q.x - S.x) * d.x + (q.y - S.y) * d.y\r\n    }\r\n    const along = (v: Pt) => (v.x - S.x) * d.x + (v.y - S.y) * d.y\r\n    while (s.pts.length > 1 && along(s.pts[s.pts.length - 1]) >= a - 2) s.pts.pop()\r\n    const last = s.pts[s.pts.length - 1]\r\n    if (Math.hypot(q.x - last.x, q.y - last.y) >= 10 && along(last) < a - 2) s.pts.push(q)\r\n    s.live = q\r\n    const path = s.pts.slice()\r\n    const tail = path[path.length - 1]\r\n    if (Math.hypot(q.x - tail.x, q.y - tail.y) > 0.5) path.push(q)\r\n    s.torn = polyLength(path)\r\n    const ext = rayLength(geo, q, d)\r\n    setPlan(ext > 1 ? [...path, { x: q.x + d.x * ext, y: q.y + d.y * ext }] : path)\r\n  }\r\n\r\n  function velocity(): Pt {\r\n    const h = s.hist\r\n    if (h.length < 2) return { x: 0, y: 0 }\r\n    const b = h[h.length - 1]\r\n    const a = h.find((p) => b.t - p.t <= 100) ?? h[0]\r\n    const dt = Math.max(1, b.t - a.t)\r\n    return { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt }\r\n  }\r\n\r\n  function springBack() {\r\n    s.mode = \"tween\"\r\n    s.tween = {\r\n      t0: performance.now(),\r\n      dur: 320,\r\n      ease: EASE_OUT,\r\n      torn: [s.torn, 0],\r\n      gap: [s.gapK, 0],\r\n      done: () => {\r\n        s.mode = \"rest\"\r\n        s.gapK = 1\r\n        s.tease = 0\r\n        s.torn = 0\r\n        show(false)\r\n      },\r\n    }\r\n    kick()\r\n  }\r\n\r\n  function startFall(v: Pt) {\r\n    const { d } = s.geo!\r\n    const sh = paint()\r\n    if (!sh || !s.layers) return finish()\r\n    const cover = getCover()\r\n    if (cover) cover.style.overflow = \"visible\"\r\n    const rnd = mulberry32(getOptions().seed * 31 + 7)\r\n    const speed = Math.hypot(v.x, v.y)\r\n    const k = speed > 2400 ? 2400 / speed : 1\r\n    s.pieces = [1, -1].map((side, i) => {\r\n      const out = { x: -d.y * side, y: d.x * side }\r\n      const spin = -(d.x * out.y - d.y * out.x)\r\n      const c = sh.centre[i]\r\n      for (const name of PIECES[i]) {\r\n        const el = s.layers![name][0]\r\n        el.style.transformOrigin = `${c.x.toFixed(1)}px ${c.y.toFixed(1)}px`\r\n        // Airborne now, so the face casts a shadow of its own. Set once; only the transform changes per frame.\r\n        if (name.startsWith(\"face\")) el.style.filter = \"drop-shadow(0 12px 14px rgb(20 14 10 / 0.22))\"\r\n      }\r\n      return {\r\n        x: 0,\r\n        y: 0,\r\n        r: 0,\r\n        rx: 0,\r\n        ry: 0,\r\n        vx: v.x * k * 0.35 + out.x * 190 + (rnd() - 0.5) * 60,\r\n        vy: v.y * k * 0.35 + out.y * 190 - 240 - rnd() * 120,\r\n        vr: spin * (50 + rnd() * 60),\r\n        vrx: side * (40 + rnd() * 60),\r\n        vry: -side * (30 + rnd() * 50),\r\n      }\r\n    })\r\n    s.mode = \"fall\"\r\n    s.fallStart = performance.now()\r\n    kick()\r\n  }\r\n\r\n  function fade() {\r\n    s.mode = \"fade\"\r\n    s.fallStart = performance.now()\r\n    const cover = getCover()\r\n    if (cover) cover.style.transition = \"none\"\r\n    kick()\r\n  }\r\n\r\n  /** Runs the tear from its tip to the far edge, then drops the halves. */\r\n  function rip(plan: Pt[], from: number, dur: number, ease: (t: number) => number, v: Pt) {\r\n    setPlan(plan)\r\n    s.mode = \"tween\"\r\n    s.tween = {\r\n      t0: performance.now(),\r\n      dur,\r\n      ease,\r\n      torn: [from, s.total],\r\n      gap: [s.gapK, 1],\r\n      done: () => startFall(v),\r\n    }\r\n    show(true)\r\n    kick()\r\n  }\r\n\r\n  function commit(v: Pt) {\r\n    getOptions().onCommit(true)\r\n    if (reduced()) return fade()\r\n    const geo = s.geo!\r\n    const { d } = geo\r\n    const path = s.pts.slice()\r\n    const q = s.live ?? geo.S\r\n    const tail = path[path.length - 1]\r\n    if (Math.hypot(q.x - tail.x, q.y - tail.y) > 0.5) path.push(q)\r\n    // Carry on in the direction the hand was going, bent back toward the far edge.\r\n    let dir = d\r\n    if (path.length > 1) {\r\n      const a = path[path.length - 2]\r\n      const len = Math.hypot(q.x - a.x, q.y - a.y) || 1\r\n      const bx = d.x + ((q.x - a.x) / len) * 0.6\r\n      const by = d.y + ((q.y - a.y) / len) * 0.6\r\n      const bl = Math.hypot(bx, by) || 1\r\n      if ((bx * d.x + by * d.y) / bl > 0.3) dir = { x: bx / bl, y: by / bl }\r\n    }\r\n    const ext = rayLength(geo, q, dir)\r\n    const plan = ext > 1 ? [...path, { x: q.x + dir.x * ext, y: q.y + dir.y * ext }] : path\r\n    const remaining = Math.max(0, polyLength(plan) - s.torn)\r\n    rip(plan, s.torn, clamp(remaining / 2.2, 120, 260), EASE_OUT, { x: v.x * 1000, y: v.y * 1000 })\r\n  }\r\n\r\n  /** A short pull at the notch, to show where the tear starts. */\r\n  function nudge() {\r\n    if (reduced()) return\r\n    window.clearTimeout(s.timer)\r\n    const run = () => {\r\n      if (s.mode !== \"rest\" || !measure()) return\r\n      straightPlan()\r\n      s.tease = 22\r\n      kick()\r\n      s.timer = window.setTimeout(() => {\r\n        if (s.mode !== \"rest\") return\r\n        s.tease = 0\r\n        kick()\r\n      }, 200)\r\n    }\r\n    if (s.mode === \"rest\") run()\r\n    else s.timer = window.setTimeout(run, 340)\r\n  }\r\n\r\n  return {\r\n    down(e: PointerEvent, handle: HTMLElement) {\r\n      if (s.pointer !== null || (s.mode !== \"rest\" && !(s.mode === \"tween\" && s.tween?.torn[1] === 0))) return\r\n      if (!measure()) return\r\n      s.pointer = e.pointerId\r\n      handle.setPointerCapture(e.pointerId)\r\n      s.mode = \"drag\"\r\n      s.tween = null\r\n      s.gapK = 1\r\n      s.moved = 0\r\n      s.pts = [s.geo!.S]\r\n      const p = toLocal(e)\r\n      s.hist = [{ t: e.timeStamp, ...p }]\r\n      track(p)\r\n      show(true)\r\n      kick()\r\n    },\r\n    move(e: PointerEvent) {\r\n      if (e.pointerId !== s.pointer || s.mode !== \"drag\") return\r\n      const p = toLocal(e)\r\n      const prev = s.hist[s.hist.length - 1]\r\n      s.moved += Math.hypot(p.x - prev.x, p.y - prev.y)\r\n      s.hist.push({ t: e.timeStamp, ...p })\r\n      if (s.hist.length > 8) s.hist.shift()\r\n      track(p)\r\n      kick()\r\n    },\r\n    up(e: PointerEvent) {\r\n      if (e.pointerId !== s.pointer) return\r\n      s.pointer = null\r\n      if (s.mode !== \"drag\") return\r\n      const geo = s.geo!\r\n      const q = s.live ?? geo.S\r\n      const progress = ((q.x - geo.S.x) * geo.d.x + (q.y - geo.S.y) * geo.d.y) / (geo.reach || 1)\r\n      const v = velocity()\r\n      // A quick flick tears it even short of the threshold.\r\n      const flick = v.x * geo.d.x + v.y * geo.d.y > 0.55 && progress > 0.12\r\n      if (e.type !== \"pointercancel\" && (progress >= getOptions().threshold || flick)) commit(v)\r\n      else if (s.moved < 4) {\r\n        springBack()\r\n        nudge()\r\n      } else springBack()\r\n    },\r\n    hover(on: boolean) {\r\n      if (s.mode !== \"rest\" || reduced()) return\r\n      if (on) {\r\n        if (!measure()) return\r\n        straightPlan()\r\n      }\r\n      s.tease = on ? 12 : 0\r\n      kick()\r\n    },\r\n    nudge,\r\n    /** Keyboard and controlled tears: one automatic pass along a gently wandering line. */\r\n    auto(byUser: boolean) {\r\n      if (s.mode !== \"rest\" || s.pointer !== null) return\r\n      if (!measure()) {\r\n        getOptions().onCommit(byUser)\r\n        return finish()\r\n      }\r\n      if (byUser) getOptions().onCommit(true)\r\n      if (reduced()) return fade()\r\n      const geo = s.geo!\r\n      const { S, d, reach, W, H } = geo\r\n      const seed = getOptions().seed\r\n      const wobble = Math.min(16, reach * 0.06)\r\n      const plan: Pt[] = []\r\n      for (let a = 0; a < reach - 1; a += 28) {\r\n        const off = a < 20 ? 0 : wobble * noise(a / 90, seed + 51)\r\n        plan.push({ x: clamp(S.x + d.x * a - d.y * off, 3, W - 3), y: clamp(S.y + d.y * a + d.x * off, 3, H - 3) })\r\n      }\r\n      plan.push({ x: S.x + d.x * reach, y: S.y + d.y * reach })\r\n      s.tease = 0\r\n      s.gapK = 1\r\n      rip(plan, s.torn, 560, EASE_IN_OUT, { x: d.x * 500, y: d.y * 500 })\r\n    },\r\n    dispose() {\r\n      cancelAnimationFrame(s.raf)\r\n      window.clearTimeout(s.timer)\r\n      s.raf = 0\r\n    },\r\n  }\r\n}\r\n\r\ntype TearEngine = ReturnType<typeof createTearEngine>\r\n\r\nexport interface TearRevealProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\r\n  /**\r\n   * What is printed on the paper: a label, an image, any node. It fills the sheet and inherits an ink colour that\r\n   * reads on `paper`. Omit for blank paper.\r\n   */\r\n  cover?: React.ReactNode\r\n  /** The content under the paper. It sets the size, unless you size the component with className. */\r\n  children?: React.ReactNode\r\n  /**\r\n   * Where the tear starts. \"down\" runs from a notch in the top edge, \"across\" from the left edge, \"corner\" from the\r\n   * top-right corner. Default \"down\".\r\n   */\r\n  direction?: TearDirection\r\n  /** Paper colour, as hex. The torn rim is a lighter mix of it. Default \"#f4f1ea\". */\r\n  paper?: string\r\n  /** How far the tear must run before letting go tears it free, 0 to 1. A quick flick also counts. Default 0.4. */\r\n  threshold?: number\r\n  /** Controlled torn state. Set it back to false to put the paper back. */\r\n  torn?: boolean\r\n  /** Initial torn state when uncontrolled. Default false. */\r\n  defaultTorn?: boolean\r\n  /** Called when the paper tears free by drag or keyboard. Not called when `torn` is set from outside. */\r\n  onTear?: () => void\r\n  /** Seed for the jagged edge and the way the halves fall. Default 5. */\r\n  seed?: number\r\n  /** Accessible name for the cover. Default \"Tear to reveal\". */\r\n  label?: string\r\n}\r\n\r\n/**\r\n * A sheet of paper over some content. Drag from the notch and the paper tears along your path, with a jagged edge\r\n * and a pale fibrous rim. Let go past the threshold and the halves fall away; let go early and it springs shut.\r\n * Enter or Space tears it in one pass.\r\n */\r\nexport function TearReveal({\r\n  cover,\r\n  children,\r\n  direction = \"down\",\r\n  paper = \"#f4f1ea\",\r\n  threshold = 0.4,\r\n  torn,\r\n  defaultTorn = false,\r\n  onTear,\r\n  seed = 5,\r\n  label = \"Tear to reveal\",\r\n  className,\r\n  ...props\r\n}: TearRevealProps) {\r\n  const coverRef = React.useRef<HTMLDivElement>(null)\r\n  const contentRef = React.useRef<HTMLDivElement>(null)\r\n  const engineRef = React.useRef<TearEngine | null>(null)\r\n  const focusAfterRef = React.useRef(false)\r\n\r\n  const [inner, setInner] = React.useState(defaultTorn)\r\n  const isTorn = torn ?? inner\r\n  const [shown, setShown] = React.useState(!isTorn)\r\n  const [generation, setGeneration] = React.useState(0)\r\n  const [entering, setEntering] = React.useState(false)\r\n  const [prevTorn, setPrevTorn] = React.useState(isTorn)\r\n  if (prevTorn !== isTorn) {\r\n    setPrevTorn(isTorn)\r\n    if (!isTorn && !shown) {\r\n      setShown(true)\r\n      setEntering(true)\r\n    }\r\n  }\r\n\r\n  const optionsRef = React.useRef<EngineOptions & { isTorn: boolean }>(null)\r\n  React.useEffect(() => {\r\n    optionsRef.current = {\r\n      direction,\r\n      seed,\r\n      threshold: clamp(threshold, 0.05, 0.95),\r\n      isTorn,\r\n      onCommit: (byUser) => {\r\n        if (!byUser) return\r\n        setInner(true)\r\n        onTear?.()\r\n      },\r\n      onDone: () => {\r\n        const el = coverRef.current\r\n        focusAfterRef.current = !!el && el.contains(document.activeElement)\r\n        if (optionsRef.current?.isTorn) setShown(false)\r\n        else {\r\n          // Controlled and the parent kept it closed: put a fresh sheet back.\r\n          setGeneration((g) => g + 1)\r\n          setEntering(true)\r\n        }\r\n      },\r\n    }\r\n  })\r\n\r\n  React.useEffect(() => {\r\n    const engine = createTearEngine(\r\n      () => coverRef.current,\r\n      () => optionsRef.current!\r\n    )\r\n    engineRef.current = engine\r\n    return () => {\r\n      engine.dispose()\r\n      engineRef.current = null\r\n    }\r\n  }, [])\r\n\r\n  // `torn` flipped to true from outside: tear it the same way the keyboard does.\r\n  React.useEffect(() => {\r\n    if (isTorn && shown) engineRef.current?.auto(false)\r\n  }, [isTorn, shown])\r\n\r\n  React.useEffect(() => {\r\n    if (shown || !focusAfterRef.current) return\r\n    focusAfterRef.current = false\r\n    contentRef.current?.focus({ preventScroll: true })\r\n  }, [shown])\r\n\r\n  const ink = inkFor(paper)\r\n  const face = (\r\n    <>\r\n      {cover}\r\n      <div\r\n        className=\"pointer-events-none absolute inset-0 forced-colors:hidden\"\r\n        style={{ backgroundImage: PAPER_TOOTH, backgroundSize: \"320px 320px\", opacity: 0.4 }}\r\n      />\r\n      <span\r\n        className={cn(\"pointer-events-none absolute border-dashed\", GUIDE[direction])}\r\n        style={{ borderColor: ink, opacity: 0.4 }}\r\n      />\r\n    </>\r\n  )\r\n  const faceStyle: React.CSSProperties = { backgroundColor: paper, color: ink }\r\n  const layerStyle: Record<LayerName, React.CSSProperties> = {\r\n    shadeA: { backgroundColor: \"rgb(20 14 10 / 0.42)\" },\r\n    shadeB: { backgroundColor: \"rgb(20 14 10 / 0.42)\" },\r\n    coreA: { backgroundColor: `color-mix(in oklab, ${paper} 22%, white)` },\r\n    coreB: { backgroundColor: `color-mix(in oklab, ${paper} 22%, white)` },\r\n    faceA: { ...faceStyle, clipPath: REST[direction] },\r\n    curlA: { backgroundColor: `color-mix(in oklab, ${paper} 88%, #241d17)` },\r\n    faceB: faceStyle,\r\n    curlB: { backgroundColor: `color-mix(in oklab, ${paper} 88%, #241d17)` },\r\n  }\r\n\r\n  return (\r\n    <div\r\n      data-slot=\"tear-reveal\"\r\n      data-state={shown ? \"covered\" : \"torn\"}\r\n      className={cn(\"relative isolate\", className)}\r\n      {...props}\r\n    >\r\n      <div ref={contentRef} tabIndex={-1} inert={shown} className=\"size-full rounded-[inherit] outline-none\">\r\n        {children}\r\n      </div>\r\n      {shown && (\r\n        <div\r\n          key={generation}\r\n          ref={coverRef}\r\n          role=\"button\"\r\n          tabIndex={0}\r\n          aria-label={label}\r\n          data-slot=\"tear-reveal-cover\"\r\n          className={cn(\r\n            \"absolute inset-0 z-10 overflow-hidden rounded-[inherit] outline-none select-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\r\n            entering && \"transition-opacity duration-200 ease-out starting:opacity-0\"\r\n          )}\r\n          style={{ perspective: 900 }}\r\n          onKeyDown={(e) => {\r\n            if (e.key !== \"Enter\" && e.key !== \" \") return\r\n            e.preventDefault()\r\n            if (!e.repeat) engineRef.current?.auto(true)\r\n          }}\r\n          onClick={(e) => {\r\n            if (!(e.target as HTMLElement).closest(\"[data-tear-handle]\")) engineRef.current?.nudge()\r\n          }}\r\n          onFocus={(e) => {\r\n            if (e.target === e.currentTarget && e.currentTarget.matches(\":focus-visible\")) engineRef.current?.hover(true)\r\n          }}\r\n          onBlur={() => engineRef.current?.hover(false)}\r\n        >\r\n          {LAYERS.map((name) => (\r\n            <div\r\n              key={name}\r\n              data-tear={name}\r\n              aria-hidden\r\n              className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\r\n              style={{\r\n                visibility: name === \"faceA\" ? undefined : \"hidden\",\r\n                filter: name.startsWith(\"shade\") ? \"blur(3px)\" : undefined,\r\n              }}\r\n            >\r\n              <div className=\"absolute inset-0 overflow-hidden rounded-[inherit]\" style={layerStyle[name]}>\r\n                {name.startsWith(\"face\") ? face : null}\r\n              </div>\r\n            </div>\r\n          ))}\r\n          <div\r\n            data-tear-handle\r\n            aria-hidden\r\n            className={cn(\"absolute z-10 cursor-grab touch-none active:cursor-grabbing\", HANDLE[direction])}\r\n            onPointerDown={(e) => {\r\n              if (e.button !== 0) return\r\n              e.preventDefault()\r\n              engineRef.current?.down(e.nativeEvent, e.currentTarget)\r\n            }}\r\n            onPointerMove={(e) => engineRef.current?.move(e.nativeEvent)}\r\n            onPointerUp={(e) => engineRef.current?.up(e.nativeEvent)}\r\n            onPointerCancel={(e) => engineRef.current?.up(e.nativeEvent)}\r\n            onPointerEnter={(e) => e.pointerType === \"mouse\" && engineRef.current?.hover(true)}\r\n            onPointerLeave={(e) => e.pointerType === \"mouse\" && engineRef.current?.hover(false)}\r\n          />\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-27"
  },
  "type": "registry:component"
}