{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "extrusion-die-cut",
  "title": "Extrusion Die Cut",
  "description": "A card-scale processing/loader widget modelled on pasta die-face cutting: a textured rope continuously extrudes from a die aperture and a guillotine blade sweeps across it every ~2.2s, severing off a segment that falls and settles into a loose floor stack. The loop is unbounded and shows something always extruding and something periodically finished, without a single digit or bar ever drawn.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/extrusion-die-cut/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ExtrusionDieCut — a card-scale processing/loader widget modelled on pasta\n// die-face cutting: a textured rope is continuously forced out of a die\n// aperture under constant pressure, and a rotating guillotine blade sweeps\n// across the die face at a fixed interval, severing the rope into a falling\n// segment that drops into a settling stack. Two independent clocks: rope\n// growth is continuous, the cut is discrete and periodic — the READ is\n// \"something is always extruding AND something is periodically finished\".\n//\n// Rope length between cuts equals CUT_SPAN (die-to-blade distance); advance\n// rate is derived FROM that span and the target cadence (CUT_INTERVAL_S),\n// not the other way round, so span and interval never drift out of sync with\n// each other on resize. Real numbers, tuned for card scale rather than lifted\n// 1:1 from an industrial line: a cut fires every ~2.2s (+/-8% jitter, never\n// perfectly metronomic — real die-cutters hunt slightly), each stroke takes\n// 220ms and is a visible sweep, never a blink (round-9's \"transition must\n// show departure and arrival\" rule). Rope surface texture is a die-drag\n// striation: a stable hash of the rope-local x-coordinate, not per-frame\n// noise, so it reads as a fixed material property rather than shimmer.\n//\n// A cut segment falls under a light constant acceleration and settles into a\n// loose floor stack; once six segments have accumulated the oldest fades out\n// so the stack never overflows the card. Colour is read once via\n// getComputedStyle(document.documentElement) with no literal fallback, and\n// nothing paints until both --foreground and --background resolve — the\n// mount loop retries on the next rAF until then (the same guard as every\n// other token-driven canvas in this registry, and the exact failure mode the\n// builder brief calls \"no paint before the first token read\").\n// ---------------------------------------------------------------------------\n\nconst CUT_INTERVAL_S = 2.2; // mean seconds between cuts\nconst CUT_JITTER = 0.08; // +/-8% on that interval, re-rolled per segment\nconst CUT_STROKE_S = 0.22; // blade sweep duration — a travel, not a blink\nconst STRIATION_PITCH_PX = 3; // die-drag ridge pitch along the rope\nconst STRIATION_VARIANCE = 0.06; // +/-6% luminance ripple within the rope's own tone\nconst GRAVITY_PX_S2 = 40; // segment fall acceleration\nconst MAX_STACK = 6;\nconst STACK_FADE_S = 0.4;\nconst DPR_CAP = 2;\n\ninterface FallingSegment {\n  id: number;\n  x: number; // left edge, css px\n  len: number;\n  y: number; // current top-of-rope y while falling; settles at floorY\n  vy: number;\n  settled: boolean;\n  settledAt: number; // performance.now() ms, for fade timing once evicted\n  fadeStart: number | null;\n}\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n}\n\nfunction readTokens(): Tokens | null {\n  if (typeof document === \"undefined\") return null;\n  const cs = getComputedStyle(document.documentElement);\n  const fg = cs.getPropertyValue(\"--foreground\").trim();\n  const bg = cs.getPropertyValue(\"--background\").trim();\n  if (!fg || !bg) return null; // stylesheet not applied yet — paint nothing\n  return { fg, bg };\n}\n\nfunction hash1(i: number): number {\n  const h = Math.sin(i * 12.9898 + 4.1414) * 43758.5453;\n  return h - Math.floor(h);\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nexport interface ExtrusionDieCutProps {\n  /** small mono label above the widget */\n  label?: string;\n  /** 0..1 determinate progress. Scales how much of the span extrudes before\n   * a cut fires; the cut cadence itself never changes. Omit for ambient use. */\n  progress?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function ExtrusionDieCut({\n  label = \"Processing\",\n  progress,\n  className = \"\",\n}: ExtrusionDieCutProps) {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const progressRef = useRef(progress);\n  progressRef.current = progress;\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let disposed = false;\n    let tokens: Tokens | null = null;\n    let dpr = 1;\n    let w = 0;\n    let h = 0;\n    let sized = false;\n    let visible = true;\n\n    let raf = 0;\n    let tokenWaitRaf = 0;\n    let last = 0;\n    let staticFrame = false;\n\n    // -- sim state -----------------------------------------------------------\n    let ropeLen = 0; // current extruded length, css px, resets to 0 after a cut\n    let cutSpan = 200; // die-to-blade distance, recomputed on resize\n    let advancePxS = 90; // derived from cutSpan / CUT_INTERVAL_S, jittered per segment\n    let strokeT: number | null = null; // 0..CUT_STROKE_S while a blade sweep is in flight\n    let pendingSegment: { len: number } | null = null; // rope length at the moment a stroke starts\n    let segments: FallingSegment[] = [];\n    let nextId = 0;\n    let rand = Math.random;\n\n    const rollAdvance = () => {\n      const jitterMul = 1 + (rand() * 2 - 1) * CUT_JITTER;\n      const prog = progressRef.current;\n      const progMul = prog == null ? 1 : Math.max(0.3, Math.min(1.7, 0.3 + prog * 1.4));\n      advancePxS = (cutSpan / CUT_INTERVAL_S) * jitterMul * progMul;\n    };\n\n    const layout = () => {\n      const minDim = Math.min(w, h);\n      const dieX = w * 0.08;\n      const ropeY = h * 0.3;\n      const ropeThickness = Math.max(3, minDim * 0.045);\n      const bladeX = dieX + cutSpan;\n      const floorY = h * 0.88;\n      return { dieX, ropeY, ropeThickness, bladeX, floorY, minDim };\n    };\n\n    const fitCanvas = () => {\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const resize = () => {\n      const rect = wrap.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);\n      w = rect.width;\n      h = rect.height;\n      cutSpan = w * 0.62;\n      fitCanvas();\n      sized = true;\n    };\n\n    // -- rope striation: a stable hash of the rope-LOCAL x-coordinate (not\n    // world/frame time), so texture reads as a fixed material property that\n    // scrolls with growth rather than shimmering per frame. -----------------\n    const drawRope = (fg: string, len: number, dieX: number, ropeY: number, thickness: number) => {\n      if (len <= 0) return;\n      const cells = Math.max(1, Math.ceil(len / STRIATION_PITCH_PX));\n      for (let i = 0; i < cells; i++) {\n        const x0 = dieX + i * STRIATION_PITCH_PX;\n        const cw = Math.min(STRIATION_PITCH_PX, dieX + len - x0);\n        if (cw <= 0) break;\n        const ripple = (hash1(i) * 2 - 1) * STRIATION_VARIANCE;\n        ctx.globalAlpha = 0.62 + ripple;\n        ctx.fillStyle = fg;\n        ctx.fillRect(x0, ropeY - thickness / 2, cw, thickness);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const drawDie = (fg: string, dieX: number, ropeY: number, thickness: number) => {\n      ctx.globalAlpha = 0.9;\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = Math.max(1.5, thickness * 0.18);\n      ctx.beginPath();\n      ctx.moveTo(dieX, ropeY - thickness * 1.4);\n      ctx.lineTo(dieX, ropeY + thickness * 1.4);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    };\n\n    const drawBlade = (fg: string, bladeX: number, ropeY: number, thickness: number, sweepFrac: number) => {\n      // travels top-to-bottom across the rope over CUT_STROKE_S — a visible\n      // stroke, matching round-9's \"transition must show departure and\n      // arrival, not a blink\".\n      const spanTop = ropeY - thickness * 2.6;\n      const spanBottom = ropeY + thickness * 2.6;\n      const bladeY = spanTop + (spanBottom - spanTop) * Math.min(1, sweepFrac);\n      ctx.strokeStyle = fg;\n      ctx.globalAlpha = 0.85;\n      ctx.lineWidth = 1.5;\n      ctx.beginPath();\n      ctx.moveTo(bladeX, spanTop);\n      ctx.lineTo(bladeX, bladeY);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    };\n\n    const drawSegments = (fg: string, ropeY: number, thickness: number, nowMs: number) => {\n      for (const seg of segments) {\n        let alpha = 0.62;\n        if (seg.fadeStart != null) {\n          const age = (nowMs - seg.fadeStart) / 1000;\n          alpha *= Math.max(0, 1 - age / STACK_FADE_S);\n          if (alpha <= 0) continue;\n        }\n        ctx.globalAlpha = alpha;\n        ctx.fillStyle = fg;\n        ctx.fillRect(seg.x, seg.y - thickness / 2, seg.len, thickness);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const spawnSegment = (len: number, dieX: number, ropeY: number, floorY: number, gapFromBlade: number) => {\n      // segment's left edge starts at the die; right edge sits gapFromBlade\n      // px short of the blade — a visible severed gap, not a flush cut.\n      const seg: FallingSegment = {\n        id: nextId++,\n        x: dieX,\n        len: Math.max(1, len - gapFromBlade),\n        y: ropeY,\n        vy: 0,\n        settled: false,\n        settledAt: 0,\n        fadeStart: null,\n      };\n      segments.push(seg);\n    };\n\n    const settleStack = (floorY: number, thickness: number) => {\n      const settled = segments.filter((s) => s.settled && s.fadeStart == null);\n      if (settled.length > MAX_STACK) {\n        const excess = settled.length - MAX_STACK;\n        for (let i = 0; i < excess; i++) settled[i]!.fadeStart = performance.now();\n      }\n      segments = segments.filter((s) => s.fadeStart == null || performance.now() - s.fadeStart < STACK_FADE_S * 1000);\n      // loose stagger: each new settle stacks slightly left of the previous\n      const rows = segments.filter((s) => s.settled);\n      rows.forEach((s, i) => {\n        s.y = floorY - i * (thickness * 0.9);\n        s.x = 0 + (i % 3) * 6;\n      });\n    };\n\n    const step = (dt: number, nowMs: number) => {\n      const { dieX, ropeY, ropeThickness, bladeX, floorY } = layout();\n\n      if (strokeT == null) {\n        ropeLen = Math.min(cutSpan, ropeLen + advancePxS * dt);\n        if (ropeLen >= cutSpan) {\n          strokeT = 0;\n          pendingSegment = { len: ropeLen };\n        }\n      } else {\n        strokeT += dt;\n        if (strokeT >= CUT_STROKE_S) {\n          if (pendingSegment) {\n            spawnSegment(pendingSegment.len, dieX, ropeY, floorY, 4);\n            pendingSegment = null;\n          }\n          strokeT = null;\n          ropeLen = 0;\n          rollAdvance();\n        }\n      }\n\n      for (const seg of segments) {\n        if (seg.settled) continue;\n        seg.vy += GRAVITY_PX_S2 * dt;\n        seg.y += seg.vy * dt;\n        if (seg.y >= floorY) {\n          seg.y = floorY;\n          seg.settled = true;\n        }\n      }\n      settleStack(floorY, ropeThickness);\n    };\n\n    const draw = (nowMs: number) => {\n      if (!tokens || !sized) return;\n      const { fg } = tokens;\n      const { dieX, ropeY, ropeThickness, bladeX, floorY } = layout();\n      ctx.clearRect(0, 0, w, h);\n\n      // floor line for the settling tray\n      ctx.globalAlpha = 0.12;\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(dieX * 0.3, floorY + ropeThickness);\n      ctx.lineTo(w * 0.96, floorY + ropeThickness);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n\n      drawSegments(fg, ropeY, ropeThickness, nowMs);\n      drawDie(fg, dieX, ropeY, ropeThickness);\n      drawRope(fg, ropeLen, dieX, ropeY, ropeThickness);\n      if (strokeT != null) {\n        drawBlade(fg, dieX + cutSpan, ropeY, ropeThickness, strokeT / CUT_STROKE_S);\n      }\n    };\n\n    // -- reduced-motion freeze: constructed directly rather than replayed,\n    // deliberately mid-cut so the die texture, the blade in travel AND a\n    // just-severed segment are all visible in one frame — the single most\n    // information-dense state, matching the spec's non-t0 freeze rule. -----\n    const buildStaticFrame = () => {\n      rand = mulberry32(20260827);\n      const { dieX, ropeY, floorY } = layout();\n      ropeLen = cutSpan - 4; // severed stub short of the blade by the same 4px gap\n      strokeT = CUT_STROKE_S * 0.6; // blade 60% through its stroke\n      pendingSegment = null;\n      segments = [];\n      nextId = 0;\n      // one freshly-cut segment sitting just past the die with its 4px gap,\n      // not yet fallen\n      spawnSegment(cutSpan, dieX, ropeY, floorY, 4);\n      segments[0]!.y = ropeY;\n      // a small settled stack behind it, so the frame also shows history\n      for (let i = 0; i < 3; i++) {\n        spawnSegment(cutSpan * (0.7 + rand() * 0.2), dieX, ropeY, floorY, 4 + rand() * 20);\n        const s = segments[segments.length - 1]!;\n        s.settled = true;\n      }\n      settleStack(floorY, layout().ropeThickness);\n    };\n\n    const loop = (nowMs: number) => {\n      if (disposed) return;\n      if (!visible) {\n        raf = 0; // re-armed by the IntersectionObserver on re-entry\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n      if (!sized || !tokens) return;\n      const dt = Math.min(0.05, last === 0 ? 1 / 60 : (nowMs - last) / 1000);\n      last = nowMs;\n      step(dt, nowMs);\n      draw(nowMs);\n    };\n\n    let started = false;\n    const kick = () => {\n      if (started || disposed || !tokens || !sized) return;\n      started = true;\n      if (reduced) {\n        staticFrame = true;\n        buildStaticFrame();\n        draw(performance.now());\n        return; // no rAF loop, no timers, no observers driving motion\n      }\n      rollAdvance();\n      raf = requestAnimationFrame(loop);\n    };\n\n    const boot = () => {\n      if (disposed) return;\n      tokens = readTokens();\n      if (!tokens) {\n        tokenWaitRaf = requestAnimationFrame(boot);\n        return;\n      }\n      resize();\n      kick();\n    };\n\n    const ro = new ResizeObserver(() => {\n      if (!tokens) return;\n      resize();\n      if (staticFrame) {\n        buildStaticFrame();\n        draw(performance.now());\n      }\n      kick();\n    });\n    ro.observe(wrap);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      if (!tokens) return;\n      if (staticFrame) draw(performance.now());\n      else if (sized) draw(performance.now());\n      kick();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible && !reduced && tokens && !raf) {\n        tokens = readTokens() ?? tokens; // pick up a theme flip that happened while hidden\n        resize();\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(wrap);\n\n    boot();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tokenWaitRaf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative w-full max-w-sm overflow-hidden rounded-md border border-border bg-surface p-4 ${className}`}\n    >\n      <p className=\"mb-3 font-mono text-[11px] tracking-widest text-ns-muted\">{label}</p>\n      <div className=\"relative w-full\" style={{ aspectRatio: \"16 / 9\" }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 h-full w-full\"\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/extrusion-die-cut.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "progress",
      "canvas",
      "extrusion",
      "food-process",
      "mechanical",
      "ambient",
      "generative"
    ],
    "instruction": "`<ExtrusionDieCut label? progress? className? />` renders a card-scale canvas simulating pasta die-face cutting. TWO CLOCKS, deliberately decoupled: rope growth (`ropeLen`) advances continuously every frame by `advancePxS * dt` up to a fixed `cutSpan` (die-to-blade distance, `containerWidth * 0.62`, recomputed on every resize); once `ropeLen` reaches `cutSpan` a blade stroke begins (`strokeT` counts 0 to `CUT_STROKE_S = 0.22s`) — during the stroke rope growth is paused, and the blade is drawn as a vertical line traveling top-to-bottom across the rope's span over that 220ms, a genuine sweep with a visible start and end position each frame, never an instant swap (the round-9 rule that a discrete event's transition must show departure and arrival). When the stroke completes, a `FallingSegment` is spawned at the die with length `ropeLen - 4` (a 4px severed gap short of the blade, so the cut reads as a genuine separation rather than a flush join), `ropeLen` resets to 0, and `advancePxS` is re-rolled from `cutSpan / CUT_INTERVAL_S` (`CUT_INTERVAL_S = 2.2s`) times a fresh +/-8% jitter factor (`CUT_JITTER`) — re-derived from the span rather than hardcoded, so span and cadence never drift apart on resize, and re-rolled per segment so the cadence is never perfectly metronomic (real die-cutters hunt slightly around their nominal rate). ROPE TEXTURE (die-drag striation): the rope is drawn as a run of `STRIATION_PITCH_PX = 3`-wide fill rects, each cell's alpha perturbed +/-6% (`STRIATION_VARIANCE`) by a stable hash of its ROPE-LOCAL cell index (`hash1`, a deterministic sine-hash, not `Math.random`) — because the hash key is the rope's own local coordinate rather than wall-clock time, the ripple reads as a fixed material property that scrolls into view as the rope grows, never as per-frame shimmer. FALLING SEGMENTS accelerate under a constant `GRAVITY_PX_S2 = 40px/s^2` (deliberately gentle for card scale, not real-world gravity) until they reach a floor y at `containerHeight * 0.88`, then mark `settled` and are re-laid into a loose staggered stack (each settled segment's y stepped up by `0.9 * ropeThickness` per stack position, x staggered `(i % 3) * 6px`) by `settleStack()`, called every step. Once more than `MAX_STACK = 6` settled segments exist, the oldest are given a `fadeStart` timestamp and fade their alpha to 0 over `STACK_FADE_S = 0.4s`, then are dropped from the array entirely — the stack never overflows the card. PROGRESS: an optional `progress` (0..1) prop scales `advancePxS` by `clamp(0.3 + progress*1.4, 0.3, 1.7)` on every re-roll — a higher progress means more of the span extrudes before the FIXED-cadence cut fires (so segments read longer/fuller at higher progress), but `CUT_INTERVAL_S` itself is never touched by `progress`; the cut cadence is the component's mechanical identity and must not become a progress-driven variable. TOKENS: `readTokens()` reads `--foreground`/`--background` via `getComputedStyle(document.documentElement)` and returns `null` if either is empty (stylesheet not yet applied); `boot()` retries on the next rAF until tokens resolve, and NOTHING is drawn before that first successful read — the same guard every other token-driven canvas in this registry uses, closing the exact 'paint before first token read' failure the builder brief calls out on the rAF-start, ResizeObserver AND IntersectionObserver-resume paths specifically (all three call `draw`/`kick` only after `tokens` is non-null). `--ns-accent` is never referenced anywhere in this file; the die, rope, blade and segments are all `--foreground` at varying `globalAlpha`, and there is no pointer interaction to justify accent chrome. REDUCED MOTION: instead of running the live loop and freezing wherever it happens to land, `buildStaticFrame()` constructs the target state directly (deterministic, seeded `mulberry32` PRNG for the stack's minor length variance) — rope at `cutSpan - 4` (severed stub, matching the live segment's 4px gap convention), blade stroke frozen at 60% (`CUT_STROKE_S * 0.6`), one freshly-cut segment sitting at the die not yet fallen, and three pre-settled stack segments behind it — the single frame that shows die texture, blade mid-travel, and a completed segment simultaneously, deliberately not the empty t=0 frame. `ResizeObserver` on the root recomputes `cutSpan` and re-derives the static frame in reduced-motion mode so it never shows a state from a stale width; `IntersectionObserver` (default threshold) pauses the rAF loop while offscreen and resumes it (picking up any theme flip that happened while hidden) on re-entry; `MutationObserver` on `documentElement`'s class watches for a theme flip and redraws immediately with the same sim state. DPR capped at 2. Zero dependencies, DOM+Canvas 2D only, no WebGL. The canvas is `aria-hidden` and `pointer-events-none`; `autoplay: mode: none` because it runs entirely on its own internal two-clock timer with nothing for a synthetic pointer driver to do."
  },
  "type": "registry:ui"
}