{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bobbin-lace-pricking",
  "title": "Bobbin Lace Pricking",
  "description": "A decorative divider/empty-state motif: a narrow working band of pins twists thread pairs into locked crosses down a pricked grid, while a pin two rows behind the band is pulled free the moment its crossing has secured. The load-bearing, uncontested mechanic no other lattice sibling has.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/bobbin-lace-pricking/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BobbinLacePricking — a decorative divider/empty-state motif: a narrow\n// WORKING BAND of pins works its way down a pricked grid, twisting a pair of\n// threads into a locked X at each active pin, while pins two rows behind the\n// band are pulled free once their crossing has secured into the growing lace\n// ground. Source: bobbin lace (pillow/pricking) construction.\n//\n// The load-bearing, uncontested part of this mechanic (per spec) is that a\n// pin is a TEMPORARY fixture — placed, used, then physically removed as work\n// progresses — which is what separates it from every other lattice/mesh\n// sibling in the registry (mesh-lash, seep-lattice, pin-register): none of\n// those remove a placed element as the pattern advances.\n//\n// TIMELINE (one continuous clock, no per-pin React state):\n//   continuousRow = elapsedMs / ROW_INTERVAL   (smooth fractional row depth)\n//   currentRow    = floor(continuousRow)        (the row being actively worked)\n//   rowElapsedMs  = fractional part * ROW_INTERVAL\n//\n// Every row is classified purely by `age = currentRow - rowIndex`:\n//   age < 0   not yet reached      -> border-token pricking dot only\n//   age == 0  actively crossing    -> pin dot + a per-column-batch twist\n//                                     animation (3 batches, 700ms apart,\n//                                     850ms twist each, mirroring \"3 pins/\n//                                     row roughly staggered 700ms\" scaled to\n//                                     any column count via `col % 3`)\n//   age == 1  just secured         -> pin dot + locked X, full strength,\n//                                     static (already crossed, not yet pulled)\n//   age == 2  pull row             -> exactly one column (cycling every\n//                                     interval) animates its pin sliding out\n//                                     + fading over 180ms while its locked X\n//                                     stays put; every other column in the\n//                                     row still shows a plain locked X + dot\n//   age >= 3  finished ground      -> locked X only, no pin dot (already\n//                                     pulled), rendered at low-moderate\n//                                     opacity, never re-animated\n//\n// Exactly one row has age===2 at any instant, so exactly one pull event is\n// ever in flight — the single, unmistakable \"old pin behind the band\" cue\n// the spec names as the ONE thing to follow, on a ~2.6s cadence well above\n// the ~1s legibility floor.\n//\n// The whole field scrolls continuously (band pinned at a fixed screen\n// fraction, rows drift upward under it), so the loop never simply stops —\n// new pricking keeps entering from the bottom, matching the spec's\n// unbounded-scrolling-field option.\n// ---------------------------------------------------------------------------\n\nconst ROW_INTERVAL_MS = 2600; // one full working-band row advance\nconst CROSS_MS = 850; // per-pin twist-and-lock duration\nconst BATCH_STAGGER_MS = 700; // stagger between the 3 within-row batches\nconst PULL_MS = 180; // pin slide-out + fade duration\nconst BAND_Y_FRAC = 1 / 3; // active row's screen position, fraction of height\nconst MIN_PITCH = 12;\nconst MAX_PITCH = 20;\nconst TARGET_PINS_ACROSS = 10; // used to derive pitch from the smaller dimension\nconst FINISHED_ALPHA = 0.38; // low-moderate opacity for set lace ground\nconst CROSS_HALF_LEN = 0.42; // fraction of pitch, half-length of each thread stroke\nconst STATIC_ROW = 3; // reduced-motion: currentRow at freeze\nconst STATIC_ROW_ELAPSED = 375; // ms into that row -> mid-cross on batch 0\n\nfunction easeOutCubic(x: number): number {\n  const t = 1 - x;\n  return 1 - t * t * t;\n}\n\nexport interface BobbinLacePrickingProps {\n  className?: string;\n}\n\nexport function BobbinLacePricking({ className = \"\" }: BobbinLacePrickingProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!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 fg = \"currentColor\";\n    let border = \"currentColor\";\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let sized = false;\n    let disposed = false;\n    let visible = true;\n\n    let pitch = 16;\n    let cols = 0;\n    let bandScreenY = 0;\n\n    const readTokens = () => {\n      const s = getComputedStyle(document.documentElement);\n      fg = s.getPropertyValue(\"--foreground\").trim() || \"currentColor\";\n      border = s.getPropertyValue(\"--border\").trim() || fg;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      width = rect.width;\n      height = rect.height;\n      if (width < 2 || height < 2) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const smaller = Math.min(width, height);\n      pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, smaller / TARGET_PINS_ACROSS));\n      cols = Math.max(2, Math.floor(width / pitch));\n      bandScreenY = height * BAND_Y_FRAC;\n      sized = true;\n    };\n\n    // Two strokes rotating from parallel (theta=0) to a locked X (theta=45deg)\n    // around (cx, cy), eased. `full` (age>=1) draws the settled X directly.\n    const drawTwist = (cx: number, cy: number, progress: number, alpha: number) => {\n      const eased = easeOutCubic(Math.min(1, Math.max(0, progress)));\n      const theta = (eased * 45 * Math.PI) / 180;\n      const len = pitch * CROSS_HALF_LEN;\n      ctx.globalAlpha = alpha;\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = 1;\n      ctx.lineCap = \"round\";\n      // thread A: rotates clockwise from vertical\n      ctx.beginPath();\n      ctx.moveTo(cx - Math.sin(theta) * len, cy - Math.cos(theta) * len);\n      ctx.lineTo(cx + Math.sin(theta) * len, cy + Math.cos(theta) * len);\n      ctx.stroke();\n      // thread B: rotates counter-clockwise from vertical\n      ctx.beginPath();\n      ctx.moveTo(cx + Math.sin(theta) * len, cy - Math.cos(theta) * len);\n      ctx.lineTo(cx - Math.sin(theta) * len, cy + Math.cos(theta) * len);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    };\n\n    const drawPinDot = (cx: number, cy: number, alpha: number, radius: number) => {\n      ctx.globalAlpha = alpha;\n      ctx.fillStyle = fg;\n      ctx.beginPath();\n      ctx.arc(cx, cy, radius, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.globalAlpha = 1;\n    };\n\n    const drawHole = (cx: number, cy: number) => {\n      ctx.globalAlpha = 1;\n      ctx.fillStyle = border;\n      ctx.beginPath();\n      ctx.arc(cx, cy, 1, 0, Math.PI * 2);\n      ctx.fill();\n    };\n\n    const drawField = (continuousRow: number) => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, width, height);\n\n      const currentRow = Math.floor(continuousRow);\n      const rowElapsedMs = (continuousRow - currentRow) * ROW_INTERVAL_MS;\n      const pullCol = ((currentRow % cols) + cols) % cols;\n      const pullProgress = Math.min(1, Math.max(0, rowElapsedMs / PULL_MS));\n\n      const minRow = Math.floor(continuousRow + (0 - bandScreenY) / pitch) - 1;\n      const maxRow = Math.ceil(continuousRow + (height - bandScreenY) / pitch) + 1;\n\n      for (let rowIndex = Math.max(0, minRow); rowIndex <= maxRow; rowIndex++) {\n        const age = currentRow - rowIndex;\n        const cy = bandScreenY + (rowIndex - continuousRow) * pitch;\n        const xOffset = (rowIndex % 2) * (pitch / 2);\n\n        for (let col = 0; col < cols; col++) {\n          const cx = xOffset + col * pitch + pitch / 2;\n          if (cx > width + pitch) continue;\n\n          if (age < 0) {\n            drawHole(cx, cy);\n            continue;\n          }\n\n          if (age === 0) {\n            const batch = col % 3;\n            const batchStart = batch * BATCH_STAGGER_MS;\n            const localProgress = (rowElapsedMs - batchStart) / CROSS_MS;\n            if (localProgress <= 0) {\n              drawPinDot(cx, cy, 1, 1.5);\n            } else {\n              drawTwist(cx, cy, localProgress, 1);\n              drawPinDot(cx, cy, 1, 1.5);\n            }\n            continue;\n          }\n\n          if (age === 1) {\n            drawTwist(cx, cy, 1, 1);\n            drawPinDot(cx, cy, 1, 1.5);\n            continue;\n          }\n\n          if (age === 2) {\n            drawTwist(cx, cy, 1, 1);\n            if (col === pullCol) {\n              const slideY = -6 * easeOutCubic(pullProgress);\n              const dotAlpha = 1 - pullProgress;\n              if (dotAlpha > 0.01) drawPinDot(cx, cy + slideY, dotAlpha, 1.5);\n            } else {\n              drawPinDot(cx, cy, 1, 1.5);\n            }\n            continue;\n          }\n\n          // age >= 3: finished ground, pin long since pulled\n          drawTwist(cx, cy, 1, FINISHED_ALPHA);\n        }\n      }\n    };\n\n    let raf = 0;\n    let startTime = 0;\n\n    const loop = (now: number) => {\n      if (!startTime) startTime = now;\n      const continuousRow = (now - startTime) / ROW_INTERVAL_MS;\n      drawField(continuousRow);\n      if (visible && !document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const drawReducedStatic = () => {\n      const continuousRow = STATIC_ROW + STATIC_ROW_ELAPSED / ROW_INTERVAL_MS;\n      drawField(continuousRow);\n    };\n\n    const start = () => {\n      if (reduced) {\n        drawReducedStatic();\n        return;\n      }\n      startTime = 0;\n      cancelAnimationFrame(raf);\n      raf = requestAnimationFrame(loop);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) drawReducedStatic();\n      }, 120);\n    };\n\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible && !reduced) start();\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      if (!document.hidden && visible && !reduced) start();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced && sized) drawReducedStatic();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    readTokens();\n    resize();\n    start();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      void disposed;\n    };\n  }, []);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden=\"true\"\n      className={`block h-full w-full ${className}`}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/bobbin-lace-pricking.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "divider",
      "empty-state",
      "ambient",
      "canvas",
      "lace",
      "textile",
      "grid",
      "mono"
    ],
    "instruction": "Build <BobbinLacePricking className?> as a self-contained, aria-hidden 2D canvas (w-full h-full) that renders a pricked pin grid scrolling continuously under a fixed-position working band. Drive everything off ONE continuous clock: continuousRow = elapsedMs / ROW_INTERVAL_MS (ROW_INTERVAL_MS = 2600, a smooth fractional row depth), currentRow = Math.floor(continuousRow) is the row actively being worked, rowElapsedMs is the fractional remainder in ms. Pin pitch is derived from the container's SMALLER dimension: pitch = clamp(smallerDim / 10, 12, 20)px, cols = floor(width / pitch), and the whole field is a rectangular grid with a half-pitch x-offset on odd rows (a cheap diamond-stagger so it reads as a pricked lace pattern, not a plain checkerboard). The active row sits at a FIXED screen y = height * (1/3); every row's screen y = bandY + (rowIndex - continuousRow) * pitch, so the field visibly and continuously scrolls upward under the band (new pricking rows enter from the bottom forever — an unbounded loop, never a fixed-length pass). CLASSIFY EVERY VISIBLE ROW PURELY BY age = currentRow - rowIndex, no per-row/per-pin React or persisted state beyond the clock: age<0 draws a --border-token 1px dot only (untouched pricking, not yet reached — never a stroke on the lace itself). age===0 is the actively-crossing row: split columns into 3 batches via `col % 3`, batch start = batch * 700ms (BATCH_STAGGER_MS), each pin's local twist progress = (rowElapsedMs - batchStart) / 850ms (CROSS_MS) eased with easeOutCubic, drawn as two --foreground strokes that start parallel/vertical (theta=0) and rotate to a locked X (theta=45deg from vertical) as progress goes 0->1, plus a small filled pin dot on top; before its batch starts the pin shows dot-only (no cross yet). age===1 draws the settled X (theta=45deg, full strength) plus its dot — already crossed, not yet pulled. age===2 is the single 'pull row' at any instant: draw every column's settled X at full strength, and for exactly one column (pullCol = ((currentRow % cols) + cols) % cols, i.e. cycling one column per row-interval) additionally animate that column's dot only — slideY = -6px * easeOutCubic(pullProgress), alpha = 1 - pullProgress, pullProgress = clamp(rowElapsedMs / 180ms, 0, 1) (PULL_MS) — a sharp upward slide-out+fade of the pin glyph ALONE; the thread crossing it secured is drawn normally underneath and never animates or disappears. Every other column in an age===2 row still shows a plain dot + settled X (unpulled) exactly like age===1. age>=3 is finished lace ground: draw the settled X only, at a fixed low-moderate alpha (FINISHED_ALPHA = 0.38) in --foreground, no pin dot (long since pulled), and never recompute or re-animate it once drawn — rows there are cosmetically identical every frame apart from their continuous scroll position. This age-based classification guarantees exactly one row has age===2 (one pull event in flight) and exactly one row has age===0 (one crossing row in flight) at any instant, which is the ONE thing a viewer should track: the working band's position plus the pull of the pin just behind it, both well above the ~1s legibility floor (850ms crosses, one pull event every 2.6s with a 180ms visible slide, never a blink). TOKENS: colors read once via getComputedStyle(document.documentElement).getPropertyValue for --foreground and --border only (no --ns-accent anywhere — this is ambient, never interaction chrome, and the climactic cross/pull moments must never touch it), re-read on a MutationObserver watching documentElement's class so both themes render correctly without remount; --border is used ONLY for the not-yet-reached pricking dots (its correct, faint, separator/placeholder use), never as a stroke on any lace thread. CANVAS HOST: DPR-aware backing store capped at 2, ResizeObserver on the canvas element (not window.resize) recomputing pitch/cols/bandY on a 120ms debounce, IntersectionObserver (threshold 0) and visibilitychange both pausing/resuming the rAF loop, full cleanup (cancelAnimationFrame, disconnect all three observers) on unmount. No paint happens before the first token read — readTokens() and resize() both run synchronously before the first start(), and the same pair reruns on the ResizeObserver and MutationObserver paths. REDUCED MOTION: prefers-reduced-motion freezes on a single deliberately-chosen non-t0 frame — continuousRow pinned at STATIC_ROW (3) + STATIC_ROW_ELAPSED (375ms) / ROW_INTERVAL_MS, which lands mid-twist on the first crossing batch (col%3===0, progress ~0.44) with the band positioned roughly a third down the field, finished ground above and untouched pricking below (the PIN_SET freeze frame) — drawn once with no rAF loop, and re-drawn (still frozen) only on a token change or a debounced resize. Purely decorative: the canvas is aria-hidden, carries no interactive affordance, and any real accessible content in a consuming empty-state/divider lives as ordinary DOM alongside it. Props: className only."
  },
  "type": "registry:ui"
}