{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jominy-quench",
  "title": "Jominy Quench",
  "description": "A hardenability comparison card built on the real ASTM A255 / ISO 642 Jominy end-quench test: a water jet sprays one end of a heated steel bar continuously, a directional quench front creeps up the bar, and hardness falls off with distance from that face along a curve that is each alloy's signature, drawn beside a real numeric table so the comparison never lives only in the picture.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/jominy-quench/component.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// JominyQuench — a hardenability comparison card built on the real ASTM A255\n// / ISO 642 Jominy end-quench test: a bar is austenitised, then a water jet is\n// sprayed against ONE end only. The quenched face cools fastest and hardens\n// most; hardness falls off with distance from that face along a curve that is\n// the alloy's signature. Comparing several bars means comparing several\n// fall-off curves — that IS the mechanic, not a metaphor bolted onto one.\n//\n// Each row runs the same fixed cycle: the bar reheats to a uniform glow, a\n// quench front creeps left-to-right across it while the water jet continuously\n// sprays the left face (a steam plume that never stops), the fall-off curve\n// above the bar draws in step with the front, then a hold at full quench\n// before the next reheat. Rows are phase-staggered so the cascade never\n// settles into one static pose.\n//\n// Distinctness: weld-pool is a continuously MOLTEN free surface with no\n// directional solidification front; kamacite-etch reveals an already-formed\n// static crystal lattice with no cooling physics at all. This is a\n// directional, one-axis COOLING front with a hardness fall-off curve as its\n// output — a different physical process from both.\n//\n// LUMINANCE ADAPTATION: the real test is intensely hue-coded (quenched steel\n// runs white -> yellow -> orange -> red -> black as it cools) but this\n// registry is five value-only tokens with no orange. Temperature is mapped to\n// luminance instead: the hottest, most salient point on the bar is always\n// closest to --foreground (the token with maximum contrast against\n// --background) and the coldest, quenched point always fades toward\n// --background. That mapping self-inverts correctly between themes, because\n// \"closest to --foreground\" already means \"near-white ink on a dark ground\"\n// in dark theme and \"near-black ink on a light ground\" in light theme — hot\n// is unambiguously the salient value in both.\n//\n// Colour is read once via getComputedStyle(document.documentElement) and\n// re-read on a MutationObserver watching documentElement's class, matching\n// every other canvas component in this registry (duplicated here on purpose —\n// no shared lib/ helper). Direct-DOM rAF, DPR-capped backing store,\n// IntersectionObserver + visibilitychange pause. --ns-accent appears only as\n// the hover/focus highlight on a compared bar's row — never in the resting\n// glow ramp.\n// ---------------------------------------------------------------------------\n\nexport interface JominyBar {\n  id: string;\n  /** alloy / grade name, e.g. \"4140\" */\n  name: string;\n  /**\n   * Rockwell-C hardness at each of the nine standard Jominy distances\n   * (DISTANCE_LABELS: 1/16\" through 2\"), quenched face first. Supply fewer\n   * values than the header shows and the table renders that many cells\n   * against the full nine-column header — pass one entry per label to\n   * avoid a mismatched row.\n   */\n  hardness: number[];\n}\n\nconst DISTANCES_16THS = [1, 2, 4, 6, 8, 12, 16, 24, 32];\nconst DISTANCE_LABELS = DISTANCES_16THS.map((n) =>\n  n < 16 ? `${n}/16″` : `${n / 16}″`\n);\n\n// Illustrative Jominy-curve shapes (plain-carbon vs. low/medium alloy vs.\n// spring steel) — the real published J-curves for these grades vary by heat\n// and mill; these are shaped to the well-documented qualitative behavior\n// (more alloy content = flatter fall-off) rather than lifted from one cert\n// sheet, and are labelled as such in meta.json.\nconst DEFAULT_BARS: JominyBar[] = [\n  { id: \"1045\", name: \"1045\", hardness: [55, 50, 36, 28, 24, 22, 21, 20, 20] },\n  { id: \"5160\", name: \"5160\", hardness: [58, 55, 48, 42, 37, 32, 29, 26, 24] },\n  { id: \"8640\", name: \"8640\", hardness: [56, 55, 53, 51, 49, 46, 44, 41, 38] },\n  { id: \"4140\", name: \"4140\", hardness: [58, 56, 53, 50, 47, 43, 40, 36, 33] },\n];\n\n// cycle phase fractions (of CYCLE_MS): reheat, quench-creep, hold\nconst RISE_FRAC = 0.1;\nconst QUENCH_FRAC = 0.58;\n// HOLD_FRAC = 1 - RISE_FRAC - QUENCH_FRAC = 0.32\nconst CYCLE_MS = 7200;\nconst PHASE_STAGGER = 0.16; // per-row offset, decorrelates the cascade\n\nconst COLD_BASE = 0.15; // heat level immediately behind the front — still a hole, but not invisible\nconst RESIDUAL_ASYMPTOTE = 0.9; // heat level the ahead-of-front zone recovers to, short of full furnace heat\nconst DIP_DEPTH = 0.35; // how far below the asymptote the zone right at the front dips\nconst DIP_WIDTH = 0.14; // fraction of bar length the dip recovers over, ahead of the front\nconst FRONT_BLEND = 0.05; // fraction of bar length the front edge is soft over\nconst STATIC_FRONT = 0.55; // reduced-motion freeze: partway up the bar\nconst STATIC_CURVE_REVEAL = 1; // ...with the full signature curve drawn\n\ntype RGB = [number, number, number];\n\nfunction parseHex(raw: string): RGB | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction lerpRGB(a: RGB, b: RGB, t: number): RGB {\n  const c = Math.max(0, Math.min(1, t));\n  return [a[0] + (b[0] - a[0]) * c, a[1] + (b[1] - a[1]) * c, a[2] + (b[2] - a[2]) * c];\n}\n\nfunction rgbStr([r, g, b]: RGB, a = 1): string {\n  return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a})`;\n}\n\nfunction smoothstep(t: number): number {\n  const c = Math.max(0, Math.min(1, t));\n  return c * c * (3 - 2 * c);\n}\n\n// Heat level 0..1 at bar-fraction x for a given front position, ignoring the\n// reheat blend — this is the \"just been quenched to here\" profile: cold\n// behind the front, a soft transition band, then a dip right at the boundary\n// (the metal there has only just started losing heat to the jet) that\n// recovers toward RESIDUAL_ASYMPTOTE as x moves away from the front — i.e.\n// the unquenched material farthest from where cooling is currently happening\n// is the material that has lost the least heat, which is the residual glow\n// the far end is meant to retain.\nfunction frontHeat(x: number, front: number): number {\n  const d = x - front;\n  if (d <= -FRONT_BLEND) return COLD_BASE;\n  const da = Math.max(0, d);\n  const aheadVal = RESIDUAL_ASYMPTOTE - DIP_DEPTH * Math.exp(-da / DIP_WIDTH);\n  if (d >= FRONT_BLEND) return aheadVal;\n  const s = smoothstep((d + FRONT_BLEND) / (2 * FRONT_BLEND));\n  return COLD_BASE + (aheadVal - COLD_BASE) * s;\n}\n\ntype Phase = { front: number; curveReveal: number; reheat: number };\n\nfunction phaseAt(u: number): Phase {\n  const t = ((u % 1) + 1) % 1;\n  if (t < RISE_FRAC) {\n    const q = smoothstep(t / RISE_FRAC);\n    return { front: 1, curveReveal: 1 - q, reheat: q };\n  }\n  const t2 = t - RISE_FRAC;\n  if (t2 < QUENCH_FRAC) {\n    const q = smoothstep(t2 / QUENCH_FRAC);\n    return { front: q, curveReveal: q, reheat: 0 };\n  }\n  return { front: 1, curveReveal: 1, reheat: 0 };\n}\n\n// During \"rise\" (reheat), the whole bar blends from wherever the last\n// quench left it back toward a uniform hot glow, so the bar never snaps —\n// it visibly warms back up before the next front starts creeping.\nfunction heatAt(x: number, ph: Phase): number {\n  const front = frontHeat(x, ph.front);\n  return ph.reheat > 0 ? front + (1 - front) * ph.reheat : front;\n}\n\nexport interface JominyQuenchProps {\n  /** bars being compared, quenched-face-first hardness per bar */\n  bars?: JominyBar[];\n  /** accessible title for the comparison */\n  title?: string;\n  className?: string;\n}\n\nexport function JominyQuench({\n  bars = DEFAULT_BARS,\n  title = \"Jominy end-quench hardenability\",\n  className = \"\",\n}: JominyQuenchProps) {\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const hoverRef = useRef<number>(-1);\n  // set only under prefers-reduced-motion, where nothing else repaints the\n  // canvas — lets hover/focus still redraw the accent highlight on demand\n  const hoverRedrawRef = useRef<(() => void) | null>(null);\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const n = bars.length;\n    const maxHRC = Math.max(1, ...bars.flatMap((b) => b.hardness)) * 1.08;\n    const nPts = Math.max(2, ...bars.map((b) => b.hardness.length));\n\n    let width = 0;\n    let rowH = 0;\n    let dpr = 1;\n    let padX = 26;\n\n    // Token fields start empty and are only ever assigned from a live\n    // getComputedStyle read — never a hardcoded fallback — so a missing\n    // token fails loud (drawFrame bails) instead of silently painting a\n    // baked-in white.\n    let bgC: RGB | null = null;\n    let fgC: RGB | null = null;\n    let mutedC: RGB | null = null;\n    let accentC: RGB | null = null;\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const get = (name: string) => cs.getPropertyValue(name).trim();\n      bgC = parseHex(get(\"--background\"));\n      fgC = parseHex(get(\"--foreground\"));\n      mutedC = parseHex(get(\"--ns-muted\"));\n      accentC = parseHex(get(\"--ns-accent\"));\n    };\n    readTokens();\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      width = rect.width;\n      // rowH derives from the container's own width (the reliably-available\n      // \"small dimension\" for a full-width stacked-row card): clamped so the\n      // bar, curve and plume stay legible at small preview-card widths\n      // instead of shrinking to illegibility or ballooning on a wide column.\n      rowH = Math.max(48, Math.min(84, width * 0.15));\n      // the plume's footprint (jet length + puff radius) scales with bar\n      // thickness, which scales with rowH — a fixed left margin clips the\n      // jet/puffs off-canvas at large rowH, so the margin is derived from\n      // the same thickness the plume actually draws at.\n      const thick = Math.max(5, Math.min(13, rowH * 0.15));\n      padX = Math.max(26, thick * 3.4);\n      const height = rowH * n;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.style.height = `${height}px`;\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n    };\n    resize();\n\n    let raf = 0;\n    let visible = true;\n    let t0 = performance.now();\n\n    const drawPlume = (cx: number, cy: number, thick: number, tSec: number, fg: RGB) => {\n      // continuous jet + steam plume at the quenched face — never stops, not\n      // tied to the quench-cycle phase, only to elapsed real time\n      const jetLen = thick * 2.6;\n      ctx.strokeStyle = rgbStr(fg, 0.28);\n      ctx.lineWidth = Math.max(1, thick * 0.22);\n      ctx.beginPath();\n      const jitter = Math.sin(tSec * 26) * 1.1 + Math.sin(tSec * 41 + 1.7) * 0.6;\n      ctx.moveTo(cx - jetLen, cy - thick * 1.6 + jitter);\n      ctx.lineTo(cx, cy);\n      ctx.stroke();\n\n      const puffs = 4;\n      for (let i = 0; i < puffs; i++) {\n        const ph = (tSec * 0.55 + i / puffs) % 1;\n        const rise = ph * thick * 3.2;\n        const wob = Math.sin(tSec * 3.1 + i * 2.3) * thick * 0.35;\n        const r = thick * (0.35 + ph * 0.55);\n        const a = 0.16 * (1 - ph);\n        ctx.fillStyle = rgbStr(fg, a);\n        ctx.beginPath();\n        ctx.arc(cx - jetLen * 0.35 + wob, cy - thick * 1.3 - rise, r, 0, Math.PI * 2);\n        ctx.fill();\n      }\n    };\n\n    const drawRow = (\n      i: number,\n      ph: Phase,\n      isStatic: boolean,\n      bg: RGB,\n      fg: RGB,\n      muted: RGB,\n      accent: RGB\n    ) => {\n      const bar = bars[i];\n      const rowTop = i * rowH;\n      const curveTop = rowTop + rowH * 0.08;\n      const curveBaseline = rowTop + rowH * 0.56;\n      const curveMaxH = curveBaseline - curveTop;\n      const barThick = Math.max(5, Math.min(13, rowH * 0.15));\n      const barY = curveBaseline + barThick * 1.35;\n      const barLeft = padX;\n      const barRight = width - padX * 0.6;\n      const barLen = Math.max(1, barRight - barLeft);\n\n      const hovered = hoverRef.current === i;\n\n      // bar fill: horizontal gradient sampled from the heat profile so the\n      // cooled/quenched zone visibly recedes toward --background and the\n      // still-hot zone reads as the salient --foreground-leaning value\n      const grad = ctx.createLinearGradient(barLeft, 0, barRight, 0);\n      const STOPS = 20;\n      for (let s = 0; s <= STOPS; s++) {\n        const x = s / STOPS;\n        const heat = heatAt(x, ph);\n        grad.addColorStop(x, rgbStr(lerpRGB(bg, fg, heat)));\n      }\n      ctx.fillStyle = grad;\n      const r = barThick / 2;\n      ctx.beginPath();\n      ctx.moveTo(barLeft + r, barY - r);\n      ctx.arcTo(barRight, barY - r, barRight, barY + r, r);\n      ctx.arcTo(barRight, barY + r, barLeft, barY + r, r);\n      ctx.arcTo(barLeft, barY + r, barLeft, barY - r, r);\n      ctx.arcTo(barLeft, barY - r, barRight, barY - r, r);\n      ctx.closePath();\n      ctx.fill();\n      ctx.strokeStyle = hovered ? rgbStr(accent, 0.85) : rgbStr(fg, 0.22);\n      ctx.lineWidth = hovered ? 1.6 : 1;\n      ctx.stroke();\n\n      // quench front marker — only visible mid-creep, the literal directional\n      // cooling boundary this mechanic is named for\n      if (ph.front > 0.004 && ph.front < 0.996) {\n        const fx = barLeft + barLen * ph.front;\n        ctx.strokeStyle = rgbStr(fg, 0.45);\n        ctx.lineWidth = 1;\n        ctx.beginPath();\n        ctx.moveTo(fx, curveBaseline);\n        ctx.lineTo(fx, barY + barThick);\n        ctx.stroke();\n      }\n\n      // fall-off curve — this alloy's signature, drawn point-to-point,\n      // clipped to curveReveal so it fills in as the front reaches each x\n      const pts: [number, number][] = bar.hardness.map((h, k) => {\n        const x = barLeft + (barLen * k) / (nPts - 1);\n        const y = curveBaseline - (h / maxHRC) * curveMaxH * 0.94;\n        return [x, y];\n      });\n      const revealX = barLeft + barLen * ph.curveReveal;\n\n      ctx.beginPath();\n      ctx.moveTo(pts[0][0], curveBaseline);\n      let leadX = pts[0][0];\n      let leadY = pts[0][1];\n      ctx.lineTo(leadX, leadY);\n      for (let k = 1; k < pts.length; k++) {\n        const [px, py] = pts[k];\n        if (px <= revealX) {\n          ctx.lineTo(px, py);\n          leadX = px;\n          leadY = py;\n        } else {\n          const [ppx, ppy] = pts[k - 1];\n          const seg = (revealX - ppx) / Math.max(1e-3, px - ppx);\n          if (seg > 0) {\n            leadX = ppx + (px - ppx) * seg;\n            leadY = ppy + (py - ppy) * seg;\n            ctx.lineTo(leadX, leadY);\n          }\n          break;\n        }\n      }\n      ctx.lineTo(leadX, curveBaseline);\n      ctx.closePath();\n      ctx.fillStyle = rgbStr(muted, hovered ? 0.14 : 0.08);\n      ctx.fill();\n\n      ctx.beginPath();\n      ctx.moveTo(pts[0][0], pts[0][1]);\n      let lx = pts[0][0];\n      let ly = pts[0][1];\n      for (let k = 1; k < pts.length; k++) {\n        const [px, py] = pts[k];\n        if (px <= revealX) {\n          ctx.lineTo(px, py);\n          lx = px;\n          ly = py;\n        } else {\n          const [ppx, ppy] = pts[k - 1];\n          const seg = (revealX - ppx) / Math.max(1e-3, px - ppx);\n          if (seg > 0) {\n            lx = ppx + (px - ppx) * seg;\n            ly = ppy + (py - ppy) * seg;\n            ctx.lineTo(lx, ly);\n          }\n          break;\n        }\n      }\n      ctx.strokeStyle = rgbStr(fg, hovered ? 1 : 0.85);\n      ctx.lineWidth = hovered ? 2 : 1.4;\n      ctx.stroke();\n\n      // the \"pen\" — a lead dot at the point currently being drawn, only while\n      // actively creeping (not once the curve is fully settled)\n      if (ph.curveReveal > 0.003 && ph.curveReveal < 0.997) {\n        ctx.fillStyle = rgbStr(fg, 0.9);\n        ctx.beginPath();\n        ctx.arc(lx, ly, 2.3, 0, Math.PI * 2);\n        ctx.fill();\n      }\n\n      const tSec = isStatic ? 0 : (performance.now() - t0) / 1000;\n      drawPlume(barLeft, barY, barThick, tSec, fg);\n    };\n\n    const drawFrame = (isStatic: boolean) => {\n      // tokens are only ever assigned from a live read (never a hardcoded\n      // fallback) — a genuinely missing token bails the paint rather than\n      // silently substituting a baked-in colour.\n      if (!bgC || !fgC || !mutedC || !accentC) return;\n      const bg = bgC;\n      const fg = fgC;\n      const muted = mutedC;\n      const accent = accentC;\n\n      ctx.save();\n      ctx.scale(dpr, dpr);\n      ctx.clearRect(0, 0, width, rowH * n);\n      ctx.fillStyle = rgbStr(bg);\n      ctx.fillRect(0, 0, width, rowH * n);\n\n      const now = isStatic ? 0 : (performance.now() - t0) / CYCLE_MS;\n      for (let i = 0; i < n; i++) {\n        const ph = isStatic\n          ? { front: STATIC_FRONT, curveReveal: STATIC_CURVE_REVEAL, reheat: 0 }\n          : phaseAt(now + i * PHASE_STAGGER);\n        drawRow(i, ph, isStatic, bg, fg, muted, accent);\n      }\n      ctx.restore();\n    };\n\n    const loop = () => {\n      drawFrame(false);\n      raf = requestAnimationFrame(loop);\n    };\n\n    if (reduced) {\n      drawFrame(true);\n      hoverRedrawRef.current = () => drawFrame(true);\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      drawFrame(reduced);\n    });\n    ro.observe(root);\n\n    const io = new IntersectionObserver((entries) => {\n      const vis = entries[0]?.isIntersecting ?? true;\n      if (vis === visible) return;\n      visible = vis;\n      if (reduced) return;\n      if (!visible && raf) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      } else if (visible && !raf) {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(root);\n\n    const onVis = () => {\n      if (reduced) return;\n      if (document.hidden && raf) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      } else if (!document.hidden && visible && !raf) {\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      drawFrame(reduced);\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    return () => {\n      if (raf) cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      hoverRedrawRef.current = null;\n    };\n    // bars/n/maxHRC are derived once at mount from the bars prop, matching\n    // every other canvas component's closure-locals convention\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [bars]);\n\n  const setHover = (i: number) => {\n    hoverRef.current = i;\n    hoverRedrawRef.current?.();\n  };\n  const clearHover = (i: number) => {\n    if (hoverRef.current === i) hoverRef.current = -1;\n    hoverRedrawRef.current?.();\n  };\n\n  return (\n    <div ref={rootRef} className={`w-full text-foreground ${className}`.trim()}>\n      {/* no bg-surface here — the canvas fills --background itself every\n          frame, and --surface is a different token, so a wrapper fill would\n          leave a visible seam inside the border */}\n      <div className=\"relative w-full overflow-hidden rounded-md border border-border\">\n        <canvas ref={canvasRef} aria-hidden className=\"block w-full\" />\n      </div>\n\n      <table className=\"mt-4 w-full border-collapse\">\n        <caption className=\"sr-only\">\n          {title} — Rockwell C hardness at each distance from the quenched face\n        </caption>\n        <thead>\n          <tr className=\"border-b border-border\">\n            <th scope=\"col\" className=\"py-1.5 pr-2 text-left text-[11px] font-medium uppercase tracking-[0.08em] text-ns-muted\">\n              Grade\n            </th>\n            {DISTANCE_LABELS.map((d) => (\n              <th\n                key={d}\n                scope=\"col\"\n                className=\"py-1.5 px-1.5 text-right font-mono text-[10px] font-normal text-ns-muted\"\n              >\n                {d}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {bars.map((bar, i) => (\n            <tr key={bar.id} className=\"border-b border-border/60 last:border-b-0\">\n              <th scope=\"row\" className=\"py-1.5 pr-2 text-left font-normal\">\n                <button\n                  type=\"button\"\n                  onPointerEnter={() => setHover(i)}\n                  onPointerLeave={() => clearHover(i)}\n                  onFocus={() => setHover(i)}\n                  onBlur={() => clearHover(i)}\n                  className=\"rounded-sm text-[13px] leading-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                >\n                  {bar.name}\n                </button>\n              </th>\n              {bar.hardness.map((h, k) => (\n                <td key={k} className=\"py-1.5 px-1.5 text-right font-mono text-[11px] tabular-nums text-foreground/90\">\n                  {h}\n                  <span className=\"sr-only\"> HRC</span>\n                </td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\nexport default JominyQuench;\n",
      "type": "registry:ui",
      "target": "components/ui/jominy-quench.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "comparison",
      "chart",
      "benchmark",
      "canvas",
      "metallurgy",
      "table",
      "spec-sheet"
    ],
    "instruction": "Build <JominyQuench bars? title? className?> where bars: JominyBar[] ({id, name, hardness: number[]}) each hold Rockwell-C hardness readings at nine standard Jominy distances from the quenched face (1/16\" to 2\", DISTANCE_LABELS). This is the real ASTM A255 / ISO 642 end-quench hardenability test: a bar is austenitised, then a water jet is sprayed against ONE end only; that face cools fastest and hardens most, and hardness falls off with distance along a curve that is the alloy's signature — comparing several bars means comparing several fall-off curves, which is the mechanic, not a metaphor. Default bars (1045, 5160, 8640, 4140) are shaped to the well-documented qualitative behavior — more alloy content flattens the fall-off — rather than lifted from one mill certificate, and are labelled as illustrative in the demo copy. LUMINANCE ADAPTATION (read this before touching the palette): the real test is intensely hue-coded, quenched steel running white -> yellow -> orange -> red -> black as it cools, but this registry has five value-only tokens and no orange. Temperature is deliberately mapped to LUMINANCE instead: the hottest point on a bar is always blended toward --foreground (maximum contrast against --background) and the coldest, just-quenched point always fades toward --background — a mapping that self-inverts correctly between light and dark theme because 'closest to --foreground' already means the salient value in either polarity, never a literal hue swap. ONE canvas runs all rows: a fixed 7.2s cycle per row (reheat 10%, quench-front creep 58%, hold 32%), phase-staggered by 16% per row so the cascade of bars never settles into one static pose — visibly different at t0, 2.5s and 5s with no input. Each row draws, left to right: a horizontal bar whose fill is a per-frame linear gradient sampled from the heat-at-x function (cold/quenched reads background-leaning, hot/unquenched reads foreground-leaning, with a soft residual gradient toward the far end, never a hard cliff); a vertical front-line marker at the current quench-front x, visible only mid-creep; and, above the bar, that alloy's hardness fall-off curve, revealed left-to-right in lockstep with the front position so the curve visibly draws itself as the quench proceeds, with a small lead dot at the point currently being traced. At the quenched face a continuous water-jet stroke plus four looping steam-plume puffs run on real elapsed time, independent of the quench-cycle phase — the jet never stops hitting the face, at rest or mid-cycle. Row geometry (bar thickness, curve band height, plume size) derives from rowH = clamp(48, 84, containerWidth * 0.15) so the mechanic stays legible at small preview-card widths rather than reading as a thin illegible sliver. Colour is read via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class (duplicated per-component per house convention, no shared lib/ helper); --ns-accent appears ONLY as the hover/focus highlight stroke on a compared bar's row (triggered by pointerenter/focus on that row's real <button> grade label) — never mixed into the resting heat ramp. Direct-DOM rAF, DPR-capped backing store (min(2, devicePixelRatio)), pauses on IntersectionObserver (root offscreen) and document visibilitychange. ACCESSIBILITY: the canvas is aria-hidden and purely decorative; every value the comparison depends on is real DOM text in a <table> beneath it — one row per bar with a <th scope=row> button (the same element that drives the hover highlight) and one <td> per distance holding the literal HRC number plus a sr-only ' HRC' unit, so a screen-reader user gets the full comparison independent of the canvas. prefers-reduced-motion freezes on a deliberately composed frame — front parked at 55% up the bar (so the directional-cooling mechanic is still visible) with the fall-off curve forced to FULLY drawn regardless of front position (bypassing the normal front-tied reveal specifically so the frozen frame shows both the mechanic and the complete signature curve at once) and the water jet/plume drawn once at a fixed pose, never animated. Props: bars (JominyBar[], default four illustrative grades), title (accessible caption for the table), className."
  },
  "type": "registry:ui"
}