Skip to main content

ns-ui / loud

Jominy Quench

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.

Usage

registry/loud/jominy-quench/demo.tsx
"use client";

import { JominyQuench } from "./component";

export default function JominyQuenchDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background px-6 py-16">
      <div className="w-full max-w-2xl">
        <p className="mb-10 text-center font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / jominy-quench
        </p>
        <div className="mx-auto mb-10 max-w-xl text-center">
          <h1 className="text-4xl font-semibold tracking-tight text-foreground">
            Which grade hardens harder, deeper
          </h1>
          <p className="mt-3 text-sm leading-relaxed text-ns-muted">
            One face of each bar is water-quenched; hardness falls off with
            distance from that face along a curve unique to the alloy —
            the standard ASTM A255 Jominy end-quench test, run side by side.
          </p>
        </div>
        <JominyQuench title="Hardenability by grade" />
        <p className="mt-10 text-center font-mono text-[11px] text-ns-muted">
          Hover or focus a grade to trace its curve.
        </p>
      </div>
    </main>
  );
}

Component

registry/loud/jominy-quench/component.tsx
"use client";

import { useLayoutEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// JominyQuench — a hardenability comparison card built on the real ASTM A255
// / ISO 642 Jominy end-quench test: a bar is austenitised, then a water jet is
// sprayed against ONE end only. The quenched face cools fastest and hardens
// most; hardness falls off with distance from that face along a curve that is
// the alloy's signature. Comparing several bars means comparing several
// fall-off curves — that IS the mechanic, not a metaphor bolted onto one.
//
// Each row runs the same fixed cycle: the bar reheats to a uniform glow, a
// quench front creeps left-to-right across it while the water jet continuously
// sprays the left face (a steam plume that never stops), the fall-off curve
// above the bar draws in step with the front, then a hold at full quench
// before the next reheat. Rows are phase-staggered so the cascade never
// settles into one static pose.
//
// Distinctness: weld-pool is a continuously MOLTEN free surface with no
// directional solidification front; kamacite-etch reveals an already-formed
// static crystal lattice with no cooling physics at all. This is a
// directional, one-axis COOLING front with a hardness fall-off curve as its
// output — a different physical process from both.
//
// LUMINANCE ADAPTATION: the real test is intensely hue-coded (quenched steel
// runs white -> yellow -> orange -> red -> black as it cools) but this
// registry is five value-only tokens with no orange. Temperature is mapped to
// luminance instead: the hottest, most salient point on the bar is always
// closest to --foreground (the token with maximum contrast against
// --background) and the coldest, quenched point always fades toward
// --background. That mapping self-inverts correctly between themes, because
// "closest to --foreground" already means "near-white ink on a dark ground"
// in dark theme and "near-black ink on a light ground" in light theme — hot
// is unambiguously the salient value in both.
//
// Colour is read once via getComputedStyle(document.documentElement) and
// re-read on a MutationObserver watching documentElement's class, matching
// every other canvas component in this registry (duplicated here on purpose —
// no shared lib/ helper). Direct-DOM rAF, DPR-capped backing store,
// IntersectionObserver + visibilitychange pause. --ns-accent appears only as
// the hover/focus highlight on a compared bar's row — never in the resting
// glow ramp.
// ---------------------------------------------------------------------------

export interface JominyBar {
  id: string;
  /** alloy / grade name, e.g. "4140" */
  name: string;
  /**
   * Rockwell-C hardness at each of the nine standard Jominy distances
   * (DISTANCE_LABELS: 1/16" through 2"), quenched face first. Supply fewer
   * values than the header shows and the table renders that many cells
   * against the full nine-column header — pass one entry per label to
   * avoid a mismatched row.
   */
  hardness: number[];
}

const DISTANCES_16THS = [1, 2, 4, 6, 8, 12, 16, 24, 32];
const DISTANCE_LABELS = DISTANCES_16THS.map((n) =>
  n < 16 ? `${n}/16″` : `${n / 16}″`
);

// Illustrative Jominy-curve shapes (plain-carbon vs. low/medium alloy vs.
// spring steel) — the real published J-curves for these grades vary by heat
// and mill; these are shaped to the well-documented qualitative behavior
// (more alloy content = flatter fall-off) rather than lifted from one cert
// sheet, and are labelled as such in meta.json.
const DEFAULT_BARS: JominyBar[] = [
  { id: "1045", name: "1045", hardness: [55, 50, 36, 28, 24, 22, 21, 20, 20] },
  { id: "5160", name: "5160", hardness: [58, 55, 48, 42, 37, 32, 29, 26, 24] },
  { id: "8640", name: "8640", hardness: [56, 55, 53, 51, 49, 46, 44, 41, 38] },
  { id: "4140", name: "4140", hardness: [58, 56, 53, 50, 47, 43, 40, 36, 33] },
];

// cycle phase fractions (of CYCLE_MS): reheat, quench-creep, hold
const RISE_FRAC = 0.1;
const QUENCH_FRAC = 0.58;
// HOLD_FRAC = 1 - RISE_FRAC - QUENCH_FRAC = 0.32
const CYCLE_MS = 7200;
const PHASE_STAGGER = 0.16; // per-row offset, decorrelates the cascade

const COLD_BASE = 0.15; // heat level immediately behind the front — still a hole, but not invisible
const RESIDUAL_ASYMPTOTE = 0.9; // heat level the ahead-of-front zone recovers to, short of full furnace heat
const DIP_DEPTH = 0.35; // how far below the asymptote the zone right at the front dips
const DIP_WIDTH = 0.14; // fraction of bar length the dip recovers over, ahead of the front
const FRONT_BLEND = 0.05; // fraction of bar length the front edge is soft over
const STATIC_FRONT = 0.55; // reduced-motion freeze: partway up the bar
const STATIC_CURVE_REVEAL = 1; // ...with the full signature curve drawn

type RGB = [number, number, number];

function parseHex(raw: string): RGB | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function lerpRGB(a: RGB, b: RGB, t: number): RGB {
  const c = Math.max(0, Math.min(1, t));
  return [a[0] + (b[0] - a[0]) * c, a[1] + (b[1] - a[1]) * c, a[2] + (b[2] - a[2]) * c];
}

function rgbStr([r, g, b]: RGB, a = 1): string {
  return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a})`;
}

function smoothstep(t: number): number {
  const c = Math.max(0, Math.min(1, t));
  return c * c * (3 - 2 * c);
}

// Heat level 0..1 at bar-fraction x for a given front position, ignoring the
// reheat blend — this is the "just been quenched to here" profile: cold
// behind the front, a soft transition band, then a dip right at the boundary
// (the metal there has only just started losing heat to the jet) that
// recovers toward RESIDUAL_ASYMPTOTE as x moves away from the front — i.e.
// the unquenched material farthest from where cooling is currently happening
// is the material that has lost the least heat, which is the residual glow
// the far end is meant to retain.
function frontHeat(x: number, front: number): number {
  const d = x - front;
  if (d <= -FRONT_BLEND) return COLD_BASE;
  const da = Math.max(0, d);
  const aheadVal = RESIDUAL_ASYMPTOTE - DIP_DEPTH * Math.exp(-da / DIP_WIDTH);
  if (d >= FRONT_BLEND) return aheadVal;
  const s = smoothstep((d + FRONT_BLEND) / (2 * FRONT_BLEND));
  return COLD_BASE + (aheadVal - COLD_BASE) * s;
}

type Phase = { front: number; curveReveal: number; reheat: number };

function phaseAt(u: number): Phase {
  const t = ((u % 1) + 1) % 1;
  if (t < RISE_FRAC) {
    const q = smoothstep(t / RISE_FRAC);
    return { front: 1, curveReveal: 1 - q, reheat: q };
  }
  const t2 = t - RISE_FRAC;
  if (t2 < QUENCH_FRAC) {
    const q = smoothstep(t2 / QUENCH_FRAC);
    return { front: q, curveReveal: q, reheat: 0 };
  }
  return { front: 1, curveReveal: 1, reheat: 0 };
}

// During "rise" (reheat), the whole bar blends from wherever the last
// quench left it back toward a uniform hot glow, so the bar never snaps —
// it visibly warms back up before the next front starts creeping.
function heatAt(x: number, ph: Phase): number {
  const front = frontHeat(x, ph.front);
  return ph.reheat > 0 ? front + (1 - front) * ph.reheat : front;
}

export interface JominyQuenchProps {
  /** bars being compared, quenched-face-first hardness per bar */
  bars?: JominyBar[];
  /** accessible title for the comparison */
  title?: string;
  className?: string;
}

export function JominyQuench({
  bars = DEFAULT_BARS,
  title = "Jominy end-quench hardenability",
  className = "",
}: JominyQuenchProps) {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const hoverRef = useRef<number>(-1);
  // set only under prefers-reduced-motion, where nothing else repaints the
  // canvas — lets hover/focus still redraw the accent highlight on demand
  const hoverRedrawRef = useRef<(() => void) | null>(null);

  useLayoutEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const n = bars.length;
    const maxHRC = Math.max(1, ...bars.flatMap((b) => b.hardness)) * 1.08;
    const nPts = Math.max(2, ...bars.map((b) => b.hardness.length));

    let width = 0;
    let rowH = 0;
    let dpr = 1;
    let padX = 26;

    // Token fields start empty and are only ever assigned from a live
    // getComputedStyle read — never a hardcoded fallback — so a missing
    // token fails loud (drawFrame bails) instead of silently painting a
    // baked-in white.
    let bgC: RGB | null = null;
    let fgC: RGB | null = null;
    let mutedC: RGB | null = null;
    let accentC: RGB | null = null;

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      const get = (name: string) => cs.getPropertyValue(name).trim();
      bgC = parseHex(get("--background"));
      fgC = parseHex(get("--foreground"));
      mutedC = parseHex(get("--ns-muted"));
      accentC = parseHex(get("--ns-accent"));
    };
    readTokens();

    const resize = () => {
      const rect = root.getBoundingClientRect();
      width = rect.width;
      // rowH derives from the container's own width (the reliably-available
      // "small dimension" for a full-width stacked-row card): clamped so the
      // bar, curve and plume stay legible at small preview-card widths
      // instead of shrinking to illegibility or ballooning on a wide column.
      rowH = Math.max(48, Math.min(84, width * 0.15));
      // the plume's footprint (jet length + puff radius) scales with bar
      // thickness, which scales with rowH — a fixed left margin clips the
      // jet/puffs off-canvas at large rowH, so the margin is derived from
      // the same thickness the plume actually draws at.
      const thick = Math.max(5, Math.min(13, rowH * 0.15));
      padX = Math.max(26, thick * 3.4);
      const height = rowH * n;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.style.height = `${height}px`;
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
    };
    resize();

    let raf = 0;
    let visible = true;
    let t0 = performance.now();

    const drawPlume = (cx: number, cy: number, thick: number, tSec: number, fg: RGB) => {
      // continuous jet + steam plume at the quenched face — never stops, not
      // tied to the quench-cycle phase, only to elapsed real time
      const jetLen = thick * 2.6;
      ctx.strokeStyle = rgbStr(fg, 0.28);
      ctx.lineWidth = Math.max(1, thick * 0.22);
      ctx.beginPath();
      const jitter = Math.sin(tSec * 26) * 1.1 + Math.sin(tSec * 41 + 1.7) * 0.6;
      ctx.moveTo(cx - jetLen, cy - thick * 1.6 + jitter);
      ctx.lineTo(cx, cy);
      ctx.stroke();

      const puffs = 4;
      for (let i = 0; i < puffs; i++) {
        const ph = (tSec * 0.55 + i / puffs) % 1;
        const rise = ph * thick * 3.2;
        const wob = Math.sin(tSec * 3.1 + i * 2.3) * thick * 0.35;
        const r = thick * (0.35 + ph * 0.55);
        const a = 0.16 * (1 - ph);
        ctx.fillStyle = rgbStr(fg, a);
        ctx.beginPath();
        ctx.arc(cx - jetLen * 0.35 + wob, cy - thick * 1.3 - rise, r, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    const drawRow = (
      i: number,
      ph: Phase,
      isStatic: boolean,
      bg: RGB,
      fg: RGB,
      muted: RGB,
      accent: RGB
    ) => {
      const bar = bars[i];
      const rowTop = i * rowH;
      const curveTop = rowTop + rowH * 0.08;
      const curveBaseline = rowTop + rowH * 0.56;
      const curveMaxH = curveBaseline - curveTop;
      const barThick = Math.max(5, Math.min(13, rowH * 0.15));
      const barY = curveBaseline + barThick * 1.35;
      const barLeft = padX;
      const barRight = width - padX * 0.6;
      const barLen = Math.max(1, barRight - barLeft);

      const hovered = hoverRef.current === i;

      // bar fill: horizontal gradient sampled from the heat profile so the
      // cooled/quenched zone visibly recedes toward --background and the
      // still-hot zone reads as the salient --foreground-leaning value
      const grad = ctx.createLinearGradient(barLeft, 0, barRight, 0);
      const STOPS = 20;
      for (let s = 0; s <= STOPS; s++) {
        const x = s / STOPS;
        const heat = heatAt(x, ph);
        grad.addColorStop(x, rgbStr(lerpRGB(bg, fg, heat)));
      }
      ctx.fillStyle = grad;
      const r = barThick / 2;
      ctx.beginPath();
      ctx.moveTo(barLeft + r, barY - r);
      ctx.arcTo(barRight, barY - r, barRight, barY + r, r);
      ctx.arcTo(barRight, barY + r, barLeft, barY + r, r);
      ctx.arcTo(barLeft, barY + r, barLeft, barY - r, r);
      ctx.arcTo(barLeft, barY - r, barRight, barY - r, r);
      ctx.closePath();
      ctx.fill();
      ctx.strokeStyle = hovered ? rgbStr(accent, 0.85) : rgbStr(fg, 0.22);
      ctx.lineWidth = hovered ? 1.6 : 1;
      ctx.stroke();

      // quench front marker — only visible mid-creep, the literal directional
      // cooling boundary this mechanic is named for
      if (ph.front > 0.004 && ph.front < 0.996) {
        const fx = barLeft + barLen * ph.front;
        ctx.strokeStyle = rgbStr(fg, 0.45);
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(fx, curveBaseline);
        ctx.lineTo(fx, barY + barThick);
        ctx.stroke();
      }

      // fall-off curve — this alloy's signature, drawn point-to-point,
      // clipped to curveReveal so it fills in as the front reaches each x
      const pts: [number, number][] = bar.hardness.map((h, k) => {
        const x = barLeft + (barLen * k) / (nPts - 1);
        const y = curveBaseline - (h / maxHRC) * curveMaxH * 0.94;
        return [x, y];
      });
      const revealX = barLeft + barLen * ph.curveReveal;

      ctx.beginPath();
      ctx.moveTo(pts[0][0], curveBaseline);
      let leadX = pts[0][0];
      let leadY = pts[0][1];
      ctx.lineTo(leadX, leadY);
      for (let k = 1; k < pts.length; k++) {
        const [px, py] = pts[k];
        if (px <= revealX) {
          ctx.lineTo(px, py);
          leadX = px;
          leadY = py;
        } else {
          const [ppx, ppy] = pts[k - 1];
          const seg = (revealX - ppx) / Math.max(1e-3, px - ppx);
          if (seg > 0) {
            leadX = ppx + (px - ppx) * seg;
            leadY = ppy + (py - ppy) * seg;
            ctx.lineTo(leadX, leadY);
          }
          break;
        }
      }
      ctx.lineTo(leadX, curveBaseline);
      ctx.closePath();
      ctx.fillStyle = rgbStr(muted, hovered ? 0.14 : 0.08);
      ctx.fill();

      ctx.beginPath();
      ctx.moveTo(pts[0][0], pts[0][1]);
      let lx = pts[0][0];
      let ly = pts[0][1];
      for (let k = 1; k < pts.length; k++) {
        const [px, py] = pts[k];
        if (px <= revealX) {
          ctx.lineTo(px, py);
          lx = px;
          ly = py;
        } else {
          const [ppx, ppy] = pts[k - 1];
          const seg = (revealX - ppx) / Math.max(1e-3, px - ppx);
          if (seg > 0) {
            lx = ppx + (px - ppx) * seg;
            ly = ppy + (py - ppy) * seg;
            ctx.lineTo(lx, ly);
          }
          break;
        }
      }
      ctx.strokeStyle = rgbStr(fg, hovered ? 1 : 0.85);
      ctx.lineWidth = hovered ? 2 : 1.4;
      ctx.stroke();

      // the "pen" — a lead dot at the point currently being drawn, only while
      // actively creeping (not once the curve is fully settled)
      if (ph.curveReveal > 0.003 && ph.curveReveal < 0.997) {
        ctx.fillStyle = rgbStr(fg, 0.9);
        ctx.beginPath();
        ctx.arc(lx, ly, 2.3, 0, Math.PI * 2);
        ctx.fill();
      }

      const tSec = isStatic ? 0 : (performance.now() - t0) / 1000;
      drawPlume(barLeft, barY, barThick, tSec, fg);
    };

    const drawFrame = (isStatic: boolean) => {
      // tokens are only ever assigned from a live read (never a hardcoded
      // fallback) — a genuinely missing token bails the paint rather than
      // silently substituting a baked-in colour.
      if (!bgC || !fgC || !mutedC || !accentC) return;
      const bg = bgC;
      const fg = fgC;
      const muted = mutedC;
      const accent = accentC;

      ctx.save();
      ctx.scale(dpr, dpr);
      ctx.clearRect(0, 0, width, rowH * n);
      ctx.fillStyle = rgbStr(bg);
      ctx.fillRect(0, 0, width, rowH * n);

      const now = isStatic ? 0 : (performance.now() - t0) / CYCLE_MS;
      for (let i = 0; i < n; i++) {
        const ph = isStatic
          ? { front: STATIC_FRONT, curveReveal: STATIC_CURVE_REVEAL, reheat: 0 }
          : phaseAt(now + i * PHASE_STAGGER);
        drawRow(i, ph, isStatic, bg, fg, muted, accent);
      }
      ctx.restore();
    };

    const loop = () => {
      drawFrame(false);
      raf = requestAnimationFrame(loop);
    };

    if (reduced) {
      drawFrame(true);
      hoverRedrawRef.current = () => drawFrame(true);
    } else {
      raf = requestAnimationFrame(loop);
    }

    const ro = new ResizeObserver(() => {
      resize();
      drawFrame(reduced);
    });
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      const vis = entries[0]?.isIntersecting ?? true;
      if (vis === visible) return;
      visible = vis;
      if (reduced) return;
      if (!visible && raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      } else if (visible && !raf) {
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(root);

    const onVis = () => {
      if (reduced) return;
      if (document.hidden && raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      } else if (!document.hidden && visible && !raf) {
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      drawFrame(reduced);
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    return () => {
      if (raf) cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      hoverRedrawRef.current = null;
    };
    // bars/n/maxHRC are derived once at mount from the bars prop, matching
    // every other canvas component's closure-locals convention
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [bars]);

  const setHover = (i: number) => {
    hoverRef.current = i;
    hoverRedrawRef.current?.();
  };
  const clearHover = (i: number) => {
    if (hoverRef.current === i) hoverRef.current = -1;
    hoverRedrawRef.current?.();
  };

  return (
    <div ref={rootRef} className={`w-full text-foreground ${className}`.trim()}>
      {/* no bg-surface here — the canvas fills --background itself every
          frame, and --surface is a different token, so a wrapper fill would
          leave a visible seam inside the border */}
      <div className="relative w-full overflow-hidden rounded-md border border-border">
        <canvas ref={canvasRef} aria-hidden className="block w-full" />
      </div>

      <table className="mt-4 w-full border-collapse">
        <caption className="sr-only">
          {title} — Rockwell C hardness at each distance from the quenched face
        </caption>
        <thead>
          <tr className="border-b border-border">
            <th scope="col" className="py-1.5 pr-2 text-left text-[11px] font-medium uppercase tracking-[0.08em] text-ns-muted">
              Grade
            </th>
            {DISTANCE_LABELS.map((d) => (
              <th
                key={d}
                scope="col"
                className="py-1.5 px-1.5 text-right font-mono text-[10px] font-normal text-ns-muted"
              >
                {d}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {bars.map((bar, i) => (
            <tr key={bar.id} className="border-b border-border/60 last:border-b-0">
              <th scope="row" className="py-1.5 pr-2 text-left font-normal">
                <button
                  type="button"
                  onPointerEnter={() => setHover(i)}
                  onPointerLeave={() => clearHover(i)}
                  onFocus={() => setHover(i)}
                  onBlur={() => clearHover(i)}
                  className="rounded-sm text-[13px] leading-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  {bar.name}
                </button>
              </th>
              {bar.hardness.map((h, k) => (
                <td key={k} className="py-1.5 px-1.5 text-right font-mono text-[11px] tabular-nums text-foreground/90">
                  {h}
                  <span className="sr-only"> HRC</span>
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default JominyQuench;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/jominy-quench.tsx, so point the import there.

Use when Pick jominy-quench when comparing several graded variants (alloys, formulations, product tiers with a measurable falloff) along one continuous physical axis and the shape of the falloff curve, not a checkmark grid, is the comparison. compare-table-reach-rule is the better pick for a feature-by-feature plan matrix; jominy-quench is for a small number of items each carrying one signature curve.

Install

npx shadcn add https://design.helpmarq.com/r/jominy-quench.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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.

Props

PropTypeDefaultDescription
bars?JominyBar[]DEFAULT_BARSbars being compared, quenched-face-first hardness per bar
title?string"Jominy end-quench hardenability"accessible title for the comparison
className?string——