{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slump-mould-drape",
  "title": "Slump Mould Drape",
  "description": "A content-reveal/settle moment built on kiln-slumping in warm glass work: a flat profile sags into a mould's contour under gravity, the centre drooping first and the drape spreading outward toward the edges over several seconds before holding, exactly conformed to the mould.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/slump-mould-drape/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SlumpMouldDrape — a content-reveal/settle moment built on kiln-slumping in\n// warm glass work: a flat glass blank heated to its slumping range sags\n// purely under gravity over a mould, the centre drooping first (least\n// supported) while the drape spreads outward toward the rim as heat and time\n// progress. It's a slow, staggered viscous deformation, never a snap or a\n// bounce — the whole point is the visible LAG between the centre and the\n// edges, not a uniformly eased reveal.\n//\n// The profile is a pure function of elapsed time: N=32 sample points, each\n// following y_i(t) = mould(x_i) * (1 - exp(-(t - delay_i) / TAU)), where\n// delay_i grows with distance from centre (0 at centre, DELAY_EDGE_S at the\n// rim). No physics state carries frame to frame, which is what makes the\n// reduced-motion freeze frame a single function call rather than a replay.\n// ---------------------------------------------------------------------------\n\nconst N_POINTS = 32;\nconst DRAPE_S = 4.5; // flat -> fully conformed\nconst HOLD_S = 1.5; // fully slumped, motionless, exact conformity\nconst RESET_S = 0.4; // lifts back to flat (a fresh blank going into the kiln)\nconst CYCLE_S = DRAPE_S + HOLD_S + RESET_S; // 6.4s, repeats indefinitely\nconst TAU_S = 1.0;\nconst DELAY_EDGE_S = 1.8; // delay at the outermost sample point, 0 at centre\nconst MAX_DROOP_FRAC = 0.3; // of the container's smaller dimension\nconst STATIC_TIME_S = 2.5; // reduced-motion freeze: centre conformed, edges still lagging\n\nfunction mouldDepth(x: number, depth: number): number {\n  // shallow-bowl reference curve: 0 at both rims, depth at centre\n  return (depth * (1 - Math.cos(2 * Math.PI * x))) / 2;\n}\n\nfunction delayAt(x: number): number {\n  return DELAY_EDGE_S * (Math.abs(x - 0.5) / 0.5);\n}\n\n// the profile's y-offset (px, downward-positive) for sample x at drape-phase\n// elapsed time; only valid while phaseT < DRAPE_S.\nfunction draping(x: number, elapsed: number, depth: number): number {\n  const delay = delayAt(x);\n  if (elapsed <= delay) return 0;\n  return mouldDepth(x, depth) * (1 - Math.exp(-(elapsed - delay) / TAU_S));\n}\n\nexport interface SlumpMouldDrapeProps {\n  /** ambient idle loop (drape -> hold -> reset -> repeat) vs. a one-shot\n   * reveal that plays once and holds its settled state forever. @default true */\n  loop?: boolean;\n  className?: string;\n}\n\nexport function SlumpMouldDrape({\n  loop = true,\n  className = \"\",\n}: SlumpMouldDrapeProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    // -- token-derived ink, read at mount and re-derived on theme flip ------\n    let fg = \"rgb(237,237,237)\";\n    let border = \"rgba(237,237,237,0.11)\";\n    let dark = true;\n    const readTokens = () => {\n      dark = document.documentElement.classList.contains(\"dark\");\n      const cs = getComputedStyle(document.documentElement);\n      fg = cs.getPropertyValue(\"--foreground\").trim() || fg;\n      border = cs.getPropertyValue(\"--border\").trim() || border;\n    };\n    readTokens();\n\n    // -- hot-path state: locals only, never React state ---------------------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    let paused = false;\n    let reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let baselineY = 0;\n    let depth = 0;\n    let lineWidth = 1.4;\n    let mouldLineWidth = 1;\n\n    const xAt = (i: number) => i / (N_POINTS - 1);\n\n    // profile y-offset (downward-positive, px) for sample index i at a given\n    // absolute elapsed time within one cycle (or clamped past the hold end\n    // when !loop).\n    const profileOffset = (i: number, phaseT: number) => {\n      const x = xAt(i);\n      if (phaseT < DRAPE_S) return draping(x, phaseT, depth);\n      if (phaseT < DRAPE_S + HOLD_S) return mouldDepth(x, depth); // exact conformity\n      const resetT = phaseT - DRAPE_S - HOLD_S;\n      const frac = Math.min(1, resetT / RESET_S);\n      const eased = frac * frac * (3 - 2 * frac); // smoothstep lift\n      return mouldDepth(x, depth) * (1 - eased);\n    };\n\n    const drawPolyline = (getY: (i: number) => number, useCurve: boolean) => {\n      ctx.beginPath();\n      for (let i = 0; i < N_POINTS; i++) {\n        const px = (xAt(i) * w);\n        const py = getY(i);\n        if (i === 0) {\n          ctx.moveTo(px, py);\n        } else if (useCurve) {\n          const prevX = xAt(i - 1) * w;\n          const prevY = getY(i - 1);\n          const midX = (prevX + px) / 2;\n          const midY = (prevY + py) / 2;\n          ctx.quadraticCurveTo(prevX, prevY, midX, midY);\n        } else {\n          ctx.lineTo(px, py);\n        }\n      }\n      // finish the last curve segment to the final point\n      if (useCurve) {\n        ctx.lineTo(xAt(N_POINTS - 1) * w, getY(N_POINTS - 1));\n      }\n      ctx.stroke();\n    };\n\n    const draw = (phaseT: number) => {\n      if (!sized) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n\n      // mould reference contour — a fixed, non-load-bearing target line the\n      // profile drapes toward; --border only, never a fill or the \"arrived\"\n      // signal itself (that's read from geometric conformity, not colour).\n      ctx.strokeStyle = border;\n      ctx.lineWidth = mouldLineWidth;\n      ctx.globalAlpha = 1;\n      drawPolyline((i) => baselineY + mouldDepth(xAt(i), depth), true);\n\n      // glass profile\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = lineWidth;\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      drawPolyline((i) => baselineY + profileOffset(i, phaseT), true);\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      if (w < 2 || h < 2) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      const minDim = Math.min(w, h);\n      depth = MAX_DROOP_FRAC * minDim;\n      baselineY = h * 0.5 - depth * 0.5;\n      lineWidth = Math.max(1.4, 0.007 * minDim);\n      // light theme's --border reads at ~1.1:1 — a heavier stroke (same\n      // token, not a different one) keeps the mould contour legible as a\n      // target line rather than a different-weight rule change per theme.\n      mouldLineWidth = Math.max(1, 0.0045 * minDim) * (dark ? 1 : 1.6);\n      sized = true;\n    };\n\n    const renderStatic = () => {\n      draw(STATIC_TIME_S);\n    };\n\n    let raf = 0;\n    let start = 0;\n    const loopFrame = (now: number) => {\n      if (!start) start = now;\n      const elapsed = (now - start) / 1000;\n      const phaseT = loop\n        ? elapsed % CYCLE_S\n        : Math.min(elapsed, DRAPE_S + HOLD_S);\n      draw(phaseT);\n      if (!paused) raf = requestAnimationFrame(loopFrame);\n    };\n\n    const stopLoop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const startLoop = () => {\n      stopLoop();\n      if (paused || !sized) return;\n      if (reduced) {\n        renderStatic();\n        return;\n      }\n      start = 0;\n      raf = requestAnimationFrame(loopFrame);\n    };\n\n    resize();\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      if (reduced) renderStatic();\n    });\n    ro.observe(root);\n\n    const onThemeChange = () => {\n      readTokens();\n      resize(); // mould stroke weight depends on the dark/light read above\n      if (reduced) draw(STATIC_TIME_S);\n    };\n    const mo = new MutationObserver(onThemeChange);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\"],\n    });\n    const colorScheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    colorScheme.addEventListener(\"change\", onThemeChange);\n\n    const reducedMq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onReducedChange = () => {\n      reduced = reducedMq.matches;\n      startLoop();\n    };\n    reducedMq.addEventListener(\"change\", onReducedChange);\n\n    const onVisibility = () => {\n      paused = document.hidden;\n      startLoop();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    let io: IntersectionObserver | undefined;\n    if (\"IntersectionObserver\" in window) {\n      io = new IntersectionObserver((entries) => {\n        const entry = entries[0];\n        if (!entry) return;\n        paused = !entry.isIntersecting || document.hidden;\n        startLoop();\n      });\n      io.observe(root);\n    }\n\n    startLoop();\n\n    return () => {\n      stopLoop();\n      ro.disconnect();\n      mo.disconnect();\n      colorScheme.removeEventListener(\"change\", onThemeChange);\n      reducedMq.removeEventListener(\"change\", onReducedChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      io?.disconnect();\n    };\n  }, [loop]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative h-full w-full overflow-hidden ${className}`}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"block h-full w-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/slump-mould-drape.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "reveal",
      "settle",
      "glass",
      "kiln",
      "lab-instrument",
      "mono",
      "ambient",
      "generative"
    ],
    "instruction": "Build <SlumpMouldDrape loop? className?> as a card-scale 2D-canvas content-reveal mechanic modelled on kiln-slumping: a flat glass profile sags under gravity into a mould's contour, centre first, edges last. THE PROFILE IS A PURE FUNCTION OF TIME: N_POINTS=32 samples span the container width; each sample's y-offset is y_i(t) = mould(x_i) * (1 - exp(-(t - delay_i)/TAU_S)) for t in the DRAPE_S=4.5s drape phase, where mould(x) = depth * (1 - cos(2*pi*x)) / 2 (a shallow bowl, 0 at both rims, depth=MAX_DROOP_FRAC=0.3 * containerMinDim at centre), delay_i = DELAY_EDGE_S=1.8s * (|x_i - 0.5| / 0.5) (0 at centre, 1.8s at the rim), and TAU_S=1.0. Nothing here carries state frame-to-frame — the whole animation is draw(phaseT) called with whatever phaseT the clock produces, which is exactly why the reduced-motion freeze frame is a single function call at STATIC_TIME_S=2.5 rather than a replay loop. PHASES: 0..DRAPE_S is the staggered drape above; DRAPE_S..DRAPE_S+HOLD_S=1.5s snaps every sample to mould(x_i) EXACTLY (full conformity, motionless — the asymptotic exp() value never quite reaches 1, so this snap is what makes the hold read as \"matches the mould\" rather than \"almost there\"); DRAPE_S+HOLD_S..CYCLE_S=6.4s (only when loop=true) lifts every sample back toward 0 over RESET_S=0.4s with a smoothstep ease (frac*frac*(3-2*frac)) — fast relative to the drape, since a real reset is a discrete offstage event (swapping blanks), not a slow process. loop=true (default) repeats the cycle indefinitely for the ambient/idle use; loop=false clamps phaseT at DRAPE_S+HOLD_S forever once reached, for a one-shot reveal (e.g. content finishing loading) whose settled state must persist rather than looping into a reset. RENDER: both the mould reference contour (drawn first, fixed, never animated) and the glass profile are stroked as a smoothed polyline — quadraticCurveTo through each pair of sample points' midpoint — never raw per-point dots, so the sheet reads as one continuous surface. TOKENS: profile line is --foreground; mould contour is --border only, and specifically NEVER the \"arrived\" signal (that reads from the profile's geometric conformity to the mould line, not colour) and never --ns-accent. Because --border is ~1.1:1 contrast in light theme, the mould's stroke WEIGHT (not a different token) is multiplied by 1.6x when documentElement lacks the dark class, so the target line the profile drapes toward stays legible in the harder theme. HOST: ResizeObserver on the root recomputes width/height/dpr(capped 2)/depth/baseline/stroke-weights (and the dark/light read they depend on) — never window.resize; IntersectionObserver(threshold 0) and visibilitychange both pause the single rAF loop; unmount cancels the frame and disconnects every observer/listener. Tokens are read via getComputedStyle(document.documentElement) plus documentElement.classList.contains('dark') at mount, before any paint, and re-read on a MutationObserver watching class/data-theme plus a prefers-color-scheme change listener. prefers-reduced-motion (matchMedia, live via a change listener) replaces the rAF loop with one draw(STATIC_TIME_S) call — centre points ~92% conformed, outer points ~50% conformed, the frame that shows the mechanic's signature centre-first/edges-last asymmetry that neither the flat start nor the fully-settled hold can show alone."
  },
  "type": "registry:ui"
}