{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tearable-reveal",
  "title": "Tearable Reveal",
  "description": "A physically-simulated cloth intro reveal, drag to tear open a torn-paper layer covering an image, with breakable constraints, particle debris, frayed jagged/rounded edges, and optional surface text or logo. Includes Auto Tear and Reset controls.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "registry/new-york/tearable-reveal/tearable-reveal.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nfunction cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport type TearableRevealFit = \"cover\" | \"contain\" | \"fill\";\nexport type TearableRevealSurfaceStyle = \"smooth\" | \"textured\";\nexport type TearableRevealEdgeStyle = \"jagged\" | \"rounded\" | \"both\";\nexport type TearableRevealTextAlign = \"left\" | \"center\" | \"right\";\n\nexport interface TearableRevealProps {\n  backgroundSrc?: string;\n  backgroundAlt?: string;\n  backgroundFit?: TearableRevealFit;\n  backgroundPositionX?: number;\n  backgroundPositionY?: number;\n  backgroundColor?: string;\n  clothColorStart?: string;\n  clothColorEnd?: string;\n  clothOpacity?: number;\n  columns?: number;\n  rows?: number;\n  tearThreshold?: number;\n  brushRadius?: number;\n  gravity?: number;\n  ambientSway?: number;\n  surfaceStyle?: TearableRevealSurfaceStyle;\n  showParticles?: boolean;\n  showShadow?: boolean;\n  tearEdgeStyle?: TearableRevealEdgeStyle;\n  surfaceText?: string;\n  surfaceTextSize?: number;\n  surfaceTextColor?: string;\n  surfaceTextFont?: string;\n  surfaceTextWeight?: number;\n  surfaceTextAlign?: TearableRevealTextAlign;\n  surfaceTextOpacity?: number;\n  surfaceLogoSrc?: string;\n  surfaceLogoWidth?: number;\n  surfaceLogoPositionX?: number;\n  surfaceLogoPositionY?: number;\n  surfaceLogoOffsetX?: number;\n  surfaceLogoOffsetY?: number;\n  surfaceLogoOpacity?: number;\n  pinTopRow?: boolean;\n  showAutoTearButton?: boolean;\n  autoTearButtonLabel?: string;\n  autoTearDuration?: number;\n  autoStartTear?: boolean;\n  autoStartDelay?: number;\n  showHint?: boolean;\n  hintText?: string;\n  resetLabel?: string;\n  showResetButton?: boolean;\n  onFullyTorn?: () => void;\n  className?: string;\n}\n\ninterface ClothPoint {\n  x: number;\n  y: number;\n  ox: number;\n  oy: number;\n  pinned: boolean;\n  anchored: boolean;\n  orphanSince: number | null;\n}\n\ninterface ClothConstraint {\n  a: number;\n  b: number;\n  length: number;\n  broken: boolean;\n}\n\ninterface ClothParticle {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  life: number;\n  maxLife: number;\n  size: number;\n  rotation: number;\n  spin: number;\n}\n\ninterface TearFlash {\n  x: number;\n  y: number;\n  life: number;\n  maxLife: number;\n  radius: number;\n}\n\nconst FADE_DURATION = 1.7;\nconst ORPHAN_FALL_DELAY = 0.4;\nconst GRAVITY_BOOST = 1.7;\nconst MAX_PARTICLES = 140;\nconst PARTICLE_GRAVITY = 620;\n// Caps how far a point (or the drag force pulling it) can move in a single\n// frame. Without this, a fast swipe or a chain-reaction of breaking\n// constraints can fling a point far past its neighbors in one step — for\n// that frame the mesh renders as a wildly stretched, tangled web before the\n// constraint solver has a chance to catch up.\nconst MAX_POINT_STEP = 42;\n\nfunction clampMagnitude(x: number, y: number, max: number): [number, number] {\n  const distSq = x * x + y * y;\n  if (distSq <= max * max) return [x, y];\n  const dist = Math.sqrt(distSq);\n  const scale = max / dist;\n  return [x * scale, y * scale];\n}\n\nconst SCISSORS_OPEN_SVG =\n  `<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'><g fill='none' stroke='%23222' stroke-width='2'><circle cx='9' cy='23' r='4'/><circle cx='23' cy='23' r='4'/><path d='M11 20 L26 5 M21 20 L6 5'/></g></svg>`;\nconst SCISSORS_CLOSED_SVG =\n  `<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'><g fill='none' stroke='%23222' stroke-width='2'><circle cx='9' cy='23' r='4'/><circle cx='23' cy='23' r='4'/><path d='M13 20 L26 8 M18 20 L6 8'/></g></svg>`;\n\nconst SCISSORS_OPEN_CURSOR = `url(\"data:image/svg+xml,${encodeURIComponent(SCISSORS_OPEN_SVG)}\") 4 4, crosshair`;\nconst SCISSORS_CLOSED_CURSOR = `url(\"data:image/svg+xml,${encodeURIComponent(SCISSORS_CLOSED_SVG)}\") 4 4, crosshair`;\n\nfunction pointFadeAlpha(p: ClothPoint, t: number): number {\n  if (p.anchored || p.orphanSince === null) return 1;\n  const elapsed = t - p.orphanSince - ORPHAN_FALL_DELAY;\n  if (elapsed <= 0) return 1;\n  const clamped = Math.max(0, Math.min(1, 1 - elapsed / FADE_DURATION));\n  return clamped * clamped;\n}\n\nfunction distToSegmentSq(\n  px: number,\n  py: number,\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number\n): number {\n  const dx = x2 - x1;\n  const dy = y2 - y1;\n  const lenSq = dx * dx + dy * dy;\n  if (lenSq < 1e-6) {\n    const ddx = px - x1;\n    const ddy = py - y1;\n    return ddx * ddx + ddy * ddy;\n  }\n  let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;\n  t = Math.max(0, Math.min(1, t));\n  const cx = x1 + dx * t;\n  const cy = y1 + dy * t;\n  const ddx = px - cx;\n  const ddy = py - cy;\n  return ddx * ddx + ddy * ddy;\n}\n\nfunction cornerTearPoint(progress: number, w: number, h: number): { x: number; y: number } {\n  const anchors: Array<[number, number]> = [\n    [0, h * 0.05],\n    [w * 0.07, h * 0.11],\n    [w * 0.14, h * 0.22],\n  ];\n  const clamped = Math.max(0, Math.min(1, progress));\n  const segT = clamped * (anchors.length - 1);\n  const idx = Math.min(anchors.length - 2, Math.floor(segT));\n  const localT = segT - idx;\n  const [ax, ay] = anchors[idx];\n  const [bx, by] = anchors[idx + 1];\n  return { x: ax + (bx - ax) * localT, y: ay + (by - ay) * localT };\n}\n\nfunction hash2(a: number, b: number): number {\n  const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;\n  return s - Math.floor(s);\n}\n\nfunction spawnTearFlash(flashes: TearFlash[], x: number, y: number, radius: number) {\n  if (flashes.length >= 30) flashes.shift();\n  flashes.push({ x, y, life: 0, maxLife: 0.22, radius });\n}\n\nfunction spawnTearParticles(\n  particles: ClothParticle[],\n  x: number,\n  y: number,\n  dirX: number,\n  dirY: number,\n  seed: number\n) {\n  if (particles.length >= MAX_PARTICLES) return;\n  const count = 2 + Math.floor(hash2(seed, 0.7) * 2);\n  for (let i = 0; i < count; i++) {\n    if (particles.length >= MAX_PARTICLES) break;\n    const angle = Math.atan2(dirY, dirX) + (hash2(seed + i, 1.3) - 0.5) * 2.4;\n    const speed = 30 + hash2(seed + i, 2.9) * 70;\n    particles.push({\n      x: x + (hash2(seed + i, 3.7) - 0.5) * 6,\n      y: y + (hash2(seed + i, 4.9) - 0.5) * 6,\n      vx: Math.cos(angle) * speed,\n      vy: Math.sin(angle) * speed - 40,\n      life: 0,\n      maxLife: 0.5 + hash2(seed + i, 5.3) * 0.6,\n      size: 2 + hash2(seed + i, 6.1) * 4,\n      rotation: hash2(seed + i, 7.2) * Math.PI * 2,\n      spin: (hash2(seed + i, 8.4) - 0.5) * 10,\n    });\n  }\n}\n\nfunction frayEdge(\n  ctx: CanvasRenderingContext2D,\n  ax: number,\n  ay: number,\n  bx: number,\n  by: number,\n  seed: number,\n  outwardX: number,\n  outwardY: number,\n  style: TearableRevealEdgeStyle\n): void {\n  const dx = bx - ax;\n  const dy = by - ay;\n  const edgeLen = Math.hypot(dx, dy);\n  if (edgeLen < 1) return;\n\n  let nx = -dy / edgeLen;\n  let ny = dx / edgeLen;\n  if (nx * outwardX + ny * outwardY < 0) {\n    nx = -nx;\n    ny = -ny;\n  }\n\n  const useFrayed = style === \"jagged\" || (style === \"both\" && hash2(seed, 99.1) < 0.5);\n\n  if (useFrayed) {\n    // Natural cloth fray: a soft irregular wave (no hard corners) plus a\n    // couple of loose thread wisps, instead of uniform sharp triangles.\n    const segments = edgeLen > 50 ? 7 : edgeLen > 25 ? 5 : 3;\n    const step = edgeLen / segments;\n    const ux = dx / edgeLen;\n    const uy = dy / edgeLen;\n\n    const pts: { x: number; y: number }[] = [{ x: ax, y: ay }];\n    for (let i = 1; i < segments; i++) {\n      const t = i / segments;\n      const baseX = ax + dx * t;\n      const baseY = ay + dy * t;\n      const shallow = hash2(seed, i * 3.1 + 1);\n      const notch = hash2(seed + 11, i * 5.7 + 2) < 0.25 ? 1.7 : 1;\n      const depth = step * (0.12 + shallow * 0.5) * notch;\n      const along = (hash2(seed + 3, i * 2.2 + 3) - 0.5) * step * 0.3;\n      pts.push({ x: baseX + nx * depth + ux * along, y: baseY + ny * depth + uy * along });\n    }\n    pts.push({ x: bx, y: by });\n\n    ctx.beginPath();\n    ctx.moveTo(pts[0].x, pts[0].y);\n    for (let i = 1; i < pts.length - 1; i++) {\n      const cur = pts[i];\n      const next = pts[i + 1];\n      const midX = (cur.x + next.x) / 2;\n      const midY = (cur.y + next.y) / 2;\n      ctx.quadraticCurveTo(cur.x, cur.y, midX, midY);\n    }\n    ctx.lineTo(bx, by);\n    ctx.closePath();\n    ctx.fill();\n\n    const threadCount = edgeLen > 40 ? 2 : 1;\n    const prevCap = ctx.lineCap;\n    ctx.lineCap = \"round\";\n    ctx.strokeStyle = ctx.fillStyle;\n    ctx.lineWidth = Math.max(0.6, edgeLen * 0.012);\n    for (let i = 0; i < threadCount; i++) {\n      const t = 0.2 + hash2(seed + 40, i * 7.7 + 1) * 0.6;\n      const baseX = ax + dx * t;\n      const baseY = ay + dy * t;\n      const len = step * (0.4 + hash2(seed + 50, i * 4.1 + 2) * 0.5);\n      const wob = (hash2(seed + 60, i * 6.3 + 3) - 0.5) * len * 0.7;\n      ctx.beginPath();\n      ctx.moveTo(baseX, baseY);\n      ctx.quadraticCurveTo(baseX + nx * len * 0.5 + ux * wob, baseY + ny * len * 0.5 + uy * wob, baseX + nx * len, baseY + ny * len);\n      ctx.stroke();\n    }\n    ctx.lineCap = prevCap;\n  } else {\n    const bites = edgeLen > 40 ? 3 : 2;\n    for (let i = 0; i < bites; i++) {\n      const baseT = (i + 0.5) / bites;\n      const jitterT = (hash2(seed, i * 7.31 + 1) - 0.5) * 0.4;\n      const t = Math.min(0.94, Math.max(0.06, baseT + jitterT));\n      const px = ax + dx * t;\n      const py = ay + dy * t;\n      const jitterR = 0.32 + hash2(seed + 19, i * 3.71 + 2) * 0.3;\n      const radius = (edgeLen / bites) * jitterR;\n      ctx.beginPath();\n      ctx.arc(px, py, radius, 0, Math.PI * 2);\n      ctx.fill();\n    }\n  }\n}\n\nfunction clampByte(v: number): number {\n  return Math.max(0, Math.min(255, Math.round(v)));\n}\n\nfunction parseColorToRgb(input: string): [number, number, number] {\n  const s = input.trim();\n  if (s.startsWith(\"#\")) {\n    let hex = s.slice(1);\n    if (hex.length === 3 || hex.length === 4) {\n      hex = hex\n        .split(\"\")\n        .map((c) => c + c)\n        .join(\"\");\n    }\n    const num = parseInt(hex.slice(0, 6), 16);\n    return [clampByte((num >> 16) & 255), clampByte((num >> 8) & 255), clampByte(num & 255)];\n  }\n  const m = s.match(/rgba?\\(([^)]+)\\)/i);\n  if (m) {\n    const parts = m[1].split(\",\").map((p) => parseFloat(p.trim()));\n    return [clampByte(parts[0] ?? 0), clampByte(parts[1] ?? 0), clampByte(parts[2] ?? 0)];\n  }\n  return [128, 128, 128];\n}\n\nexport function TearableReveal({\n  backgroundSrc,\n  backgroundAlt = \"\",\n  backgroundFit = \"cover\",\n  backgroundPositionX = 50,\n  backgroundPositionY = 50,\n  backgroundColor = \"#f4f3f1\",\n  clothColorStart = \"#5a78c8\",\n  clothColorEnd = \"#6eb48c\",\n  clothOpacity = 0.94,\n  columns = 40,\n  rows = 27,\n  tearThreshold = 1.15,\n  brushRadius = 56,\n  gravity = 480,\n  ambientSway = 4,\n  surfaceStyle = \"textured\",\n  showParticles = true,\n  showShadow = true,\n  tearEdgeStyle = \"jagged\",\n  surfaceText = \"\",\n  surfaceTextSize = 32,\n  surfaceTextColor = \"#0d0d0d\",\n  surfaceTextFont = \"Helvetica Neue, Arial, sans-serif\",\n  surfaceTextWeight = 400,\n  surfaceTextAlign = \"center\",\n  surfaceTextOpacity = 0.9,\n  surfaceLogoSrc,\n  surfaceLogoWidth = 28,\n  surfaceLogoPositionX = 50,\n  surfaceLogoPositionY = 50,\n  surfaceLogoOffsetX = 0,\n  surfaceLogoOffsetY = 0,\n  surfaceLogoOpacity = 0.95,\n  pinTopRow = true,\n  showAutoTearButton = false,\n  autoTearButtonLabel = \"Or click this button\",\n  autoTearDuration = 1.4,\n  autoStartTear = false,\n  autoStartDelay = 2,\n  showHint = true,\n  hintText = \"Drag, it tears easily\",\n  resetLabel = \"Reset\",\n  showResetButton = true,\n  onFullyTorn,\n  className,\n}: TearableRevealProps) {\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n  const rafRef = React.useRef<number | null>(null);\n\n  const pointsRef = React.useRef<ClothPoint[]>([]);\n  const constraintsRef = React.useRef<ClothConstraint[]>([]);\n  const edgeMapRef = React.useRef<Map<string, ClothConstraint>>(new Map());\n  const adjacencyRef = React.useRef<number[][]>([]);\n  const particlesRef = React.useRef<ClothParticle[]>([]);\n  const flashesRef = React.useRef<TearFlash[]>([]);\n\n  const sizeRef = React.useRef({ w: 0, h: 0 });\n  const colsRef = React.useRef(columns);\n  const rowsRef = React.useRef(rows);\n  const timeRef = React.useRef(0);\n  const firedFullTearRef = React.useRef(false);\n\n  const mouseRef = React.useRef({ x: 0, y: 0, px: 0, py: 0, down: false });\n\n  const autoTearActiveRef = React.useRef(false);\n  const autoTearProgressRef = React.useRef(0);\n  const [autoTearActive, setAutoTearActive] = React.useState(false);\n\n  const cornerTearActiveRef = React.useRef(false);\n  const cornerTearProgressRef = React.useRef(0);\n  const cornerTearPrevRef = React.useRef({ x: 0, y: 0 });\n\n  const [tornFraction, setTornFraction] = React.useState(0);\n  const [isComplete, setIsComplete] = React.useState(false);\n  const [isPressed, setIsPressed] = React.useState(false);\n\n  const liveTearThreshold = React.useRef(tearThreshold);\n  const liveBrushRadius = React.useRef(brushRadius);\n  const liveGravity = React.useRef(gravity);\n  const liveAmbientSway = React.useRef(ambientSway);\n  const livePinTopRow = React.useRef(pinTopRow);\n  const liveClothOpacity = React.useRef(clothOpacity);\n  const liveSurfaceStyle = React.useRef(surfaceStyle);\n  const liveShowParticles = React.useRef(showParticles);\n  const liveShowShadow = React.useRef(showShadow);\n  const liveTearEdgeStyle = React.useRef(tearEdgeStyle);\n  const liveOnFullyTorn = React.useRef(onFullyTorn);\n  const liveAutoTearDuration = React.useRef(autoTearDuration);\n  const liveClothColorStart = React.useRef(clothColorStart);\n  const liveClothColorEnd = React.useRef(clothColorEnd);\n\n  const liveSurfaceText = React.useRef(surfaceText);\n  const liveSurfaceTextSize = React.useRef(surfaceTextSize);\n  const liveSurfaceTextColor = React.useRef(surfaceTextColor);\n  const liveSurfaceTextFont = React.useRef(surfaceTextFont);\n  const liveSurfaceTextWeight = React.useRef(surfaceTextWeight);\n  const liveSurfaceTextAlign = React.useRef(surfaceTextAlign);\n  const liveSurfaceTextOpacity = React.useRef(surfaceTextOpacity);\n\n  const liveSurfaceLogoWidth = React.useRef(surfaceLogoWidth);\n  const liveSurfaceLogoPositionX = React.useRef(surfaceLogoPositionX);\n  const liveSurfaceLogoPositionY = React.useRef(surfaceLogoPositionY);\n  const liveSurfaceLogoOffsetX = React.useRef(surfaceLogoOffsetX);\n  const liveSurfaceLogoOffsetY = React.useRef(surfaceLogoOffsetY);\n  const liveSurfaceLogoOpacity = React.useRef(surfaceLogoOpacity);\n\n  const logoImageRef = React.useRef<HTMLImageElement | null>(null);\n\n  React.useEffect(() => {\n    liveClothColorStart.current = clothColorStart;\n  }, [clothColorStart]);\n  React.useEffect(() => {\n    liveClothColorEnd.current = clothColorEnd;\n  }, [clothColorEnd]);\n  React.useEffect(() => {\n    liveTearThreshold.current = tearThreshold;\n  }, [tearThreshold]);\n  React.useEffect(() => {\n    liveBrushRadius.current = brushRadius;\n  }, [brushRadius]);\n  React.useEffect(() => {\n    liveGravity.current = gravity;\n  }, [gravity]);\n  React.useEffect(() => {\n    liveAmbientSway.current = ambientSway;\n  }, [ambientSway]);\n  React.useEffect(() => {\n    livePinTopRow.current = pinTopRow;\n  }, [pinTopRow]);\n  React.useEffect(() => {\n    liveClothOpacity.current = clothOpacity;\n  }, [clothOpacity]);\n  React.useEffect(() => {\n    liveSurfaceStyle.current = surfaceStyle;\n  }, [surfaceStyle]);\n  React.useEffect(() => {\n    liveShowParticles.current = showParticles;\n  }, [showParticles]);\n  React.useEffect(() => {\n    liveShowShadow.current = showShadow;\n  }, [showShadow]);\n  React.useEffect(() => {\n    liveTearEdgeStyle.current = tearEdgeStyle;\n  }, [tearEdgeStyle]);\n  React.useEffect(() => {\n    liveOnFullyTorn.current = onFullyTorn;\n  }, [onFullyTorn]);\n  React.useEffect(() => {\n    liveAutoTearDuration.current = autoTearDuration;\n  }, [autoTearDuration]);\n  React.useEffect(() => {\n    liveSurfaceText.current = surfaceText;\n  }, [surfaceText]);\n  React.useEffect(() => {\n    liveSurfaceTextSize.current = surfaceTextSize;\n  }, [surfaceTextSize]);\n  React.useEffect(() => {\n    liveSurfaceTextColor.current = surfaceTextColor;\n  }, [surfaceTextColor]);\n  React.useEffect(() => {\n    liveSurfaceTextFont.current = surfaceTextFont;\n  }, [surfaceTextFont]);\n  React.useEffect(() => {\n    liveSurfaceTextWeight.current = surfaceTextWeight;\n  }, [surfaceTextWeight]);\n  React.useEffect(() => {\n    liveSurfaceTextAlign.current = surfaceTextAlign;\n  }, [surfaceTextAlign]);\n  React.useEffect(() => {\n    liveSurfaceTextOpacity.current = surfaceTextOpacity;\n  }, [surfaceTextOpacity]);\n  React.useEffect(() => {\n    liveSurfaceLogoWidth.current = surfaceLogoWidth;\n  }, [surfaceLogoWidth]);\n  React.useEffect(() => {\n    liveSurfaceLogoPositionX.current = surfaceLogoPositionX;\n  }, [surfaceLogoPositionX]);\n  React.useEffect(() => {\n    liveSurfaceLogoPositionY.current = surfaceLogoPositionY;\n  }, [surfaceLogoPositionY]);\n  React.useEffect(() => {\n    liveSurfaceLogoOffsetX.current = surfaceLogoOffsetX;\n  }, [surfaceLogoOffsetX]);\n  React.useEffect(() => {\n    liveSurfaceLogoOffsetY.current = surfaceLogoOffsetY;\n  }, [surfaceLogoOffsetY]);\n  React.useEffect(() => {\n    liveSurfaceLogoOpacity.current = surfaceLogoOpacity;\n  }, [surfaceLogoOpacity]);\n\n  React.useEffect(() => {\n    if (!surfaceLogoSrc) {\n      logoImageRef.current = null;\n      return;\n    }\n    const img = new Image();\n    img.crossOrigin = \"anonymous\";\n    img.onload = () => {\n      logoImageRef.current = img;\n    };\n    img.src = surfaceLogoSrc;\n    return () => {\n      img.onload = null;\n    };\n  }, [surfaceLogoSrc]);\n\n  const buildCloth = React.useCallback(() => {\n    const { w, h } = sizeRef.current;\n    if (w <= 0 || h <= 0) return;\n    const cols = colsRef.current;\n    const rows_ = rowsRef.current;\n    const spacingX = w / (cols - 1);\n    const spacingY = h / (rows_ - 1);\n    const points: ClothPoint[] = [];\n    for (let y = 0; y < rows_; y++) {\n      for (let x = 0; x < cols; x++) {\n        const px = x * spacingX;\n        const py = y * spacingY;\n        points.push({\n          x: px,\n          y: py,\n          ox: px,\n          oy: py,\n          pinned: livePinTopRow.current && y === 0,\n          anchored: true,\n          orphanSince: null,\n        });\n      }\n    }\n\n    const idx = (x: number, y: number) => y * cols + x;\n    const constraints: ClothConstraint[] = [];\n    const edgeMap = new Map<string, ClothConstraint>();\n    const adjacency: number[][] = points.map(() => []);\n\n    const addConstraint = (a: number, b: number) => {\n      const pa = points[a];\n      const pb = points[b];\n      const length = Math.hypot(pb.x - pa.x, pb.y - pa.y);\n      const c: ClothConstraint = { a, b, length, broken: false };\n      constraints.push(c);\n      const key = a < b ? `${a}_${b}` : `${b}_${a}`;\n      edgeMap.set(key, c);\n      const ci = constraints.length - 1;\n      adjacency[a].push(ci);\n      adjacency[b].push(ci);\n    };\n\n    for (let y = 0; y < rows_; y++) {\n      for (let x = 0; x < cols; x++) {\n        if (x < cols - 1) addConstraint(idx(x, y), idx(x + 1, y));\n        if (y < rows_ - 1) addConstraint(idx(x, y), idx(x, y + 1));\n      }\n    }\n\n    pointsRef.current = points;\n    constraintsRef.current = constraints;\n    edgeMapRef.current = edgeMap;\n    adjacencyRef.current = adjacency;\n    firedFullTearRef.current = false;\n    setTornFraction(0);\n  }, []);\n\n  const isEdgeBroken = React.useCallback((x1: number, y1: number, x2: number, y2: number) => {\n    const cols = colsRef.current;\n    const i1 = y1 * cols + x1;\n    const i2 = y2 * cols + x2;\n    const key = i1 < i2 ? `${i1}_${i2}` : `${i2}_${i1}`;\n    const c = edgeMapRef.current.get(key);\n    return c ? c.broken : true;\n  }, []);\n\n  function updateAnchoring(t: number) {\n    const points = pointsRef.current;\n    const constraints = constraintsRef.current;\n    const adjacency = adjacencyRef.current;\n\n    if (!livePinTopRow.current) {\n      for (const p of points) p.anchored = true;\n      return;\n    }\n\n    const visited = new Uint8Array(points.length);\n    const queue: number[] = [];\n    for (let i = 0; i < points.length; i++) {\n      if (points[i].pinned) {\n        visited[i] = 1;\n        queue.push(i);\n      }\n    }\n    let qi = 0;\n    while (qi < queue.length) {\n      const cur = queue[qi++];\n      for (const ci of adjacency[cur]) {\n        const c = constraints[ci];\n        if (c.broken) continue;\n        const other = c.a === cur ? c.b : c.a;\n        if (!visited[other]) {\n          visited[other] = 1;\n          queue.push(other);\n        }\n      }\n    }\n\n    for (let i = 0; i < points.length; i++) {\n      const wasAnchored = points[i].anchored;\n      const anchored = visited[i] === 1;\n      points[i].anchored = anchored;\n      if (wasAnchored && !anchored && points[i].orphanSince === null) {\n        points[i].orphanSince = t;\n      }\n      if (anchored) {\n        points[i].orphanSince = null;\n      }\n    }\n  }\n\n  function simulate(dt: number) {\n    {\n      const points = pointsRef.current;\n      const constraints = constraintsRef.current;\n      const { w, h } = sizeRef.current;\n      const friction = 0.985;\n      const g = liveGravity.current;\n      const sway = liveAmbientSway.current;\n      const m = mouseRef.current;\n      timeRef.current += dt;\n      const t = timeRef.current;\n\n      updateAnchoring(t);\n\n      for (const p of points) {\n        if (p.pinned) continue;\n        const [vx, vy] = clampMagnitude((p.x - p.ox) * friction, (p.y - p.oy) * friction, MAX_POINT_STEP);\n        p.ox = p.x;\n        p.oy = p.y;\n        const gravBoost = p.anchored ? 1 : GRAVITY_BOOST;\n        p.x += vx;\n        p.y += vy + g * gravBoost * dt * dt;\n        if (sway > 0) {\n          p.x += Math.sin(t * 0.6 + p.oy * 0.018 + p.ox * 0.01) * sway * dt;\n          p.y += Math.cos(t * 0.45 + p.ox * 0.015) * sway * dt * 0.5;\n        }\n      }\n\n      if (m.down) {\n        const radius = liveBrushRadius.current;\n        const radiusSq = radius * radius;\n        // A fast real-world swipe can jump the pointer many pixels between\n        // two rAF frames; clamp the drag force itself so it can't fling\n        // points further than the mesh can keep up with.\n        const [dragX, dragY] = clampMagnitude((m.x - m.px) * 1.3, (m.y - m.py) * 1.3, MAX_POINT_STEP);\n        for (const p of points) {\n          if (p.pinned) continue;\n          const dx = p.x - m.x;\n          const dy = p.y - m.y;\n          if (dx * dx + dy * dy < radiusSq) {\n            p.x += dragX;\n            p.y += dragY;\n          }\n        }\n\n        const dragDirX = m.x - m.px;\n        const dragDirY = m.y - m.py;\n        for (const c of constraints) {\n          if (c.broken) continue;\n          const pa = points[c.a];\n          const pb = points[c.b];\n          const mx = (pa.x + pb.x) / 2;\n          const my = (pa.y + pb.y) / 2;\n          const dSq = distToSegmentSq(mx, my, m.px, m.py, m.x, m.y);\n          if (dSq < radiusSq) {\n            c.broken = true;\n            if (liveShowParticles.current) {\n              spawnTearParticles(particlesRef.current, mx, my, dragDirX, dragDirY, c.a * 31 + c.b * 17 + t * 1000);\n              if (hash2(c.a, c.b + t) < 0.4) {\n                spawnTearFlash(flashesRef.current, mx, my, 10 + hash2(c.a + 5, c.b) * 8);\n              }\n            }\n          }\n        }\n      }\n\n      if (autoTearActiveRef.current) {\n        const duration = Math.max(0.2, liveAutoTearDuration.current);\n        autoTearProgressRef.current += dt / duration;\n        const progress = Math.min(1, autoTearProgressRef.current);\n        const frontX = progress * w;\n        for (const c of constraints) {\n          if (c.broken) continue;\n          const pa = points[c.a];\n          const pb = points[c.b];\n          const mx = (pa.x + pb.x) / 2;\n          const my = (pa.y + pb.y) / 2;\n          if (mx <= frontX) {\n            c.broken = true;\n            if (liveShowParticles.current && hash2(c.a, c.b + t) < 0.5) {\n              spawnTearParticles(particlesRef.current, mx, my, 1, -0.3, c.a * 31 + c.b * 17 + t * 1000);\n              if (hash2(c.a + 3, c.b) < 0.3) {\n                spawnTearFlash(flashesRef.current, mx, my, 8 + hash2(c.a + 5, c.b) * 6);\n              }\n            }\n          }\n        }\n        if (progress >= 1) {\n          autoTearActiveRef.current = false;\n          setAutoTearActive(false);\n        }\n      }\n\n      if (cornerTearActiveRef.current) {\n        cornerTearProgressRef.current += dt / 0.8;\n        const progress = Math.min(1, cornerTearProgressRef.current);\n        const target = cornerTearPoint(progress, w, h);\n        const prev = cornerTearPrevRef.current;\n        const radius = Math.max(18, liveBrushRadius.current * 0.6);\n        const radiusSq = radius * radius;\n        for (const c of constraints) {\n          if (c.broken) continue;\n          const pa = points[c.a];\n          const pb = points[c.b];\n          const mx = (pa.x + pb.x) / 2;\n          const my = (pa.y + pb.y) / 2;\n          if (distToSegmentSq(mx, my, prev.x, prev.y, target.x, target.y) < radiusSq) {\n            c.broken = true;\n            if (liveShowParticles.current) {\n              spawnTearParticles(particlesRef.current, mx, my, target.x - prev.x, target.y - prev.y, c.a * 31 + c.b * 17 + t * 1000);\n              if (hash2(c.a, c.b + t) < 0.4) {\n                spawnTearFlash(flashesRef.current, mx, my, 8 + hash2(c.a + 5, c.b) * 6);\n              }\n            }\n          }\n        }\n        cornerTearPrevRef.current = target;\n        if (progress >= 1) cornerTearActiveRef.current = false;\n      }\n\n      const threshold = liveTearThreshold.current;\n      for (let iter = 0; iter < 6; iter++) {\n        for (const c of constraints) {\n          if (c.broken) continue;\n          const pa = points[c.a];\n          const pb = points[c.b];\n          const dx = pb.x - pa.x;\n          const dy = pb.y - pa.y;\n          const dist = Math.hypot(dx, dy) || 0.0001;\n          const diff = (dist - c.length) / dist;\n\n          if (dist > c.length * threshold) {\n            c.broken = true;\n            const mx = (pa.x + pb.x) / 2;\n            const my = (pa.y + pb.y) / 2;\n            if (liveShowParticles.current) {\n              spawnTearParticles(particlesRef.current, mx, my, dx, dy, c.a * 31 + c.b * 17 + t * 1000);\n              if (hash2(c.a, c.b + t) < 0.4) {\n                spawnTearFlash(flashesRef.current, mx, my, 10 + hash2(c.a + 5, c.b) * 8);\n              }\n            }\n            continue;\n          }\n\n          const offX = dx * 0.5 * diff;\n          const offY = dy * 0.5 * diff;\n          if (!pa.pinned) {\n            pa.x += offX;\n            pa.y += offY;\n          }\n          if (!pb.pinned) {\n            pb.x -= offX;\n            pb.y -= offY;\n          }\n        }\n      }\n\n      for (const p of points) {\n        if (p.x < -60) p.x = -60;\n        if (p.x > w + 60) p.x = w + 60;\n        if (p.y < -60) p.y = -60;\n        if (p.y > h + 300) p.y = h + 300;\n      }\n\n      const particles = particlesRef.current;\n      let writeIdx = 0;\n      for (let i = 0; i < particles.length; i++) {\n        const p = particles[i];\n        p.life += dt;\n        if (p.life >= p.maxLife) continue;\n        p.vy += PARTICLE_GRAVITY * dt;\n        p.vx *= 0.97;\n        p.vy *= 0.99;\n        p.x += p.vx * dt;\n        p.y += p.vy * dt;\n        p.rotation += p.spin * dt;\n        particles[writeIdx++] = p;\n      }\n      particles.length = writeIdx;\n\n      const flashes = flashesRef.current;\n      let fWrite = 0;\n      for (let i = 0; i < flashes.length; i++) {\n        const f = flashes[i];\n        f.life += dt;\n        if (f.life >= f.maxLife) continue;\n        flashes[fWrite++] = f;\n      }\n      flashes.length = fWrite;\n    }\n  }\n\n  function draw() {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const { w, h } = sizeRef.current;\n    const cols = colsRef.current;\n    const rows_ = rowsRef.current;\n    const cellCols = cols - 1;\n    const cellRows = rows_ - 1;\n    const points = pointsRef.current;\n    const t = timeRef.current;\n\n    ctx.clearRect(0, 0, w, h);\n\n    const cellVisible: boolean[] = new Array(cellCols * cellRows).fill(false);\n    const idx = (x: number, y: number) => y * cols + x;\n\n    let totalCount = 0;\n    let brokenCount = 0;\n    for (let y = 0; y < cellRows; y++) {\n      for (let x = 0; x < cellCols; x++) {\n        totalCount++;\n        const edgesOk =\n          !isEdgeBroken(x, y, x + 1, y) &&\n          !isEdgeBroken(x, y, x, y + 1) &&\n          !isEdgeBroken(x + 1, y, x + 1, y + 1) &&\n          !isEdgeBroken(x, y + 1, x + 1, y + 1);\n        const p00 = points[idx(x, y)];\n        const p10 = points[idx(x + 1, y)];\n        const p01 = points[idx(x, y + 1)];\n        const p11 = points[idx(x + 1, y + 1)];\n        const alphaOk =\n          pointFadeAlpha(p00, t) > 0.02 &&\n          pointFadeAlpha(p10, t) > 0.02 &&\n          pointFadeAlpha(p01, t) > 0.02 &&\n          pointFadeAlpha(p11, t) > 0.02;\n        const visible = edgesOk && alphaOk;\n        cellVisible[y * cellCols + x] = visible;\n        if (!visible) brokenCount++;\n      }\n    }\n\n    const [r1, g1, b1] = parseColorToRgb(liveClothColorStart.current);\n    const [r2, g2, b2] = parseColorToRgb(liveClothColorEnd.current);\n    const opacity = liveClothOpacity.current;\n\n    if (liveShowShadow.current) {\n      ctx.save();\n      ctx.fillStyle = \"rgba(10,10,12,0.3)\";\n      for (let y = 0; y < cellRows; y++) {\n        for (let x = 0; x < cellCols; x++) {\n          if (!cellVisible[y * cellCols + x]) continue;\n          const isEdgeCell =\n            x === 0 ||\n            y === 0 ||\n            x === cellCols - 1 ||\n            y === cellRows - 1 ||\n            !cellVisible[(y - 1) * cellCols + x] ||\n            !cellVisible[(y + 1) * cellCols + x] ||\n            !cellVisible[y * cellCols + (x - 1)] ||\n            !cellVisible[y * cellCols + (x + 1)];\n          if (!isEdgeCell) continue;\n          const p00 = points[idx(x, y)];\n          const p10 = points[idx(x + 1, y)];\n          const p01 = points[idx(x, y + 1)];\n          const p11 = points[idx(x + 1, y + 1)];\n          ctx.beginPath();\n          ctx.moveTo(p00.x + 5, p00.y + 9);\n          ctx.lineTo(p10.x + 5, p10.y + 9);\n          ctx.lineTo(p11.x + 5, p11.y + 9);\n          ctx.lineTo(p01.x + 5, p01.y + 9);\n          ctx.closePath();\n          ctx.fill();\n        }\n      }\n      ctx.restore();\n    }\n\n    // A single continuous gradient (sampled at absolute canvas coordinates)\n    // is used for every quad, in both surface styles. This avoids per-cell\n    // quantized flat colors, which is what produced a visible \"checkerboard\"\n    // mosaic across the mesh.\n    const clothGradient = ctx.createLinearGradient(0, 0, w, h);\n    clothGradient.addColorStop(0, `rgb(${r1},${g1},${b1})`);\n    clothGradient.addColorStop(1, `rgb(${r2},${g2},${b2})`);\n\n    const smooth = liveSurfaceStyle.current === \"smooth\";\n    // All full-opacity quads are accumulated into one Path2D and filled with\n    // a single ctx.fill() call. Filling per-quad (each one slightly\n    // overdrawn via `expand` to hide subpixel seams) was compounding alpha\n    // at every shared edge, which is what read as a grid.\n    const clothPath = new Path2D();\n    const foldQuads: { x0: number; y0: number; x1: number; y1: number; x2: number; y2: number; x3: number; y3: number; alpha: number }[] = [];\n\n    for (let y = 0; y < cellRows; y++) {\n      for (let x = 0; x < cellCols; x++) {\n        if (!cellVisible[y * cellCols + x]) continue;\n        const p00 = points[idx(x, y)];\n        const p10 = points[idx(x + 1, y)];\n        const p01 = points[idx(x, y + 1)];\n        const p11 = points[idx(x + 1, y + 1)];\n\n        const a00 = pointFadeAlpha(p00, t);\n        const a10 = pointFadeAlpha(p10, t);\n        const a01 = pointFadeAlpha(p01, t);\n        const a11 = pointFadeAlpha(p11, t);\n        const cellAlpha = Math.min(a00, a10, a01, a11);\n\n        const cx = (p00.x + p10.x + p01.x + p11.x) / 4;\n        const cy = (p00.y + p10.y + p01.y + p11.y) / 4;\n        // Wider overdraw than a per-cell fill can safely use: while\n        // swinging/falling, points separate faster than a tight margin can\n        // cover, opening gaps that show the backdrop through as flicker.\n        // Safe to push higher now that quads are batched into one fill\n        // instead of each blending alpha individually.\n        const expand = 1.06;\n        const ex00x = cx + (p00.x - cx) * expand;\n        const ex00y = cy + (p00.y - cy) * expand;\n        const ex10x = cx + (p10.x - cx) * expand;\n        const ex10y = cy + (p10.y - cy) * expand;\n        const ex11x = cx + (p11.x - cx) * expand;\n        const ex11y = cy + (p11.y - cy) * expand;\n        const ex01x = cx + (p01.x - cx) * expand;\n        const ex01y = cy + (p01.y - cy) * expand;\n\n        // Two triangles instead of one 4-point polygon: under heavy\n        // deformation (fast swings, sharp folds) a quad's corners can cross\n        // over each other into a self-intersecting \"bowtie\" shape, and\n        // canvas's nonzero fill rule punches a hole exactly at that\n        // crossing point — the backdrop shows through as a small\n        // square/triangle flicker. Three points can never self-intersect,\n        // so triangles can't produce that hole no matter how the mesh\n        // contorts.\n        if (cellAlpha > 0.98) {\n          clothPath.moveTo(ex00x, ex00y);\n          clothPath.lineTo(ex10x, ex10y);\n          clothPath.lineTo(ex01x, ex01y);\n          clothPath.closePath();\n          clothPath.moveTo(ex10x, ex10y);\n          clothPath.lineTo(ex11x, ex11y);\n          clothPath.lineTo(ex01x, ex01y);\n          clothPath.closePath();\n        } else {\n          ctx.fillStyle = clothGradient;\n          ctx.globalAlpha = opacity * cellAlpha;\n          ctx.beginPath();\n          ctx.moveTo(ex00x, ex00y);\n          ctx.lineTo(ex10x, ex10y);\n          ctx.lineTo(ex01x, ex01y);\n          ctx.closePath();\n          ctx.fill();\n          ctx.beginPath();\n          ctx.moveTo(ex10x, ex10y);\n          ctx.lineTo(ex11x, ex11y);\n          ctx.lineTo(ex01x, ex01y);\n          ctx.closePath();\n          ctx.fill();\n          ctx.globalAlpha = 1;\n        }\n\n        if (!smooth) {\n          const topKey = idx(x, y) < idx(x + 1, y) ? `${idx(x, y)}_${idx(x + 1, y)}` : `${idx(x + 1, y)}_${idx(x, y)}`;\n          const leftKey = idx(x, y) < idx(x, y + 1) ? `${idx(x, y)}_${idx(x, y + 1)}` : `${idx(x, y + 1)}_${idx(x, y)}`;\n          const topC = edgeMapRef.current.get(topKey);\n          const leftC = edgeMapRef.current.get(leftKey);\n\n          let foldShade = 1;\n          if (topC && leftC) {\n            const curTop = Math.hypot(p10.x - p00.x, p10.y - p00.y);\n            const curLeft = Math.hypot(p01.x - p00.x, p01.y - p00.y);\n            const stretchTop = curTop / topC.length;\n            const stretchLeft = curLeft / leftC.length;\n            foldShade = (stretchTop + stretchLeft) / 2;\n          }\n          const brightness = Math.max(0.62, Math.min(1.35, foldShade));\n          const foldDepth = Math.max(0, 0.86 - brightness);\n          if (foldDepth > 0.015) {\n            foldQuads.push({\n              x0: ex00x,\n              y0: ex00y,\n              x1: ex10x,\n              y1: ex10y,\n              x2: ex11x,\n              y2: ex11y,\n              x3: ex01x,\n              y3: ex01y,\n              alpha: Math.min(0.5, foldDepth * 0.9) * cellAlpha,\n            });\n          }\n        }\n      }\n    }\n\n    ctx.fillStyle = clothGradient;\n    ctx.globalAlpha = opacity;\n    ctx.fill(clothPath);\n    ctx.globalAlpha = 1;\n\n    // Fold shading is a soft multiply tint over just the creased quads\n    // instead of a per-cell stroke, so it reads as fabric draping naturally\n    // rather than an outlined mesh.\n    if (foldQuads.length) {\n      ctx.save();\n      ctx.globalCompositeOperation = \"multiply\";\n      for (const q of foldQuads) {\n        ctx.fillStyle = `rgba(0,0,0,${q.alpha})`;\n        ctx.beginPath();\n        ctx.moveTo(q.x0, q.y0);\n        ctx.lineTo(q.x1, q.y1);\n        ctx.lineTo(q.x3, q.y3);\n        ctx.closePath();\n        ctx.fill();\n        ctx.beginPath();\n        ctx.moveTo(q.x1, q.y1);\n        ctx.lineTo(q.x2, q.y2);\n        ctx.lineTo(q.x3, q.y3);\n        ctx.closePath();\n        ctx.fill();\n      }\n      ctx.restore();\n    }\n\n    const edgeStyle = liveTearEdgeStyle.current;\n    ctx.save();\n    ctx.globalCompositeOperation = \"destination-out\";\n    ctx.fillStyle = \"rgba(0,0,0,1)\";\n    for (let y = 0; y < cellRows; y++) {\n      for (let x = 0; x < cellCols; x++) {\n        if (!cellVisible[y * cellCols + x]) continue;\n        const p00 = points[idx(x, y)];\n        const p10 = points[idx(x + 1, y)];\n        const p01 = points[idx(x, y + 1)];\n        const p11 = points[idx(x + 1, y + 1)];\n        const cx = (p00.x + p10.x + p01.x + p11.x) / 4;\n        const cy = (p00.y + p10.y + p01.y + p11.y) / 4;\n\n        const leftVisible = x > 0 && cellVisible[y * cellCols + (x - 1)];\n        const rightVisible = x < cellCols - 1 && cellVisible[y * cellCols + (x + 1)];\n        const topVisible = y > 0 && cellVisible[(y - 1) * cellCols + x];\n        const bottomVisible = y < cellRows - 1 && cellVisible[(y + 1) * cellCols + x];\n\n        if (!topVisible) frayEdge(ctx, p00.x, p00.y, p10.x, p10.y, x * 13 + y * 7, cx - (p00.x + p10.x) / 2, cy - (p00.y + p10.y) / 2, edgeStyle);\n        if (!bottomVisible) frayEdge(ctx, p01.x, p01.y, p11.x, p11.y, x * 17 + y * 11 + 3, cx - (p01.x + p11.x) / 2, cy - (p01.y + p11.y) / 2, edgeStyle);\n        if (!leftVisible) frayEdge(ctx, p00.x, p00.y, p01.x, p01.y, x * 5 + y * 19 + 7, cx - (p00.x + p01.x) / 2, cy - (p00.y + p01.y) / 2, edgeStyle);\n        if (!rightVisible) frayEdge(ctx, p10.x, p10.y, p11.x, p11.y, x * 23 + y * 3 + 11, cx - (p10.x + p11.x) / 2, cy - (p10.y + p11.y) / 2, edgeStyle);\n      }\n    }\n    ctx.restore();\n\n    const text = liveSurfaceText.current;\n    if (text) {\n      ctx.save();\n      ctx.beginPath();\n      for (let y = 0; y < cellRows; y++) {\n        for (let x = 0; x < cellCols; x++) {\n          if (!cellVisible[y * cellCols + x]) continue;\n          const p00 = points[idx(x, y)];\n          const p10 = points[idx(x + 1, y)];\n          const p01 = points[idx(x, y + 1)];\n          const p11 = points[idx(x + 1, y + 1)];\n          ctx.moveTo(p00.x, p00.y);\n          ctx.lineTo(p10.x, p10.y);\n          ctx.lineTo(p11.x, p11.y);\n          ctx.lineTo(p01.x, p01.y);\n          ctx.closePath();\n        }\n      }\n      ctx.clip();\n\n      const size = liveSurfaceTextSize.current;\n      const weight = liveSurfaceTextWeight.current;\n      const font = liveSurfaceTextFont.current;\n      const align = liveSurfaceTextAlign.current;\n      ctx.font = `${weight} ${size}px ${font}`;\n      ctx.fillStyle = liveSurfaceTextColor.current;\n      ctx.globalAlpha = liveSurfaceTextOpacity.current;\n      ctx.textBaseline = \"middle\";\n      ctx.textAlign = align;\n\n      const lines = text.split(\"\\n\");\n      const lineHeight = size * 1.2;\n      const totalHeight = lineHeight * lines.length;\n      const startY = h / 2 - totalHeight / 2 + lineHeight / 2;\n      const textX = align === \"left\" ? w * 0.08 : align === \"right\" ? w * 0.92 : w / 2;\n      lines.forEach((line, i) => {\n        ctx.fillText(line, textX, startY + i * lineHeight);\n      });\n      ctx.restore();\n    }\n\n    const logoImg = logoImageRef.current;\n    if (logoImg) {\n      ctx.save();\n      ctx.beginPath();\n      for (let y = 0; y < cellRows; y++) {\n        for (let x = 0; x < cellCols; x++) {\n          if (!cellVisible[y * cellCols + x]) continue;\n          const p00 = points[idx(x, y)];\n          const p10 = points[idx(x + 1, y)];\n          const p01 = points[idx(x, y + 1)];\n          const p11 = points[idx(x + 1, y + 1)];\n          ctx.moveTo(p00.x, p00.y);\n          ctx.lineTo(p10.x, p10.y);\n          ctx.lineTo(p11.x, p11.y);\n          ctx.lineTo(p01.x, p01.y);\n          ctx.closePath();\n        }\n      }\n      ctx.clip();\n\n      const logoW = liveSurfaceLogoWidth.current;\n      const aspect = logoImg.naturalHeight / (logoImg.naturalWidth || 1);\n      const drawW = (logoW / 100) * w;\n      const drawH = drawW * aspect;\n      const posX = (w - drawW) * (liveSurfaceLogoPositionX.current / 100) + liveSurfaceLogoOffsetX.current;\n      const posY = (h - drawH) * (liveSurfaceLogoPositionY.current / 100) + liveSurfaceLogoOffsetY.current;\n      ctx.globalAlpha = liveSurfaceLogoOpacity.current;\n      ctx.drawImage(logoImg, posX, posY, drawW, drawH);\n      ctx.restore();\n    }\n\n    if (liveShowParticles.current) {\n      for (const p of particlesRef.current) {\n        const lifeFrac = p.life / p.maxLife;\n        const fade = Math.max(0, 1 - lifeFrac * lifeFrac);\n        if (fade <= 0.02) continue;\n        const v = Math.min(1, Math.max(0, p.y / h));\n        const rr = Math.round(r1 + (r2 - r1) * v);\n        const gg = Math.round(g1 + (g2 - g1) * v);\n        const bb = Math.round(b1 + (b2 - b1) * v);\n        ctx.save();\n        ctx.translate(p.x, p.y);\n        ctx.rotate(p.rotation);\n        ctx.fillStyle = `rgba(${rr},${gg},${bb},${fade * 0.9})`;\n        ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);\n        ctx.restore();\n      }\n    }\n\n    for (const f of flashesRef.current) {\n      const lifeFrac = f.life / f.maxLife;\n      const fade = Math.max(0, 1 - lifeFrac) * 0.85;\n      if (fade <= 0.02) continue;\n      const radius = f.radius * (0.4 + lifeFrac * 0.9);\n      ctx.beginPath();\n      ctx.arc(f.x, f.y, radius, 0, Math.PI * 2);\n      ctx.fillStyle = `rgba(255,255,255,${fade})`;\n      ctx.fill();\n    }\n\n    const fraction = totalCount > 0 ? brokenCount / totalCount : 0;\n    setTornFraction(fraction);\n    if (fraction > 0.92 && !firedFullTearRef.current) {\n      firedFullTearRef.current = true;\n      setIsComplete(true);\n      liveOnFullyTorn.current?.();\n    }\n  }\n\n  function loop() {\n    simulate(1 / 60);\n    draw();\n    rafRef.current = requestAnimationFrame(loop);\n  }\n\n  const resizeCanvas = React.useCallback(() => {\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) return;\n    const rect = container.getBoundingClientRect();\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    sizeRef.current = { w: rect.width, h: rect.height };\n    canvas.width = Math.max(1, Math.round(rect.width * dpr));\n    canvas.height = Math.max(1, Math.round(rect.height * dpr));\n    canvas.style.width = `${rect.width}px`;\n    canvas.style.height = `${rect.height}px`;\n    const ctx = canvas.getContext(\"2d\");\n    if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  }, []);\n\n  const resetCloth = React.useCallback(() => {\n    resizeCanvas();\n    colsRef.current = Math.max(4, Math.round(columns));\n    rowsRef.current = Math.max(4, Math.round(rows));\n    buildCloth();\n    particlesRef.current = [];\n    flashesRef.current = [];\n    setIsComplete(false);\n    autoTearActiveRef.current = false;\n    autoTearProgressRef.current = 0;\n    setAutoTearActive(false);\n    cornerTearActiveRef.current = false;\n    cornerTearProgressRef.current = 0;\n  }, [buildCloth, columns, resizeCanvas, rows]);\n\n  const startAutoTear = React.useCallback(() => {\n    autoTearProgressRef.current = 0;\n    autoTearActiveRef.current = true;\n    setAutoTearActive(true);\n  }, []);\n\n  React.useEffect(() => {\n    colsRef.current = Math.max(4, Math.round(columns));\n    rowsRef.current = Math.max(4, Math.round(rows));\n    resizeCanvas();\n\n    const initTimer = setTimeout(() => {\n      buildCloth();\n      rafRef.current = requestAnimationFrame(loop);\n    }, 0);\n\n    const container = containerRef.current;\n    let observer: ResizeObserver | null = null;\n    if (container && typeof ResizeObserver !== \"undefined\") {\n      observer = new ResizeObserver(() => {\n        resizeCanvas();\n        buildCloth();\n      });\n      observer.observe(container);\n    }\n\n    return () => {\n      clearTimeout(initTimer);\n      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n      observer?.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [columns, rows, pinTopRow]);\n\n  React.useEffect(() => {\n    if (!autoStartTear) return;\n    const timer = setTimeout(() => {\n      cornerTearProgressRef.current = 0;\n      cornerTearPrevRef.current = cornerTearPoint(0, sizeRef.current.w, sizeRef.current.h);\n      cornerTearActiveRef.current = true;\n    }, autoStartDelay * 1000);\n    return () => clearTimeout(timer);\n  }, [autoStartTear, autoStartDelay]);\n\n  const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {\n    const canvas = canvasRef.current;\n    if (!canvas) return { x: 0, y: 0 };\n    const rect = canvas.getBoundingClientRect();\n    return { x: e.clientX - rect.left, y: e.clientY - rect.top };\n  };\n\n  const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {\n    const pos = getPos(e);\n    mouseRef.current = { x: pos.x, y: pos.y, px: pos.x, py: pos.y, down: true };\n    setIsPressed(true);\n    (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);\n  };\n\n  const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {\n    const pos = getPos(e);\n    const m = mouseRef.current;\n    m.px = m.x;\n    m.py = m.y;\n    m.x = pos.x;\n    m.y = pos.y;\n  };\n\n  const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {\n    mouseRef.current.down = false;\n    setIsPressed(false);\n    (e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\"relative w-full h-full overflow-hidden\", className)}\n      style={{ backgroundColor, pointerEvents: isComplete ? \"none\" : undefined }}\n    >\n      {backgroundSrc && (\n        <img\n          src={backgroundSrc}\n          alt={backgroundAlt}\n          draggable={false}\n          className=\"absolute inset-0 h-full w-full\"\n          style={{\n            objectFit: backgroundFit,\n            objectPosition: `${backgroundPositionX}% ${backgroundPositionY}%`,\n            userSelect: \"none\",\n          }}\n        />\n      )}\n\n      {!isComplete && (\n        <canvas\n          ref={canvasRef}\n          className=\"absolute inset-0 h-full w-full touch-none\"\n          style={{ cursor: isPressed ? SCISSORS_CLOSED_CURSOR : SCISSORS_OPEN_CURSOR }}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={handlePointerUp}\n          onPointerCancel={handlePointerUp}\n          onPointerLeave={handlePointerUp}\n        />\n      )}\n\n      {showHint && tornFraction < 0.04 && !isComplete && (\n        <div\n          className=\"pointer-events-none absolute bottom-4 left-4 rounded-[8px] px-3 py-1.5 text-[13px]\"\n          style={{ fontFamily: \"Helvetica Neue, sans-serif\", color: \"#0d0d0d\", background: \"#f4f3f1\" }}\n        >\n          {hintText}\n        </div>\n      )}\n\n      {showAutoTearButton && !autoTearActive && !isComplete && (\n        <button\n          type=\"button\"\n          onClick={startAutoTear}\n          className=\"absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full px-[22px] py-2.5 text-sm font-semibold\"\n          style={{ fontFamily: \"Helvetica Neue, sans-serif\", color: \"#f4f3f1\", background: \"#0d0d0d\" }}\n        >\n          {autoTearButtonLabel}\n        </button>\n      )}\n\n      {showResetButton && !isComplete && (\n        <button\n          type=\"button\"\n          onClick={resetCloth}\n          className=\"absolute bottom-4 right-4 rounded-[8px] px-3.5 py-1.5 text-[13px]\"\n          style={{ fontFamily: \"Helvetica Neue, sans-serif\", color: \"#f4f3f1\", background: \"#0d0d0d\" }}\n        >\n          {resetLabel}\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport default TearableReveal;\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}