{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-dipole-field",
  "title": "Hero Dipole Field",
  "description": "Full-bleed hero where the headline exists twice: crisp DOM type above, and beneath it a canvas field of iron-filing strokes solved from a two-pole dipole field, so the type visibly iron-files into existence as the cursor approaches and CTA hover bends the whole field toward the button.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/hero-dipole-field/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// LodestoneHero — full-bleed hero where the headline exists twice: crisp DOM\n// type above, and beneath it a canvas field of iron-filing strokes solved from\n// a two-pole dipole field (cursor pole + fixed anchor pole behind the primary\n// CTA). Filings pack densely only inside the headline's letter-mask, so the\n// type visibly \"iron-files\" into existence as the cursor approaches; hovering\n// the CTA doubles its pole strength and bends the whole field toward the\n// button. Vector-field solver rendering: each filing is a short line segment\n// whose angle chases atan2 of the summed field vector with critically-damped\n// easing. Canvas 2D, refs-only hot path, breath-cycle ambient drift with real\n// sleep windows between breaths.\n// ---------------------------------------------------------------------------\n\ntype Vec3 = [number, number, number];\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);\n      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);\n      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nfunction mix(a: Vec3, b: Vec3, t: number): Vec3 {\n  return [\n    Math.round(a[0] + (b[0] - a[0]) * t),\n    Math.round(a[1] + (b[1] - a[1]) * t),\n    Math.round(a[2] + (b[2] - a[2]) * t),\n  ];\n}\n\n// deterministic prng — filing placement is stable across re-renders\nfunction mulberry32(a: number) {\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\n// 2-octave value noise — seeds each filing's ambient-drift character\nfunction hash2(x: number, y: number) {\n  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;\n  return n - Math.floor(n);\n}\nfunction vnoise(x: number, y: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = x - xi;\n  const yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi);\n  const b = hash2(xi + 1, yi);\n  const c = hash2(xi, yi + 1);\n  const d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction noise2(x: number, y: number) {\n  return 0.65 * vnoise(x, y) + 0.35 * vnoise(x * 2.1 + 19.7, y * 2.1 + 7.3);\n}\n\nfunction easeOutExpo(p: number) {\n  return p >= 1 ? 1 : 1 - Math.pow(2, -10 * p);\n}\n\n// time-based pole-strength tween — duration IS the forced-settle deadline\n// (max 600ms < 800ms budget), so a spring can never hunt forever\ntype Tween = { from: number; to: number; t0: number; dur: number; value: number; active: boolean };\nfunction retarget(tw: Tween, to: number, dur: number, now: number) {\n  if (!tw.active && tw.value === to) return;\n  tw.from = tw.value;\n  tw.to = to;\n  tw.t0 = now;\n  tw.dur = dur;\n  tw.active = true;\n}\nfunction tickTween(tw: Tween, now: number) {\n  if (!tw.active) return;\n  const p = (now - tw.t0) / tw.dur;\n  if (p >= 1) {\n    tw.value = tw.to;\n    tw.active = false;\n  } else {\n    tw.value = tw.from + (tw.to - tw.from) * easeOutExpo(p);\n  }\n}\n\nconst POLE_RMIN = 48; // r clamp — field never blows up at the pole core\nconst MAG_SOFT = 5.5e-5; // soft-knee normalizer for magnitude → alpha\nconst IDLE_MS = 500; // pointer-idle threshold before ambient drift fades in\nconst DRIFT_HZ = 0.1; // ambient oscillation frequency\nconst DRIFT_AMP = (8 * Math.PI) / 180; // ±8°\nconst BREATH_S = 12; // ambient breath cycle length\nconst ACTIVE_FRAC = 0.72; // fraction of the cycle that drifts; rest = sleep\nconst SLEEP_EPS = 0.001; // max per-filing angular delta below which we sleep\n\nexport function LodestoneHero({\n  eyebrow = \"FIELD-ALIGNED INFRASTRUCTURE\",\n  headlineLines = [\"Every signal bends\", \"toward your stack\"],\n  subcopy = \"Lodestone routes traffic the way a magnet organizes iron filings: declare the pole, and every request, retry, and rollback aligns itself. No orchestration YAML, no drift.\",\n  primaryCta = { label: \"Deploy the field\", href: \"#deploy\" },\n  secondaryCta = { label: \"Read the docs\", href: \"#docs\" },\n  stats = [\n    { value: \"12ms\", label: \"p99 route solve\" },\n    { value: \"99.98%\", label: \"field uptime\" },\n    { value: \"4,200+\", label: \"clusters aligned\" },\n  ],\n  maskFilings = 1800,\n  ambientFilings = 600,\n  className = \"\",\n}: {\n  /** mono eyebrow line above the headline */\n  eyebrow?: string;\n  /** display headline, one string per rendered line — this is the letter-mask source */\n  headlineLines?: string[];\n  /** muted supporting copy under the headline */\n  subcopy?: string;\n  /** primary accent CTA — its center is the fixed anchor pole */\n  primaryCta?: { label: string; href: string };\n  /** ghost secondary CTA */\n  secondaryCta?: { label: string; href: string };\n  /** three mono stats for the bordered strip below the CTAs */\n  stats?: { value: string; label: string }[];\n  /** filings rejection-sampled inside the headline letter-mask */\n  maskFilings?: number;\n  /** sparse ambient filings outside the mask */\n  ambientFilings?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const headlineRef = useRef<HTMLHeadingElement>(null);\n  const ctaRef = useRef<HTMLAnchorElement>(null);\n  const lineRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const linesKey = headlineLines.join(\"\u0000\");\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const headline = headlineRef.current;\n    const cta = ctaRef.current;\n    if (!root || !canvas || !headline || !cta) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const maskCanvas = document.createElement(\"canvas\");\n    const mctx = maskCanvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!mctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const rand = mulberry32(0x10de);\n    let disposed = false;\n\n    // -- token-derived ink: read at mount, re-derived live on theme flips ----\n    // 16 alpha-bucketed rgba strings per group so the draw loop batches\n    // strokes instead of setting per-filing styles\n    const styleFg: string[] = new Array(16).fill(\"\");\n    const styleAmb: string[] = new Array(16).fill(\"\");\n    const styleAcc: string[] = new Array(16).fill(\"\");\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const bg = parseColor(cs.getPropertyValue(\"--background\")) ?? [10, 10, 10];\n      const fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? [237, 237, 237];\n      const accent = parseColor(cs.getPropertyValue(\"--ns-accent\")) ?? [0, 107, 255];\n      const border = parseColor(cs.getPropertyValue(\"--border\")) ?? [46, 46, 46];\n      const muted = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? [143, 143, 143];\n      // ambient ink leans toward --border on light themes so sparse filings\n      // stay whisper-quiet against white instead of reading as dust\n      const lum = (0.2126 * bg[0] + 0.7152 * bg[1] + 0.0722 * bg[2]) / 255;\n      const amb = lum < 0.5 ? mix(muted, border, 0.25) : mix(muted, border, 0.45);\n      for (let l = 0; l < 16; l++) {\n        const a = (l / 15).toFixed(3);\n        styleFg[l] = `rgba(${fg[0]},${fg[1]},${fg[2]},${a})`;\n        styleAmb[l] = `rgba(${amb[0]},${amb[1]},${amb[2]},${a})`;\n        styleAcc[l] = `rgba(${accent[0]},${accent[1]},${accent[2]},${a})`;\n      }\n    };\n    derive();\n\n    // -- sizing: canvas is a replaced element — style.width/height set\n    // explicitly from the measured rect, backing store scaled by dpr --------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false; // zero-size guard — loop no-ops until real\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      sized = true;\n    };\n\n    // -- filing storage (structure-of-arrays, hot loop reads these only) ----\n    let n = 0; // total filings\n    let nMask = 0; // first nMask live inside the letter-mask\n    let fx = new Float32Array(0);\n    let fy = new Float32Array(0);\n    let fa = new Float32Array(0); // current angle\n    let flen = new Float32Array(0); // segment length 6–10px\n    let fna = new Float32Array(0); // drift amplitude seed [-1,1] (value noise)\n    let fph = new Float32Array(0); // drift phase seed\n    let ex0 = new Float32Array(0); // per-frame segment endpoints\n    let ey0 = new Float32Array(0);\n    let ex1 = new Float32Array(0);\n    let ey1 = new Float32Array(0);\n    let bucket = new Uint8Array(0); // quantized alpha 0–15\n    let group = new Uint8Array(0); // 0 = mask/fg, 1 = ambient, 2 = accent\n    let faccent = new Uint8Array(0); // in the ~5% nearest the anchor pole\n    let ax = 0; // anchor pole — offset from container origin, NEVER page coords\n    let ay = 0;\n\n    // letter-mask + rejection sampling. Rebuilt on ResizeObserver and after\n    // webfonts finish loading (glyph metrics shift the mask).\n    const buildField = () => {\n      if (!sized) return;\n      const rootRect = root.getBoundingClientRect();\n      const mw = Math.max(1, Math.round(w));\n      const mh = Math.max(1, Math.round(h));\n      maskCanvas.width = mw;\n      maskCanvas.height = mh;\n      mctx.clearRect(0, 0, mw, mh);\n      mctx.fillStyle = \"#fff\";\n\n      // font shorthand read from the live DOM headline so canvas glyphs\n      // match the crisp type above\n      const hcs = getComputedStyle(headline);\n      mctx.font = `${hcs.fontStyle} ${hcs.fontWeight} ${hcs.fontSize} ${hcs.fontFamily}`;\n      if (\"letterSpacing\" in mctx) {\n        (mctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =\n          hcs.letterSpacing === \"normal\" ? \"0px\" : hcs.letterSpacing;\n      }\n      mctx.textAlign = \"left\";\n      mctx.textBaseline = \"alphabetic\";\n\n      let minX = mw;\n      let minY = mh;\n      let maxX = 0;\n      let maxY = 0;\n      for (let li = 0; li < headlineLines.length; li++) {\n        const span = lineRefs.current[li];\n        const text = headlineLines[li];\n        if (!span || !text) continue;\n        const r = span.getBoundingClientRect();\n        const relL = r.left - rootRect.left;\n        const relT = r.top - rootRect.top;\n        const m = mctx.measureText(text);\n        // center glyphs on the span's line box, x-scale to its measured\n        // width so the mask tracks the DOM rendering\n        const glyphMid = (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;\n        const sc = m.width > 1 ? r.width / m.width : 1;\n        mctx.save();\n        mctx.translate(relL, relT + r.height / 2 + glyphMid);\n        mctx.scale(sc, 1);\n        mctx.fillText(text, 0, 0);\n        mctx.restore();\n        minX = Math.min(minX, relL);\n        minY = Math.min(minY, relT);\n        maxX = Math.max(maxX, relL + r.width);\n        maxY = Math.max(maxY, relT + r.height);\n      }\n      const data = mctx.getImageData(0, 0, mw, mh).data;\n      const inMask = (x: number, y: number) => {\n        const xi = x | 0;\n        const yi = y | 0;\n        if (xi < 0 || yi < 0 || xi >= mw || yi >= mh) return false;\n        return (data[(yi * mw + xi) * 4 + 3] ?? 0) > 100;\n      };\n\n      // anchor pole = primary CTA center as an offset from the container\n      const cr = cta.getBoundingClientRect();\n      ax = cr.left - rootRect.left + cr.width / 2;\n      ay = cr.top - rootRect.top + cr.height / 2;\n\n      // halve counts on dense-DPR narrow viewports\n      const small = dpr >= 2 && w < 900;\n      const wantMask = Math.max(0, Math.round(small ? maskFilings / 2 : maskFilings));\n      const wantAmb = Math.max(0, Math.round(small ? ambientFilings / 2 : ambientFilings));\n      const cap = wantMask + wantAmb;\n      fx = new Float32Array(cap);\n      fy = new Float32Array(cap);\n      fa = new Float32Array(cap);\n      flen = new Float32Array(cap);\n      fna = new Float32Array(cap);\n      fph = new Float32Array(cap);\n      ex0 = new Float32Array(cap);\n      ey0 = new Float32Array(cap);\n      ex1 = new Float32Array(cap);\n      ey1 = new Float32Array(cap);\n      bucket = new Uint8Array(cap);\n      group = new Uint8Array(cap);\n      faccent = new Uint8Array(cap);\n\n      // rejection-sample dense filings inside the mask (bbox-bounded)\n      const bx = Math.max(0, minX - 8);\n      const by = Math.max(0, minY - 8);\n      const bw = Math.max(1, Math.min(mw, maxX + 8) - bx);\n      const bh = Math.max(1, Math.min(mh, maxY + 8) - by);\n      let i = 0;\n      let tries = 0;\n      const maxTriesMask = wantMask * 80;\n      while (i < wantMask && tries < maxTriesMask) {\n        tries++;\n        const x = bx + rand() * bw;\n        const y = by + rand() * bh;\n        if (!inMask(x, y)) continue;\n        fx[i] = x;\n        fy[i] = y;\n        i++;\n      }\n      nMask = i;\n      // sparse ambient filings anywhere outside the mask\n      tries = 0;\n      const maxTriesAmb = wantAmb * 20;\n      let j = 0;\n      while (j < wantAmb && tries < maxTriesAmb) {\n        tries++;\n        const x = rand() * w;\n        const y = rand() * h;\n        if (inMask(x, y)) continue;\n        fx[i + j] = x;\n        fy[i + j] = y;\n        j++;\n      }\n      n = nMask + j;\n\n      // per-filing constants: length, drift seeds, accent eligibility\n      const dists = new Float32Array(n);\n      for (let k = 0; k < n; k++) {\n        flen[k] = 6 + rand() * 4;\n        fna[k] = 2 * noise2((fx[k] ?? 0) * 0.012, (fy[k] ?? 0) * 0.012) - 1;\n        fph[k] = noise2((fx[k] ?? 0) * 0.03 + 51.7, (fy[k] ?? 0) * 0.03) * Math.PI * 2;\n        dists[k] = Math.hypot((fx[k] ?? 0) - ax, (fy[k] ?? 0) - ay);\n      }\n      // ~5% nearest the anchor pole may take --ns-accent while the CTA is hot\n      const sorted = Array.from(dists).sort((a, b) => a - b);\n      const thr = sorted[Math.floor(n * 0.05)] ?? 0;\n      for (let k = 0; k < n; k++) faccent[k] = (dists[k] ?? 0) <= thr ? 1 : 0;\n    };\n\n    // -- pole state ----------------------------------------------------------\n    const cursorT: Tween = { from: 0, to: 0, t0: 0, dur: 1, value: 0, active: false };\n    const anchorT: Tween = { from: 0.7, to: 0.7, t0: 0, dur: 1, value: 0.7, active: false };\n    let pcx = 0;\n    let pcy = 0;\n    let ctaHot = false; // accent gate — hover/focus only\n    let lastMove = -1e9;\n\n    // solve the dipole field into angles/endpoints/buckets for one frame.\n    // k = angle chase gain this frame (1 - exp(-dt*10) → critically damped);\n    // returns the max remaining per-filing angular delta (sleep signal).\n    const solve = (\n      nowS: number,\n      cs: number,\n      asV: number,\n      driftAmp: number,\n      k: number\n    ) => {\n      let maxD = 0;\n      const osc = Math.PI * 2 * DRIFT_HZ * nowS;\n      for (let i = 0; i < n; i++) {\n        const x = fx[i] ?? 0;\n        const y = fy[i] ?? 0;\n        let vx = 0;\n        let vy = 0;\n        // anchor pole — pure radial\n        {\n          const dx = x - ax;\n          const dy = y - ay;\n          const r = Math.hypot(dx, dy) || 1;\n          const rc = Math.max(POLE_RMIN, r);\n          const s = asV / (rc * rc);\n          vx += (s * dx) / r;\n          vy += (s * dy) / r;\n        }\n        // cursor pole — radial + mild tangential swirl\n        if (cs > 1e-4) {\n          const dx = x - pcx;\n          const dy = y - pcy;\n          const r = Math.hypot(dx, dy) || 1;\n          const rc = Math.max(POLE_RMIN, r);\n          const s = cs / (rc * rc);\n          const ux = dx / r;\n          const uy = dy / r;\n          vx += s * (ux * 0.85 - uy * 0.35);\n          vy += s * (uy * 0.85 + ux * 0.35);\n        }\n        const mag = Math.hypot(vx, vy);\n        let target = Math.atan2(vy, vx);\n        if (driftAmp > 1e-4) {\n          target += driftAmp * (fna[i] ?? 0) * Math.sin(osc + (fph[i] ?? 0));\n        }\n        // shortest-path wrap, then critically-damped chase — no overshoot\n        let d = target - (fa[i] ?? 0);\n        d = ((d + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;\n        const ad = Math.abs(d);\n        if (ad > maxD) maxD = ad;\n        const ang = (fa[i] ?? 0) + d * k;\n        fa[i] = ang;\n        const half = (flen[i] ?? 8) / 2;\n        const c = Math.cos(ang);\n        const sn = Math.sin(ang);\n        ex0[i] = x - c * half;\n        ey0[i] = y - sn * half;\n        ex1[i] = x + c * half;\n        ey1[i] = y + sn * half;\n        // magnitude → ink: mask 0.25→0.9 in --foreground, ambient 0.06→0.18\n        const nm = mag / (mag + MAG_SOFT);\n        const alpha = i < nMask ? 0.25 + 0.65 * nm : 0.06 + 0.12 * nm;\n        bucket[i] = Math.round(alpha * 15);\n        group[i] = ctaHot && faccent[i] === 1 ? 2 : i < nMask ? 0 : 1;\n      }\n      return maxD;\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h); // full clear + redraw — no alpha accumulation\n      ctx.lineCap = \"round\";\n      ctx.lineWidth = 1;\n      const styles = [styleFg, styleAmb, styleAcc] as const;\n      for (let g = 0; g < 3; g++) {\n        const pal = styles[g]!;\n        for (let l = 1; l < 16; l++) {\n          let any = false;\n          ctx.beginPath();\n          for (let i = 0; i < n; i++) {\n            if (group[i] !== g || bucket[i] !== l) continue;\n            ctx.moveTo(ex0[i] ?? 0, ey0[i] ?? 0);\n            ctx.lineTo(ex1[i] ?? 0, ey1[i] ?? 0);\n            any = true;\n          }\n          if (any) {\n            ctx.strokeStyle = pal[l] ?? \"rgba(0,0,0,0)\";\n            ctx.stroke();\n          }\n        }\n      }\n    };\n\n    // -- reduced motion: one static solved frame, anchor pole only ----------\n    const renderStatic = () => {\n      resize();\n      if (!sized) return;\n      buildField();\n      solve(0, 0, 0.7, 0, 1); // k=1 snaps angles straight to target\n      draw();\n    };\n\n    if (reduced) {\n      renderStatic();\n      const ro = new ResizeObserver(renderStatic);\n      ro.observe(root);\n      const mo = new MutationObserver(() => {\n        derive();\n        if (sized) draw(); // re-ink the solved frame for the new theme\n      });\n      mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n      document.fonts.ready.then(() => {\n        if (!disposed) renderStatic();\n      });\n      return () => {\n        disposed = true;\n        ro.disconnect();\n        mo.disconnect();\n      };\n    }\n\n    // -- animated path -------------------------------------------------------\n    resize();\n    buildField();\n    solve(performance.now() / 1000, 0, 0.7, 0, 1); // settle initial pose\n\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let wakeTimer = 0;\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!visible || !sized) {\n        last = 0;\n        return; // paused offscreen / zero-size — observers wake us\n      }\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n      tickTween(cursorT, now);\n      tickTween(anchorT, now);\n\n      // ambient drift breathes on a 12s cycle: ~8.6s of value-noise drift,\n      // then a rest phase where the loop genuinely sleeps until the next\n      // breath — ambient is the default look, sleep is still real\n      const nowS = now / 1000;\n      const ph = (nowS % BREATH_S) / BREATH_S;\n      const env =\n        ph < ACTIVE_FRAC ? Math.pow(Math.sin((Math.PI * ph) / ACTIVE_FRAC), 2) : 0;\n      const idle = Math.min(1, Math.max(0, (now - lastMove - IDLE_MS) / 400));\n      const driftAmp = DRIFT_AMP * env * idle;\n\n      const k = 1 - Math.exp(-dt * 10);\n      const maxD = solve(nowS, cursorT.value, anchorT.value, driftAmp, k);\n      draw();\n\n      // sleep: field settled AND no pole strength animating AND drift at\n      // its rest phase — schedule the wake for the next breath\n      if (maxD < SLEEP_EPS && !cursorT.active && !anchorT.active && ph >= ACTIVE_FRAC) {\n        last = 0;\n        window.clearTimeout(wakeTimer);\n        wakeTimer = window.setTimeout(wake, (1 - ph) * BREATH_S * 1000 + 32);\n        return; // raf stays 0 — genuinely asleep\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf && visible) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    // -- pointer: cursor pole ------------------------------------------------\n    const onMove = (e: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      pcx = e.clientX - rect.left;\n      pcy = e.clientY - rect.top;\n      lastMove = performance.now();\n      if (cursorT.to !== 1) retarget(cursorT, 1, 200, lastMove);\n      wake();\n    };\n    const onLeave = () => {\n      // cursor pole decays to 0 over 600ms (deadline-bounded tween)\n      retarget(cursorT, 0, 600, performance.now());\n      wake();\n    };\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerdown\", onMove);\n    root.addEventListener(\"pointerleave\", onLeave);\n\n    // -- CTA pole: hover/focus springs 0.7 → 1.4 over 250ms ease-out-expo ---\n    const ctaOn = () => {\n      ctaHot = true;\n      retarget(anchorT, 1.4, 250, performance.now());\n      wake();\n    };\n    const ctaOff = () => {\n      ctaHot = false;\n      retarget(anchorT, 0.7, 250, performance.now());\n      wake();\n    };\n    cta.addEventListener(\"pointerenter\", ctaOn);\n    cta.addEventListener(\"pointerleave\", ctaOff);\n    cta.addEventListener(\"focus\", ctaOn);\n    cta.addEventListener(\"blur\", ctaOff);\n\n    // -- observers -----------------------------------------------------------\n    const ro = new ResizeObserver(() => {\n      resize();\n      buildField(); // letter-mask resampled on resize\n      solve(performance.now() / 1000, cursorT.value, anchorT.value, 0, 1);\n      if (sized) draw();\n      wake();\n    });\n    ro.observe(root);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n    });\n    io.observe(root);\n    const mo = new MutationObserver(() => {\n      derive(); // live theme re-derive — next frame re-inks every filing\n      if (sized) draw();\n      wake();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n    document.fonts.ready.then(() => {\n      // webfont swap moves glyph outlines — rebuild the mask once settled\n      if (disposed) return;\n      resize();\n      buildField();\n      solve(performance.now() / 1000, cursorT.value, anchorT.value, 0, 1);\n      if (sized) draw();\n      wake();\n    });\n\n    wake();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      window.clearTimeout(wakeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      cta.removeEventListener(\"pointerenter\", ctaOn);\n      cta.removeEventListener(\"pointerleave\", ctaOff);\n      cta.removeEventListener(\"focus\", ctaOn);\n      cta.removeEventListener(\"blur\", ctaOff);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [linesKey, maskFilings, ambientFilings]);\n\n  return (\n    <section\n      ref={rootRef}\n      className={`relative isolate overflow-hidden bg-background ${className}`}\n    >\n      {/* filing field — pointer-events none, DOM copy above stays interactive */}\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute left-0 top-0\"\n      />\n      <div className=\"relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center px-6 pb-16 pt-24 text-center sm:pb-24 sm:pt-32\">\n        <p className=\"mb-6 font-mono text-[11px] tracking-widest text-ns-muted\">\n          {eyebrow}\n        </p>\n        <h1\n          ref={headlineRef}\n          className=\"font-semibold text-foreground\"\n          style={{\n            fontSize: \"clamp(2.5rem, 6.5vw, 4.5rem)\",\n            lineHeight: 1.06,\n            letterSpacing: \"-0.03em\",\n          }}\n        >\n          {headlineLines.map((line, i) => (\n            <span\n              key={i}\n              ref={(el) => {\n                lineRefs.current[i] = el;\n              }}\n              className=\"block\"\n            >\n              {line}\n            </span>\n          ))}\n        </h1>\n        <p className=\"mt-6 max-w-xl text-base leading-relaxed text-ns-muted\">\n          {subcopy}\n        </p>\n        <div className=\"mt-9 flex flex-wrap items-center justify-center gap-3\">\n          <a\n            ref={ctaRef}\n            href={primaryCta.href}\n            className=\"rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          >\n            {primaryCta.label}\n          </a>\n          <a\n            href={secondaryCta.href}\n            className=\"rounded-sm border border-border px-5 py-2.5 text-sm font-medium text-ns-muted transition-colors duration-200 hover:border-foreground/20 hover:bg-surface hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          >\n            {secondaryCta.label}\n          </a>\n        </div>\n        <div className=\"mt-16 grid w-full max-w-2xl grid-cols-1 divide-y divide-border border-y border-border sm:grid-cols-3 sm:divide-x sm:divide-y-0\">\n          {stats.slice(0, 3).map((s) => (\n            <div key={s.label} className=\"flex flex-col items-center gap-1 px-6 py-4\">\n              <span className=\"font-mono text-lg text-foreground\">{s.value}</span>\n              <span className=\"font-mono text-[11px] tracking-widest text-ns-muted\">\n                {s.label.toUpperCase()}\n              </span>\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/hero-dipole-field.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "hero",
      "vector-field",
      "dipole",
      "particles",
      "cursor",
      "letter-mask",
      "text",
      "ambient"
    ],
    "instruction": "A full-bleed hero where the headline exists twice: crisp DOM type above, and beneath it a Canvas 2D field of iron-filing strokes solved from a two-pole dipole field (cursor pole + fixed anchor pole behind the primary CTA). The canvas sits absolutely under the DOM copy with pointer-events none so every CTA stays interactive. A letter-mask is built by drawing the headline text into an offscreen canvas using the font shorthand read from the live DOM headline via getComputedStyle, aligned and x-scaled to each line span's measured rect; ~1,800 filing positions are rejection-sampled inside the mask plus ~600 sparse ambient filings outside (counts halved when devicePixelRatio >= 2 and viewport < 900px), each filing a 6-10px line segment stored in structure-of-arrays Float32Arrays. Field model: cursor pole strength 1.0 (radial plus mild tangential swirl), anchor pole 0.7 (pure radial, its position computed as a getBoundingClientRect offset from the container origin, never page coords); contribution = strength / max(r, 48)^2; filing target angle = atan2 of the summed vector, chased per frame with angle += delta * (1 - exp(-dt*10)) for a critically-damped no-overshoot feel. CTA hover/focus springs the anchor pole 0.7 to 1.4 over 250ms ease-out-expo; pointerleave decays the cursor pole to 0 over 600ms; both are time-based tweens whose duration is the forced-settle deadline (under the 800ms budget), so no spring can hunt forever. After 500ms pointer idle, angles drift on 2-octave value noise at plus/minus 8 degrees and 0.1Hz inside a 12s breath envelope with a real rest phase. Ink is parsed from getComputedStyle tokens at mount and re-derived live via a MutationObserver on documentElement class: mask filings alpha 0.25 to 0.9 by field magnitude in --foreground, ambient filings 0.06 to 0.18 in a --ns-muted/--border mix, and --ns-accent permitted only on the ~5% of filings nearest the anchor pole while the CTA is hovered or focused; strokes are batched into 16 alpha buckets per color group so the draw loop sets style once per batch, with a full clear + redraw each frame (no destination-in accumulation). The canvas is sized as a replaced element: style.width/height set explicitly from the measured rect, backing store scaled by DPR (clamped 2). The rAF loop sleeps when the max per-filing angular delta drops below 0.001 rad, no pole tween is active, and the ambient drift is in its rest phase (a timeout wakes the next breath); IntersectionObserver pauses offscreen, zero-size containers bail, ResizeObserver resamples the letter-mask, document.fonts.ready rebuilds it after webfont swap, and every listener, observer, timer, and rAF is torn down on unmount. Under prefers-reduced-motion one static solved frame renders (anchor pole only, cursor pole off) and the loop never starts. Demo is the full weighted composition: full-bleed hero on bg-background with mono eyebrow, 2-line Geist Sans 600 display headline (the mask source), muted sub-copy, accent primary CTA + ghost secondary (rounded-sm, token-relative hover/focus), and a thin bordered strip of three mono stats."
  },
  "type": "registry:ui"
}