{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meter-threshold-trip",
  "title": "Meter Threshold Trip",
  "description": "A data-driven threshold indicator built like a thermostat's bimetallic strip: it bows progressively as a metric climbs, snaps across a visible contact gap at the trip point and latches, then only re-straightens once the value falls below a lower re-arm mark, making hysteresis visible instead of implying it with a color change.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/meter-threshold-trip/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BimetalTrip — a data-driven threshold indicator built like a thermostat's\n// bimetallic strip. One SVG quadratic-bezier path is fixed at two mounting\n// posts; only its control point moves, offset upward as `value` climbs\n// toward `tripAt` — straight at the bottom of the range, closing in on (but\n// deliberately never quite touching) the contact pad as it nears the trip\n// point, so there is always a real gap left for the latch to snap shut. That\n// crossing LATCHES: the strip stays pinned at full bow (heavier --foreground\n// stroke, filled contact pad — never\n// --ns-accent, latching is data state, not interaction) regardless of value\n// wobbling anywhere between `clearAt` and `tripAt`. It only relaxes back\n// once value falls below `clearAt`, the lower re-arm mark — that gap between\n// where it trips and where it clears is hysteresis made visible, not implied\n// by a color. The snap into contact is a 320ms CSS `d`-transition on a\n// back-out curve (~12% overshoot, no JS spring loop); the re-arm relax is a\n// slow 600ms ease-out-expo settle; ordinary climbing between ticks gets a\n// quick, non-overshooting ease. A muted hairline band under the strip spans\n// clearAt..tripAt on the same x-domain as the strip's own mounts, with a\n// live position marker and Geist Mono 'clears N / trips N' captions, so the\n// two thresholds are legible from a single still frame. Every stroke/fill is\n// a token class (text-foreground/text-ns-muted/text-border) — no hex, no\n// canvas. DOM+SVG+CSS only.\n// ---------------------------------------------------------------------------\n\nexport interface BimetalTripProps {\n  /** the live metric being watched */\n  value: number;\n  /** the upper threshold — crossing it latches the strip against the contact */\n  tripAt: number;\n  /** the lower re-arm mark — the strip only relaxes once value falls below this */\n  clearAt: number;\n  /** domain floor for the visual scale (default 0) */\n  min?: number;\n  /** domain ceiling for the visual scale (default 100) */\n  max?: number;\n  /** unit suffix for readouts, e.g. \"%\", \"ms\", \"req/s\" (default \"%\") */\n  unit?: string;\n  /** what's being watched, shown above the strip, e.g. \"CPU temp\" */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\n// geometry, SVG viewBox units\nconst VIEW_W = 320;\nconst VIEW_H = 150;\nconst ANCHOR_L_X = 40;\nconst ANCHOR_R_X = 280;\nconst MID_X = (ANCHOR_L_X + ANCHOR_R_X) / 2;\nconst BASE_Y = 62;\nconst MAX_CONTROL_OFFSET = 46; // control-point y-offset at full bow (bowFrac 1)\nconst PEAK_OFFSET = MAX_CONTROL_OFFSET / 2; // a quadratic's true peak sits at half the control offset\nconst CONTACT_Y = BASE_Y - PEAK_OFFSET; // where the strip's tip meets the pad, fully latched\nconst POST_TOP_Y = CONTACT_Y - 18;\nconst PAD_W = 16;\nconst PAD_H = 5;\nconst BAND_Y = 112;\nconst BAND_H = 6;\nconst TICK_H = 10;\n// the unlatched approach curve's ceiling — kept below 1 (full/latched bow) so\n// there is always a real geometric gap left against the contact pad for the\n// latch's snap to close; see the bowFrac comment below for why this matters.\nconst UNLATCHED_BOW_CEILING = 0.75;\n\ntype TransitionKind = \"climb\" | \"snap\" | \"rearm\";\n\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, n));\n}\n\nfunction fmt(n: number) {\n  if (!Number.isFinite(n)) return \"0\";\n  const abs = Math.abs(n);\n  if (Number.isInteger(n) || abs >= 100) return Math.round(n).toString();\n  return n.toFixed(1);\n}\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nfunction transitionStyle(kind: TransitionKind, reduced: boolean) {\n  // The strip's curvature IS the reading (latched vs. clear) — a 0ms jump\n  // removes the only passive signal that value crossed a threshold. Keep a\n  // short, non-overshooting linear settle instead of the full snap/rearm\n  // spring curves.\n  if (reduced) return { transitionDuration: \"140ms\", transitionTimingFunction: \"linear\" };\n  if (kind === \"snap\") {\n    return {\n      transitionDuration: \"320ms\",\n      transitionTimingFunction: \"cubic-bezier(0.34, 1.56, 0.64, 1)\",\n    };\n  }\n  if (kind === \"rearm\") {\n    return {\n      transitionDuration: \"600ms\",\n      transitionTimingFunction: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n    };\n  }\n  return {\n    transitionDuration: \"260ms\",\n    transitionTimingFunction: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n  };\n}\n\nexport function BimetalTrip({\n  value,\n  tripAt,\n  clearAt,\n  min = 0,\n  max = 100,\n  unit = \"%\",\n  label = \"Threshold\",\n  className = \"\",\n}: BimetalTripProps) {\n  const uid = useId();\n  const reduced = useReducedMotion();\n\n  // latched is derived data state, initialized once from the mounting value\n  // so a demo/dashboard that mounts already past tripAt renders latched with\n  // no spurious snap animation on first paint.\n  const [latched, setLatched] = useState(() => value >= tripAt);\n  const [transitionKind, setTransitionKind] = useState<TransitionKind>(\"climb\");\n  const [announce, setAnnounce] = useState(\"\");\n  const mountedRef = useRef(false);\n\n  useEffect(() => {\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      return;\n    }\n    if (!latched && value >= tripAt) {\n      setLatched(true);\n      setTransitionKind(\"snap\");\n      setAnnounce(\n        `Latched — ${fmt(value)}${unit} crossed trip at ${fmt(tripAt)}${unit}. Holds until it falls below ${fmt(clearAt)}${unit}.`\n      );\n    } else if (latched && value < clearAt) {\n      setLatched(false);\n      setTransitionKind(\"rearm\");\n      setAnnounce(`Re-armed — ${fmt(value)}${unit} fell below clear mark ${fmt(clearAt)}${unit}.`);\n    } else {\n      setTransitionKind(\"climb\");\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [value, tripAt, clearAt]);\n\n  // BUG FIX: bowFrac must never let the *unlatched* approach curve reach the\n  // same numeric value as the *latched* pinned bow (1). It used to — clamp()\n  // saturated the unlatched formula to exactly 1 the instant value reached\n  // tripAt, which is the same value the latch pins to, so by the time the\n  // `latched` effect actually flipped (one tick later) the path's `d` hadn't\n  // changed at all: no delta for the CSS transition to animate, so the\n  // signature 320ms back-out \"snap into contact\" never played — the strip\n  // just silently arrived already fully bowed. Capping the unlatched\n  // approach short of 1 keeps a real, persistent gap against the contact pad\n  // (matching \"closes a visible gap\" below) so crossing tripAt always leaves\n  // genuine distance for the snap to close, regardless of step size.\n  const safeTripAt = tripAt > min ? tripAt : min + 1e-6;\n  const approachFrac = clamp((value - min) / (safeTripAt - min), 0, 1);\n  const bowFrac = latched ? 1 : approachFrac * UNLATCHED_BOW_CEILING;\n  const controlY = BASE_Y - bowFrac * MAX_CONTROL_OFFSET;\n  const stripD = `M ${ANCHOR_L_X} ${BASE_Y} Q ${MID_X} ${controlY} ${ANCHOR_R_X} ${BASE_Y}`;\n  const stripStyle = transitionStyle(transitionKind, reduced);\n\n  const domain = Math.max(1e-6, max - min);\n  const xFor = (v: number) => ANCHOR_L_X + (clamp(v, min, max) - min) / domain * (ANCHOR_R_X - ANCHOR_L_X);\n  const xClear = xFor(clearAt);\n  const xTrip = xFor(tripAt);\n  const xValue = xFor(value);\n\n  const labelId = `${uid}-label`;\n  const descId = `${uid}-desc`;\n  const liveId = `${uid}-live`;\n  const valueText = `${fmt(value)}${unit}, ${latched ? \"latched\" : \"clear\"}, trips at ${fmt(tripAt)}${unit}, clears at ${fmt(clearAt)}${unit}`;\n  const stateWord = latched ? \"LATCHED\" : \"CLEAR\";\n  const description = latched\n    ? `Latched at ${fmt(value)}${unit} — trips at ${fmt(tripAt)}${unit}, will re-arm below ${fmt(clearAt)}${unit}.`\n    : `Clear at ${fmt(value)}${unit} — trips at ${fmt(tripAt)}${unit}, clears at ${fmt(clearAt)}${unit}.`;\n\n  return (\n    <div className={className}>\n      <style>{`\n.ns-bimetal-strip{transition-property:d}\n@media (prefers-reduced-motion: reduce){\n  .ns-bimetal-strip{transition-duration:140ms !important;transition-timing-function:linear !important}\n}\n`}</style>\n\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <span id={labelId} className=\"font-mono text-[11px] tracking-wide text-ns-muted\">\n          {label.toUpperCase()}\n        </span>\n        <span\n          className={\n            \"font-mono text-[11px] tracking-wide text-foreground \" +\n            (latched ? \"font-semibold\" : \"\")\n          }\n        >\n          {stateWord}\n        </span>\n      </div>\n\n      <div className=\"mt-1 flex items-baseline gap-1.5\">\n        <span className=\"text-2xl font-semibold tabular-nums text-foreground\">\n          {fmt(value)}\n          {unit}\n        </span>\n      </div>\n\n      <div\n        role=\"meter\"\n        aria-labelledby={labelId}\n        aria-describedby={descId}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={clamp(value, min, max)}\n        aria-valuetext={valueText}\n        tabIndex={0}\n        className=\"mt-3 rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent\"\n      >\n        <svg\n          viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}\n          className=\"h-[150px] w-full\"\n          aria-hidden\n          focusable=\"false\"\n        >\n          {/* hysteresis band, on the strip's own x-domain */}\n          <rect\n            x={xClear}\n            y={BAND_Y}\n            width={Math.max(0, xTrip - xClear)}\n            height={BAND_H}\n            rx={2}\n            className=\"fill-current text-ns-muted opacity-40\"\n          />\n          <line\n            x1={xClear}\n            x2={xClear}\n            y1={BAND_Y - TICK_H / 2}\n            y2={BAND_Y + BAND_H + TICK_H / 2}\n            className=\"stroke-current text-border\"\n            strokeWidth={1}\n          />\n          <line\n            x1={xTrip}\n            x2={xTrip}\n            y1={BAND_Y - TICK_H / 2}\n            y2={BAND_Y + BAND_H + TICK_H / 2}\n            className=\"stroke-current text-border\"\n            strokeWidth={1}\n          />\n          {/* live position marker along the band */}\n          <circle\n            cx={xValue}\n            cy={BAND_Y + BAND_H / 2}\n            r={3}\n            className=\"fill-current text-foreground\"\n          />\n\n          {/* mounting posts, fixed endpoints of the strip */}\n          <rect\n            x={ANCHOR_L_X - 4}\n            y={BASE_Y - 9}\n            width={8}\n            height={18}\n            rx={2}\n            className=\"fill-current text-border\"\n          />\n          <rect\n            x={ANCHOR_R_X - 4}\n            y={BASE_Y - 9}\n            width={8}\n            height={18}\n            rx={2}\n            className=\"fill-current text-border\"\n          />\n\n          {/* the strip itself — one quadratic bezier, control point is the mechanism */}\n          <path\n            d={stripD}\n            fill=\"none\"\n            className=\"ns-bimetal-strip stroke-current text-foreground\"\n            style={stripStyle}\n            strokeWidth={latched ? 3.5 : 2}\n            strokeLinecap=\"round\"\n          />\n\n          {/* contact assembly: fixed post + pad the strip's tip closes against */}\n          <line\n            x1={MID_X}\n            x2={MID_X}\n            y1={POST_TOP_Y}\n            y2={CONTACT_Y}\n            className=\"stroke-current text-border\"\n            strokeWidth={1.5}\n          />\n          <rect\n            x={MID_X - PAD_W / 2}\n            y={CONTACT_Y - PAD_H / 2}\n            width={PAD_W}\n            height={PAD_H}\n            rx={1.5}\n            className={\n              latched\n                ? \"fill-current text-foreground\"\n                : \"fill-none stroke-current text-border\"\n            }\n            strokeWidth={latched ? 0 : 1.5}\n          />\n          {latched ? (\n            <circle\n              data-bimetal-contact-lit\n              cx={MID_X}\n              cy={CONTACT_Y}\n              r={4}\n              className=\"fill-current text-foreground\"\n            />\n          ) : null}\n        </svg>\n      </div>\n\n      <div className=\"mt-1.5 flex items-center justify-between font-mono text-[11px] text-ns-muted\">\n        <span>\n          clears {fmt(clearAt)}\n          {unit}\n        </span>\n        <span>\n          trips {fmt(tripAt)}\n          {unit}\n        </span>\n      </div>\n\n      <p id={descId} className=\"mt-2 text-center font-mono text-[11px] text-ns-muted\">\n        {description}\n      </p>\n\n      <span id={liveId} role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/meter-threshold-trip.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "meter",
      "threshold",
      "hysteresis",
      "alert",
      "svg",
      "spring",
      "status",
      "accessibility"
    ],
    "instruction": "Renders one watched metric — `value` against an upper `tripAt` and a lower `clearAt` (with `clearAt` < `tripAt`), plus a `min`/`max` display domain (default 0/100) and `unit` (default \"%\") — as a bimetallic thermostat strip instead of a colored status badge. The strip is a single SVG quadratic bezier fixed at two mounting posts (`stroke-current text-border` rects); only its control point moves, offset upward as a `bowFrac` scalar that is the entire mechanism: while unlatched, `bowFrac` = clamp((value-min)/(tripAt-min), 0, 1) scaled to a fixed ceiling short of full bow — 0 (straight) at `min`, approaching but deliberately never reaching full bow as `value` nears `tripAt` — and while latched it is pinned at exactly 1, which is what makes the hysteresis visible (a value that dips from above `tripAt` back down into the band between `clearAt` and `tripAt` does not un-bow the strip at all) and also what guarantees the snap always has real distance to close: the unlatched ceiling and the latched pin are never numerically equal, so the moment `value` reaches `tripAt` there is always a genuine gap left for the crossing to visibly snap shut, regardless of how big a step `value` jumped by. The strip's tip closes a visible gap against a fixed contact pad mounted above it; the instant `value` reaches `tripAt` while unlatched, a `latched` boolean flips true (derived purely from data — a `value`/`tripAt`/`clearAt` effect, never user input) and, edge-triggered on that specific crossing, the path's `d` transitions on a 320ms back-out CSS curve (`cubic-bezier(0.34, 1.56, 0.64, 1)`, i.e. genuinely overshoots past full bow before settling — the ~12% spring overshoot, achieved as a CSS `d` transition rather than a JS simulation loop, matching this registry's established technique for path-level springs) while the strip's `stroke-width` jumps from 2 to 3.5 (a heavier `text-foreground` stroke — never `text-ns-accent`, since latching is a data state, not an interaction) and the contact pad switches from an outlined `text-border` rect to a filled `text-foreground` one with an additional filled circle exactly at the touch point marking contact made. Latched, the strip stays exactly there — pinned, heavier, contact filled — no matter how `value` moves, until it falls below `clearAt`, at which point `latched` flips false and the same `d` instead relaxes on a slow 600ms ease-out-expo curve (`cubic-bezier(0.16, 1, 0.3, 1)`, no overshoot) back down to wherever the ordinary `bowFrac` formula now places it for the current (sub-`clearAt`) value — a deliberate 'un-tensing' rather than a snap. Ordinary value changes that cross neither threshold ease on a quick 260ms non-overshooting curve of the same easing family, so only the two threshold crossings get their own distinct physics. Below the strip, a muted hairline band (`fill-current text-ns-muted opacity-40`) spans `clearAt`..`tripAt` on the exact same x-domain as the strip's own mounting posts, with a `text-border` tick at each end, a small `text-foreground` circle riding along it at the live value's position, and a Geist Mono caption row below reading 'clears {clearAt}{unit}' / 'trips {tripAt}{unit}' — the two marks are legible from a single still frame, not just inferable from strip position. A bold Geist Mono state chip ('CLEAR' / 'LATCHED') sits beside the label above the strip at all times, and a status paragraph below spells out the current state and, when latched, exactly what has to happen to clear it. The outer meter node is `role=meter`, `aria-labelledby` on the visible label, `aria-describedby` on that status paragraph (which always states the trip mark, the clear mark, and the current latch state — legible on demand, not just at the moment of a crossing), `aria-valuemin=min`, `aria-valuemax=max`, `aria-valuenow` (clamped into [min,max]), and `aria-valuetext` spelling the value, latch state, and both thresholds in one sentence; it also carries `tabIndex=0` so it is reachable and inspectable by a screen reader even though it exposes no interactive affordance of its own (not a control, correctly exempt from the registry's accessible-name-on-controls rule, though it has one anyway via `aria-labelledby`). A separate visually-hidden `role=status`/`aria-live=polite` span announces only the two edge-triggered transitions ('Latched — ... crossed trip ...' / 'Re-armed — ... fell below clear ...'), not every value tick. The SVG itself is `aria-hidden`, decorative once the meter node and the two text readouts carry the real semantics. Under `prefers-reduced-motion: reduce` (checked via `matchMedia` with a live change listener, backed by a CSS media-query override on the path's `transition` as well) every `d` transition shortens to a plain 140ms linear ease instead of collapsing to instant — snap, re-arm, and ordinary climbs all still visibly settle into their resting shape rather than teleporting, since the strip's curvature is the only passive signal the threshold was crossed — while the same aria-live announcements still fire on the same crossings, so the state change is never silent even without motion. Every stroke and fill is a `stroke-current`/`fill-current` Tailwind class tinted `text-border`, `text-ns-muted`, or `text-foreground` — never a hex literal, never `getComputedStyle`, since nothing here is a raster surface — so both themes restyle for free. No canvas: one strip `<path>`, two mounting-post `<rect>`s, one contact post `<line>` plus pad `<rect>` plus a conditional lit `<circle>`, one hysteresis band `<rect>` with two tick `<line>`s and a live-position `<circle>`, is the entire SVG, DOM+SVG+CSS only, zero dependencies."
  },
  "type": "registry:ui"
}