{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bias-hysteresis",
  "title": "Bias Hysteresis",
  "description": "A saturation/headroom gauge rendered as an analog tape deck's own AC-bias hysteresis loop instead of a bar or dial: a bright marker rides a closed H/B loop that is narrow and sharp at low drive and widens with rounded corners toward saturation at high drive, its own envelope breathing on a slower independent cycle so the loop's shape visibly changes across a five-second sample, not just the marker's position on a fixed track.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/bias-hysteresis/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BiasHysteresis — a level/capacity gauge rendered as an analog tape deck's\n// own AC-bias hysteresis loop instead of a bar or dial.\n//\n// SOURCE: AC-bias magnetic tape recording linearizes an inherently nonlinear\n// medium by superimposing a high-frequency bias signal on the audio; the\n// tape's flux density B vs. drive field H traces a hysteresis loop, not a\n// straight line (Jiles-Atherton hysteresis model; documented on any pro\n// reel-to-reel service manual, e.g. Studer A80 / Ampex ATR bias alignment\n// procedures). As input climbs toward and past 0dB the loop widens and its\n// corners round off toward saturation — the mechanism behind analog tape's\n// \"soft clip\" character, colloquially \"tape saturation\".\n//\n// A bright marker rides the loop's edge, one full lobe traversal every 8.3s.\n// The loop itself is recomputed every frame from B = Bsat*tanh(k*(H -+ Hc))\n// (sign set by sweep direction — ascending vs. descending branch — which is\n// what opens the two straight tanh curves into a closed lobed loop rather\n// than a single line), so its own envelope amplitude breathes on an\n// independent 21s cycle: the loop is visibly narrower/sharper at low drive\n// and wider/rounder at high drive across a single 5-second sample, not just\n// a dot moving on a fixed shape. The real bias frequency this represents\n// (~150kHz on a pro deck) is documented here only — rendering it 1:1 against\n// a ~60Hz paint rate would alias into a strobe, so the rendered rate is a\n// decoupled, deliberately slow drive sweep instead (round-9 legibility rule).\n// ---------------------------------------------------------------------------\n\nconst DRIVE_PERIOD_S = 8.3; // one full lobe traversal (0.12Hz)\nconst DRIVE_PHASE_OFFSET = 0.12; // cycle fraction offset — see freeze-frame note below\nconst ENVELOPE_PERIOD_S = 21; // saturation-amount breathing cycle\nconst ENVELOPE_MIN = 0.6; // peak H amplitude at envelope trough\nconst ENVELOPE_MAX = 1.4; // peak H amplitude at envelope peak\nconst COERCIVITY_HC = 0.18;\nconst B_SAT = 0.92;\nconst K_STEEPNESS = 2.4; // tanh steepness — controls corner rounding\nconst LOOP_POINTS = 240; // 120 ascending + 120 descending branch samples\nconst SQUARE_FRACTION = 0.7; // loop region side = min(w,h) * this\nconst H_DOMAIN = 1.5; // H-axis half-range shown — tight enough that the trough-amplitude\n// loop (amp 0.6) still reads as a lens with real horizontal width, not a\n// tall sliver dominated by the fixed ~0.75 vertical coercivity gap\nconst B_DOMAIN = 1.05; // B-axis half-range shown, padding beyond Bsat\nconst MARKER_RADIUS_PX = 3.5;\nconst AREA_FILL_ALPHA = 0.05;\n\n// FREEZE FRAME: reduced-motion locks at drive-cycle phase 0.62 — chosen so\n// that with DRIVE_PHASE_OFFSET applied, H is descending through zero right\n// as the 21s envelope sits within a hair of its own peak (0.62 * 8.3s =\n// 5.146s; 5.146s / 21s ~= 0.245 of the envelope's own sine, ~=0.9993 of its\n// peak). At H=0 the marker sits at the widest vertical separation between\n// the two branches (the coercivity gap, ~0.75 of the B range) with the loop\n// itself at peak envelope amplitude — the single frame that shows the full\n// open loop at its widest, rather than a thin near-origin sliver at a small\n// envelope amplitude.\nconst FREEZE_PHASE_FRAC = 0.62;\nconst FREEZE_T = FREEZE_PHASE_FRAC * DRIVE_PERIOD_S;\n\nfunction envelopeAmplitude(t: number): number {\n  const center = (ENVELOPE_MIN + ENVELOPE_MAX) / 2;\n  const halfRange = (ENVELOPE_MAX - ENVELOPE_MIN) / 2;\n  return center + halfRange * Math.sin((2 * Math.PI * t) / ENVELOPE_PERIOD_S);\n}\n\nfunction driveAngle(t: number): number {\n  return 2 * Math.PI * (t / DRIVE_PERIOD_S - DRIVE_PHASE_OFFSET);\n}\n\n/** ascending branch: H rising, B lags \"behind\" toward +Hc */\nfunction bAscending(h: number): number {\n  return B_SAT * Math.tanh(K_STEEPNESS * (h - COERCIVITY_HC));\n}\n\n/** descending branch: H falling, B lags \"behind\" toward -Hc */\nfunction bDescending(h: number): number {\n  return B_SAT * Math.tanh(K_STEEPNESS * (h + COERCIVITY_HC));\n}\n\n/** Full closed loop, LOOP_POINTS samples, for the current envelope amplitude. */\nfunction buildLoop(amp: number): { h: number; b: number }[] {\n  const half = LOOP_POINTS / 2;\n  const pts: { h: number; b: number }[] = [];\n  for (let i = 0; i <= half; i++) {\n    const h = -amp + (2 * amp * i) / half;\n    pts.push({ h, b: bAscending(h) });\n  }\n  for (let i = 0; i <= half; i++) {\n    const h = amp - (2 * amp * i) / half;\n    pts.push({ h, b: bDescending(h) });\n  }\n  return pts;\n}\n\n/** Current marker position: same math as the loop branches, evaluated\n * directly at the drive's current H and sweep direction. */\nfunction markerPoint(t: number, amp: number): { h: number; b: number } {\n  const angle = driveAngle(t);\n  const h = amp * Math.sin(angle);\n  const rising = Math.cos(angle) >= 0;\n  const b = rising ? bAscending(h) : bDescending(h);\n  return { h, b };\n}\n\nexport interface BiasHysteresisProps {\n  /** accessible name for the reading, e.g. \"Input drive\" */\n  label?: string;\n  /** canvas panel height in px. Default 220. */\n  height?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function BiasHysteresis({\n  label = \"Bias saturation\",\n  height = 220,\n  className = \"\",\n}: BiasHysteresisProps) {\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 = \"\";\n    let border = \"\";\n\n    // fallbacks are CSS keywords, never literal colour values\n    const readTokens = () => {\n      const root = getComputedStyle(document.documentElement);\n      fg = root.getPropertyValue(\"--foreground\").trim() || \"currentColor\";\n      border = root.getPropertyValue(\"--border\").trim() || \"currentColor\";\n    };\n\n    let w = 0;\n    let h = 0;\n    let sized = false;\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      w = rect.width;\n      h = rect.height;\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      sized = true;\n    };\n\n    const draw = (t: number) => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, w, h);\n\n      const side = Math.min(w, h) * SQUARE_FRACTION;\n      const cx = w / 2;\n      const cy = h / 2;\n      const xFor = (hVal: number) => cx + (hVal / H_DOMAIN) * (side / 2);\n      const yFor = (bVal: number) => cy - (bVal / B_DOMAIN) * (side / 2);\n\n      // axis crosshair — separator only, never the loop's own line\n      ctx.strokeStyle = border;\n      ctx.lineWidth = 1;\n      ctx.globalAlpha = 1;\n      ctx.beginPath();\n      ctx.moveTo(cx - side / 2, cy);\n      ctx.lineTo(cx + side / 2, cy);\n      ctx.moveTo(cx, cy - side / 2);\n      ctx.lineTo(cx, cy + side / 2);\n      ctx.stroke();\n\n      const amp = envelopeAmplitude(t);\n      const loop = buildLoop(amp);\n\n      // enclosed hysteresis area — the \"energy lost to saturation\" — as a\n      // very low-opacity foreground fill, never a hue\n      ctx.beginPath();\n      loop.forEach((p, i) => {\n        const x = xFor(p.h);\n        const y = yFor(p.b);\n        if (i === 0) ctx.moveTo(x, y);\n        else ctx.lineTo(x, y);\n      });\n      ctx.closePath();\n      ctx.fillStyle = fg;\n      ctx.globalAlpha = AREA_FILL_ALPHA;\n      ctx.fill();\n\n      // the loop stroke itself\n      ctx.globalAlpha = 1;\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = 1.5;\n      ctx.beginPath();\n      loop.forEach((p, i) => {\n        const x = xFor(p.h);\n        const y = yFor(p.b);\n        if (i === 0) ctx.moveTo(x, y);\n        else ctx.lineTo(x, y);\n      });\n      ctx.closePath();\n      ctx.stroke();\n\n      // marker riding the loop's edge — the one thing to follow\n      const marker = markerPoint(t, amp);\n      ctx.beginPath();\n      ctx.arc(xFor(marker.h), yFor(marker.b), MARKER_RADIUS_PX, 0, Math.PI * 2);\n      ctx.fillStyle = fg;\n      ctx.globalAlpha = 1;\n      ctx.fill();\n    };\n\n    // -- loop ----------------------------------------------------------------\n    let raf = 0;\n    let last = 0;\n    let globalT = 0;\n\n    const loop = (now: number) => {\n      const dtMs = last ? Math.min(250, now - last) : 1000 / 60;\n      last = now;\n      globalT += dtMs / 1000;\n      draw(globalT);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      draw(reduced ? FREEZE_T : globalT);\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\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        readTokens();\n        resize();\n        draw(reduced ? FREEZE_T : globalT);\n      }, 150);\n    };\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        const visible = entries[0]?.isIntersecting;\n        if (visible && !reduced && sized) {\n          cancelAnimationFrame(raf);\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        } else if (!visible) {\n          cancelAnimationFrame(raf);\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      cancelAnimationFrame(raf);\n      if (!document.hidden && !reduced && sized) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    // no paint before the first token read\n    readTokens();\n    resize();\n\n    if (reduced) {\n      draw(FREEZE_T);\n    } else {\n      draw(0);\n      raf = requestAnimationFrame(loop);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [height]);\n\n  return (\n    <div className={className}>\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <span className=\"font-mono text-[11px] tracking-wide text-ns-muted\">\n          {label.toUpperCase()}\n        </span>\n        <span className=\"font-mono text-[11px] tracking-wide text-ns-muted\">H / B LOOP</span>\n      </div>\n      <div role=\"img\" aria-label={`${label}: a closed hysteresis loop, widening and rounding as drive climbs toward saturation`} className=\"mt-2\">\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"block w-full\"\n          style={{ height }}\n        />\n      </div>\n      <div className=\"mt-1.5 flex items-center justify-between font-mono text-[11px] text-ns-muted\">\n        <span>Hc {COERCIVITY_HC.toFixed(2)}</span>\n        <span>Bsat {B_SAT.toFixed(2)}</span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/bias-hysteresis.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "meter",
      "gauge",
      "saturation",
      "hysteresis",
      "tape",
      "analog",
      "canvas",
      "monochrome"
    ],
    "instruction": "Build <BiasHysteresis label? height? className?> as a card-scale <canvas> panel, a drop-in saturation/headroom gauge that plots a closed H (drive field, x) vs. B (flux density, y) hysteresis loop instead of a bar or dial. SOURCE: AC-bias magnetic tape recording linearizes an inherently nonlinear medium by superimposing a high-frequency bias signal on the audio; the tape's flux density B vs. drive field H traces a hysteresis loop, not a straight line (Jiles-Atherton hysteresis model; documented on any pro reel-to-reel service manual, e.g. Studer A80 / Ampex ATR bias alignment procedures) — as input climbs toward and past 0dB the loop widens and its corners round off toward saturation, the mechanism behind analog tape's 'soft clip' character, colloquially 'tape saturation'. MATH: each branch is B = Bsat*tanh(k*(H -+ Hc)) with Bsat=0.92, Hc=0.18 (coercivity), k=2.4 (steepness constant controlling corner rounding) — the ascending branch (H rising) uses B = Bsat*tanh(k*(H-Hc)), the descending branch (H falling) uses B = Bsat*tanh(k*(H+Hc)); using the opposite sign per sweep direction is what opens the two tanh curves into a single closed lobed loop rather than one line. The full loop is rebuilt every frame at LOOP_POINTS=240 resolution (120 ascending-branch samples spanning H from -amp to +amp, 120 descending-branch samples spanning +amp back to -amp, closed into one path), never cached, because its own envelope amplitude changes continuously. DRIVE: H oscillates as a slow LFO, period 8.3s (0.12Hz, DRIVE_PERIOD_S), H = amp*sin(2*PI*(t/8.3 - 0.12)) — the 0.12-cycle phase offset exists purely so the reduced-motion freeze frame (below) lands on a specific, chosen state, not an arbitrary one. ENVELOPE: the loop's own saturation amount breathes on an independent 21s cycle (ENVELOPE_PERIOD_S), amp = 1.0 + 0.4*sin(2*PI*t/21), modulating peak H amplitude between 0.6 and 1.4 — this is what makes the loop's overall width/roundedness visibly different across a five-second sample, not just the marker's position on a fixed loop: narrow and sharp near the 0.6 trough, wide and rounded near the 1.4 peak. MARKER: computed directly from the same branch formulas at the drive's current H and sweep direction (ascending if cos(angle) >= 0, else descending) rather than searched out of the loop's point array — one full lobe traversal every 8.3s, ~29 points/second equivalent smoothness, nowhere near paint-rate aliasing risk; this is the ONE thing a viewer should follow, and 8.3s per lap is slow enough to track continuously with the eye, never a discrete jump. REAL BIAS FREQUENCY: professional decks bias at roughly 150kHz — documented here in this instruction and in the component's own code comments only, never rendered 1:1 (that would alias against a ~60Hz paint rate into a strobe, the exact round-9 legibility failure mode this registry has already shipped once) — the rendered drive rate is a decoupled, deliberately slow 8.3s sweep instead. GEOMETRY: the loop is plotted in a square region of side min(containerWidth, containerHeight) * 0.7, centered in the canvas, so it holds its shape at card scale regardless of the panel's own aspect ratio; H maps across an H_DOMAIN of +-1.5 (tight padding beyond the 1.4 max envelope amplitude, deliberately kept close so the trough-amplitude loop at amp=0.6 still reads as a lens with real horizontal width instead of a tall sliver dominated by the fixed ~0.75 vertical coercivity gap) and B across a B_DOMAIN of +-1.05 (padding beyond Bsat), each independently scaled to the square's half-side. RENDER ORDER per frame: clear, draw a thin border-token axis crosshair (vertical H=0, horizontal B=0) first so it always sits under the loop, then fill the closed loop path at 0.05 foreground-alpha (the enclosed hysteresis area, read as 'energy lost to saturation'), then stroke the same closed path at full foreground opacity, then paint the marker as a filled foreground circle (radius 3.5px) on top. TOKENS: fg/border are read once via getComputedStyle(document.documentElement) against --foreground/--border before the first paint (no paint before the first read), re-derived on a documentElement class MutationObserver so a theme flip repaints live; the loop stroke is --foreground at full opacity, the axis crosshair is --border (a separator only, never the loop's own line, never a fill), the enclosed-area fill is a very low-opacity (0.05) --foreground tint — never a hue anywhere, and light theme is the harder case for that low-opacity fill (verify it doesn't visually disappear against a near-white --background before shipping). --ns-accent never appears: there is no interactive affordance in this build (a hover-driven 'current drive level' readout could be added later, but it must never recolor the loop stroke or fill with accent — accent stays reserved for a focus ring on interactive chrome only, never the loop itself). RESTING LOOP: t0 — marker somewhere on the loop, loop at whatever phase of its 21s envelope-breath it started at; 2.5s — marker has traveled roughly 30% of one lobe, loop width has shifted measurably (~12% of a full 21s breath elapsed); 5s — marker has crossed into the opposite lobe, loop shape visibly wider or narrower than at t0. Unbounded, zero input, alive at rest. REDUCED MOTION: freezes at drive-cycle phase 0.62 (FREEZE_T = 0.62 * 8.3s = 5.146s) — chosen because with the 0.12-cycle DRIVE_PHASE_OFFSET applied, H is descending through zero at exactly that instant while the 21s envelope sits within a hair of its own peak amplitude (5.146s / 21s ~= 0.245 of the envelope's own sine, ~=0.9993 of its peak). At H=0 the marker sits at the widest vertical separation between the two branches (the coercivity gap, ~0.75 of the plotted B range) with the loop itself at peak envelope amplitude — the single frame that shows the full open loop at its widest, rather than a thin near-origin sliver at a small envelope amplitude. Checked via matchMedia('(prefers-reduced-motion: reduce)') at mount, no live-change listener needed since this component has no other motion state to reconcile. LIFECYCLE: canvas sized via getBoundingClientRect with devicePixelRatio (capped at 2) applied through ctx.setTransform, resize handled by a debounced ResizeObserver (150ms), the rAF loop paused by an IntersectionObserver when off-screen and by document.hidden via a visibilitychange listener (both resume with a fresh last-timestamp, no giant delta-time jump), and every rAF/observer/listener is torn down on unmount. A11Y: the canvas itself is aria-hidden (decorative raster); it is wrapped in a role=img element carrying an aria-label summarizing the reading in prose ('a closed hysteresis loop, widening and rounding as drive climbs toward saturation') since a continuously-reshaping loop has no single scalar aria-valuenow to expose the way an ordinary meter would. Below the label row, a static Geist Mono caption row reads the two REAL NUMBERS that never change — 'Hc 0.18' / 'Bsat 0.92' — so those constants are legible from a single still frame independent of motion. No canvas colour literal, no dependency: 2D canvas, DOM+canvas+CSS only. Props: label (accessible name and visible caption, default 'Bias saturation'), height (canvas panel height px, default 220), className."
  },
  "type": "registry:ui"
}