{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "score-gauge",
  "title": "Score gauge",
  "description": "A 0–1 score as an instrument gauge: a 250° comb of bars in a stop-to-go gradient, divided into named zones, with a tapered needle that springs in to the reading.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/score-gauge/score-gauge.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type MouseEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useInView,\n  useReducedMotionConfig,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A 0–1 score as an instrument gauge.\n *\n * **The whole scale is always drawn.** A 250° sweep opening at the bottom —\n * zero at seven o'clock, one at five o'clock — so the arc is the territory and\n * the needle is the reading. The arc is a comb of radial bars in one\n * continuous stop → orange → yellow → go gradient, divided into zones.\n *\n * Two sizes of the same instrument, read off the same geometry and the same\n * ramp so a score sits in the same zone, in the same colour, at either size:\n *\n * - `full`: 60 bars, scale numbers, a readout table, and a hover / tap\n *   explainer per zone. For the one place someone interrogates the number.\n * - `mini`: 24 fatter bars, no numbers, no hover, a two-line readout. For a\n *   card in a list, where six interactive dials would be noise.\n *\n * Both swing the needle in on a slow spring the first time the dial scrolls\n * into view, and land on the reading with no sweep under reduced motion.\n */\n\n/* ------------------------------------------------------------------------ */\n/* Zones                                                                    */\n/* ------------------------------------------------------------------------ */\n\nexport type ScoreGaugeTone = \"stop\" | \"hold\" | \"go\";\n\nexport type ScoreGaugeZone = {\n  /** Stable key. */\n  id: string;\n  /** The band's name, as the readout prints it (\"High\"). */\n  label: string;\n  /** Where the zone starts on the 0–1 scale (inclusive). */\n  from: number;\n  /** Where it ends (exclusive, except the last zone, which owns 1). */\n  to: number;\n  /** Ink for the band's name in the readout. Defaults from the zone's midpoint. */\n  tone?: ScoreGaugeTone;\n  /** What landing in this zone means, in the reader's terms. Shown on hover. */\n  description?: string;\n  /** A second, quieter line: how a score usually gets here. */\n  detail?: string;\n};\n\n/**\n * Six tiers, cut so that with 60 bars every threshold falls exactly on a gap\n * between two bars.\n */\nexport const DEFAULT_PRIORITY_ZONES: ScoreGaugeZone[] = [\n  { id: \"critical_low\", label: \"Critical Low\", from: 0, to: 0.2 },\n  { id: \"low\", label: \"Low\", from: 0.2, to: 0.4 },\n  { id: \"low_medium\", label: \"Low Medium\", from: 0.4, to: 0.55 },\n  { id: \"high_medium\", label: \"High Medium\", from: 0.55, to: 0.7 },\n  { id: \"high\", label: \"High\", from: 0.7, to: 0.85 },\n  { id: \"extremely_high\", label: \"Extremely High\", from: 0.85, to: 1 },\n];\n\nfunction zoneOf(\n  zones: ScoreGaugeZone[],\n  fraction: number,\n): ScoreGaugeZone | null {\n  const last = zones[zones.length - 1];\n  for (const zone of zones) {\n    if (fraction >= zone.from && fraction < zone.to) return zone;\n  }\n  return last && fraction >= last.to ? last : null;\n}\n\nfunction toneOf(zone: ScoreGaugeZone): ScoreGaugeTone {\n  if (zone.tone) return zone.tone;\n  const mid = (zone.from + zone.to) / 2;\n  if (mid < 0.4) return \"stop\";\n  if (mid < 0.7) return \"hold\";\n  return \"go\";\n}\n\nconst TONE_TEXT: Record<ScoreGaugeTone, string> = {\n  stop: \"text-stop\",\n  hold: \"text-hold\",\n  go: \"text-go\",\n};\n\nconst fmt = (n: number) => (n === 0 ? \"0\" : n.toFixed(2));\n\nfunction rangeOf(zone: ScoreGaugeZone): string {\n  if (zone.from <= 0) return `under ${fmt(zone.to)}`;\n  if (zone.to >= 1) return `${fmt(zone.from)} and up`;\n  return `${fmt(zone.from)} to ${fmt(zone.to)}`;\n}\n\n/* ------------------------------------------------------------------------ */\n/* The ramp                                                                 */\n/* ------------------------------------------------------------------------ */\n\n/**\n * Gradient stops behind the comb. Every stop is a contract token (or a mix of\n * two), so the ramp follows the theme and the mode with no JavaScript. The\n * two ends are pushed a step deeper than the state tokens by mixing toward a\n * neighbour, which keeps the original's brick → amber → forest character.\n */\nconst RAMP: readonly { at: number; colour: string }[] = [\n  { at: 0, colour: \"var(--stop)\" },\n  { at: 0.2, colour: \"color-mix(in oklab, var(--stop) 45%, var(--ramp-orange))\" },\n  { at: 0.4, colour: \"var(--ramp-orange)\" },\n  { at: 0.55, colour: \"var(--milestone-yellow)\" },\n  { at: 0.7, colour: \"color-mix(in oklab, var(--go-pale) 40%, var(--go))\" },\n  { at: 0.85, colour: \"var(--go)\" },\n  { at: 1, colour: \"color-mix(in oklab, var(--go) 78%, var(--ink))\" },\n];\n\n/** The ramp at `fraction`, as a CSS colour. Pass it through `style`. */\nfunction rampColour(fraction: number): string {\n  const f = clamp01(fraction);\n  let lower = RAMP[0];\n  let upper = RAMP[RAMP.length - 1];\n  for (let i = 0; i < RAMP.length - 1; i += 1) {\n    if (f >= RAMP[i].at && f <= RAMP[i + 1].at) {\n      lower = RAMP[i];\n      upper = RAMP[i + 1];\n      break;\n    }\n  }\n  const span = upper.at - lower.at;\n  const t = span <= 0 ? 0 : (f - lower.at) / span;\n  const lowerShare = Math.round((1 - t) * 1000) / 10;\n  return `color-mix(in oklab, ${lower.colour} ${lowerShare}%, ${upper.colour})`;\n}\n\n/* ------------------------------------------------------------------------ */\n/* Geometry                                                                 */\n/* ------------------------------------------------------------------------ */\n\nconst START_ANGLE_DEG = 145;\nconst SWEEP_DEG = 250;\nconst CX = 100;\nconst CY = 100;\n\nconst clamp01 = (n: number) => Math.max(0, Math.min(1, n));\nconst round = (n: number) => Math.round(n * 1000) / 1000;\n\nfunction angleAt(fraction: number): number {\n  return ((START_ANGLE_DEG + fraction * SWEEP_DEG) * Math.PI) / 180;\n}\n\nfunction pointAt(fraction: number, radius: number): { x: number; y: number } {\n  const angle = angleAt(fraction);\n  return {\n    x: round(CX + radius * Math.cos(angle)),\n    y: round(CY + radius * Math.sin(angle)),\n  };\n}\n\ntype Geometry = {\n  ticks: number;\n  tickInner: number;\n  tickOuter: number;\n  tickWidth: number;\n  needleTip: number;\n  needleTail: number;\n  needleHalfWidth: number;\n};\n\nconst GEOMETRY: Record<\"full\" | \"mini\", Geometry> = {\n  full: {\n    ticks: 60,\n    tickInner: 72,\n    tickOuter: 94,\n    tickWidth: 3.4,\n    needleTip: 62,\n    needleTail: 15,\n    needleHalfWidth: 4,\n  },\n  mini: {\n    ticks: 24,\n    tickInner: 74,\n    tickOuter: 94,\n    tickWidth: 5.2,\n    needleTip: 62,\n    needleTail: 13,\n    needleHalfWidth: 5,\n  },\n};\n\n/** Scale labels sit just outside the comb. */\nconst R_LABEL = 107;\n\nfunction buildTicks(geometry: Geometry) {\n  return Array.from({ length: geometry.ticks }, (_, index) => {\n    const centre = (index + 0.5) / geometry.ticks;\n    const base = pointAt(centre, geometry.tickInner);\n    const tip = pointAt(centre, geometry.tickOuter);\n    return {\n      index,\n      centre,\n      colour: rampColour(centre),\n      x1: base.x,\n      y1: base.y,\n      x2: tip.x,\n      y2: tip.y,\n    };\n  });\n}\n\nconst FULL_TICKS = buildTicks(GEOMETRY.full);\nconst MINI_TICKS = buildTicks(GEOMETRY.mini);\n\n/** The needle as a tapered wedge from behind the hub to short of the comb. */\nfunction needlePoints(fraction: number, geometry: Geometry): string {\n  const angle = angleAt(fraction);\n  const px = -Math.sin(angle) * geometry.needleHalfWidth;\n  const py = Math.cos(angle) * geometry.needleHalfWidth;\n  const tip = pointAt(fraction, geometry.needleTip);\n  const tail = pointAt(fraction, -geometry.needleTail);\n  return [\n    `${round(tail.x + px)},${round(tail.y + py)}`,\n    `${round(tip.x)},${round(tip.y)}`,\n    `${round(tail.x - px)},${round(tail.y - py)}`,\n  ].join(\" \");\n}\n\n/** An arc along the scale, as a stroke-able path. Used for the hover targets. */\nfunction arcPath(from: number, to: number, radius: number): string {\n  const start = pointAt(from, radius);\n  const end = pointAt(to, radius);\n  const largeArc = (to - from) * SWEEP_DEG > 180 ? 1 : 0;\n  return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;\n}\n\n/** Which side of the dial a label hangs off, so text never sits on the arc. */\nfunction labelAnchor(fraction: number): \"start\" | \"middle\" | \"end\" {\n  const cos = Math.cos(angleAt(fraction));\n  if (cos < -0.25) return \"end\";\n  if (cos > 0.25) return \"start\";\n  return \"middle\";\n}\n\n/* ------------------------------------------------------------------------ */\n/* Motion                                                                   */\n/* ------------------------------------------------------------------------ */\n\n/**\n * The needle's entrance: at rest on zero until the dial is first seen, then a\n * slow spring carries it to the reading — the way a physical needle swings\n * over and settles, with a whisper of overshoot. Duration-based, so timing is\n * the same whatever the score. A later change of value swings from wherever\n * the needle is. Reduced motion gets the reading with no sweep at all.\n */\nfunction useNeedleSweep(\n  target: number | null,\n  ref: RefObject<Element | null>,\n): number | null {\n  const reduceMotion = useReducedMotionConfig();\n  const inView = useInView(ref, { once: true, amount: 0.35 });\n  const [swept, setSwept] = useState(0);\n  const current = useRef(0);\n\n  useEffect(() => {\n    if (target === null || reduceMotion || !inView) return;\n    const controls = animate(current.current, target, {\n      type: \"spring\",\n      duration: 1.8,\n      bounce: 0.12,\n      onUpdate: (value) => {\n        current.current = value;\n        setSwept(value);\n      },\n      onComplete: () => {\n        current.current = target;\n        setSwept(target);\n      },\n    });\n    return () => controls.stop();\n  }, [target, inView, reduceMotion]);\n\n  if (target === null) return null;\n  if (reduceMotion) return target;\n  // The spring may breathe a hair past either end; the dial draws 0–1 only.\n  return clamp01(swept);\n}\n\n/* ------------------------------------------------------------------------ */\n/* Tooltip placement                                                        */\n/* ------------------------------------------------------------------------ */\n\nconst TOOLTIP_W = 220;\nconst TOOLTIP_H = 200;\n\ntype TooltipPlacement = { style: CSSProperties; arrowStyle: CSSProperties };\n\n/**\n * Beside the gauge rather than over it: left if there is room, else right,\n * else (on a phone) below.\n */\nfunction placeTooltip(\n  cursor: { x: number; y: number } | null,\n  rect: DOMRect | null,\n  span: { from: number; to: number },\n): TooltipPlacement {\n  let cy = cursor?.y ?? 80;\n  if (!cursor) {\n    const pt = pointAt((span.from + span.to) / 2, R_HIT);\n    const scale = ((rect?.width ?? 250) * (188 / 260)) / 188;\n    cy = (pt.y + 12) * scale;\n  }\n\n  const spaceLeft = rect ? rect.left : 400;\n  const spaceRight =\n    rect && typeof window !== \"undefined\" ? window.innerWidth - rect.right : 200;\n\n  const top = Math.max(0, Math.min(80, cy - 60));\n  const arrowTop = Math.max(16, Math.min(TOOLTIP_H - 16, cy - top));\n\n  if (spaceLeft >= TOOLTIP_W + 16) {\n    return {\n      style: { right: \"calc(100% + 14px)\", top },\n      arrowStyle: { right: -5, top: arrowTop, transform: \"translateY(-50%) rotate(45deg)\" },\n    };\n  }\n  if (spaceRight >= TOOLTIP_W + 16) {\n    return {\n      style: { left: \"calc(100% + 14px)\", top },\n      arrowStyle: { left: -5, top: arrowTop, transform: \"translateY(-50%) rotate(45deg)\" },\n    };\n  }\n  const arrowLeft = Math.max(16, Math.min(TOOLTIP_W - 16, cursor?.x ?? 110));\n  return {\n    style: { top: \"calc(100% + 12px)\", left: \"50%\", transform: \"translateX(-50%)\" },\n    arrowStyle: { top: -5, left: arrowLeft, transform: \"translateX(-50%) rotate(45deg)\" },\n  };\n}\n\n/** The hover target: one fat transparent arc per zone, swallowing the comb. */\nconst R_HIT = (GEOMETRY.full.tickInner + GEOMETRY.full.tickOuter) / 2;\nconst HIT_WIDTH = GEOMETRY.full.tickOuter - GEOMETRY.full.tickInner + 8;\n\n/* ------------------------------------------------------------------------ */\n/* Component                                                                */\n/* ------------------------------------------------------------------------ */\n\nexport type ScoreGaugeReadout = { label: string; value: ReactNode };\n\nexport type ScoreGaugeProps = {\n  /** The score, 0–1. `null` draws the scale faded with no needle. */\n  value: number | null;\n  /** `full` for a record header, `mini` for a card in a list. */\n  size?: \"full\" | \"mini\";\n  /** What the score is called, in the readout and for screen readers. */\n  label?: string;\n  /** The bands. Defaults to six tiers at 0.20 / 0.40 / 0.55 / 0.70 / 0.85. */\n  zones?: ScoreGaugeZone[];\n  /** Extra rows under the band in the full readout (\"Parameters: 3 of 5\"). */\n  readouts?: ScoreGaugeReadout[];\n  /** One closing line in every zone explainer. Full size only. */\n  footnote?: string;\n  /** What to print with no score. */\n  emptyLabel?: string;\n  /** A zone was pinned (tap / Enter) or unpinned (`null`). Full size only. */\n  onZoneSelect?: (zoneId: string | null) => void;\n  className?: string;\n};\n\nexport function ScoreGauge({ size = \"full\", ...props }: ScoreGaugeProps) {\n  return size === \"mini\" ? <MiniDial {...props} /> : <FullDial {...props} />;\n}\n\ntype InnerProps = Omit<ScoreGaugeProps, \"size\">;\n\nfunction Hub({ shown, children }: { shown: boolean; children?: ReactNode }) {\n  return shown ? (\n    <g className=\"text-ink\">\n      {children}\n      <circle cx={CX} cy={CY} r={11} className=\"fill-paper-sunk\" />\n      <circle cx={CX} cy={CY} r={6} fill=\"currentColor\" />\n    </g>\n  ) : (\n    <circle cx={CX} cy={CY} r={9} className=\"fill-paper-sunk\" />\n  );\n}\n\nfunction FullDial({\n  value,\n  label = \"Priority\",\n  zones = DEFAULT_PRIORITY_ZONES,\n  readouts,\n  footnote,\n  emptyLabel = \"Not scored yet\",\n  onZoneSelect,\n  className,\n}: InnerProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);\n  const [rect, setRect] = useState<DOMRect | null>(null);\n  const [hoveredZone, setHoveredZone] = useState<string | null>(null);\n  // Pinning makes the explanation reachable on a touch screen.\n  const [pinnedZone, setPinnedZone] = useState<string | null>(null);\n\n  const clamped = value === null ? null : clamp01(value);\n  const shown = useNeedleSweep(clamped, containerRef);\n  const zone = shown === null ? null : zoneOf(zones, shown);\n  const finalZone = clamped === null ? null : zoneOf(zones, clamped);\n\n  const scaleMarks = useMemo(() => {\n    const cuts = new Set<number>([0, 1]);\n    for (const z of zones) cuts.add(z.from);\n    return [...cuts].sort((a, b) => a - b);\n  }, [zones]);\n\n  const activeId = hoveredZone ?? pinnedZone;\n  const activeZone = activeId === null ? null : (zones.find((z) => z.id === activeId) ?? null);\n  const placement = activeZone ? placeTooltip(cursor, rect, activeZone) : null;\n\n  const togglePin = (id: string) => {\n    const next = pinnedZone === id ? null : id;\n    setPinnedZone(next);\n    onZoneSelect?.(next);\n  };\n\n  const handleMouseMove = (event: MouseEvent<HTMLDivElement>) => {\n    if (!containerRef.current) return;\n    const box = containerRef.current.getBoundingClientRect();\n    setRect(box);\n    setCursor({ x: event.clientX - box.left, y: event.clientY - box.top });\n  };\n\n  const srExtras = (readouts ?? [])\n    .map((r) => (typeof r.value === \"string\" || typeof r.value === \"number\" ? `, ${r.label} ${r.value}` : \"\"))\n    .join(\"\");\n\n  return (\n    <div\n      ref={containerRef}\n      onMouseMove={handleMouseMove}\n      className={cn(\n        \"relative flex min-h-0 w-[210px] max-w-full shrink-0 flex-col items-center sm:w-[230px] md:w-[250px] lg:w-[275px] xl:w-[290px]\",\n        className,\n      )}\n    >\n      <p className=\"sr-only\">\n        {clamped === null\n          ? `${label}: ${emptyLabel.toLowerCase()}.`\n          : `${label} ${clamped.toFixed(2)} out of 1.00${\n              finalZone ? `, ${finalZone.label.toLowerCase()}` : \"\"\n            }${srExtras}.`}\n      </p>\n      <div\n        role=\"group\"\n        aria-label={`${label} scale. Each zone explains what landing in it means.`}\n        className=\"w-full\"\n      >\n        <svg viewBox=\"-30 -12 260 188\" className=\"block h-auto w-full select-none\">\n          <g\n            strokeWidth={GEOMETRY.full.tickWidth}\n            strokeLinecap=\"round\"\n            opacity={clamped === null ? 0.3 : 1}\n          >\n            {FULL_TICKS.map((tick) => (\n              <line\n                key={tick.index}\n                x1={tick.x1}\n                y1={tick.y1}\n                x2={tick.x2}\n                y2={tick.y2}\n                // `style`, not `stroke={…}`: the colour is a CSS `color-mix()`,\n                // which a presentation attribute does not parse.\n                style={{ stroke: tick.colour }}\n                opacity={\n                  activeId === null || zoneOf(zones, tick.centre)?.id === activeId ? 1 : 0.22\n                }\n                className=\"transition-opacity duration-150\"\n              />\n            ))}\n          </g>\n\n          {scaleMarks.map((mark) => {\n            const at = pointAt(mark, R_LABEL);\n            return (\n              <text\n                key={mark}\n                x={at.x}\n                y={at.y + 3.5}\n                textAnchor={labelAnchor(mark)}\n                fontSize={9.5}\n                fontWeight={600}\n                className=\"fill-faint font-body\"\n              >\n                {fmt(mark)}\n              </text>\n            );\n          })}\n\n          <Hub shown={shown !== null}>\n            {shown !== null && (\n              <polygon points={needlePoints(shown, GEOMETRY.full)} fill=\"currentColor\" />\n            )}\n          </Hub>\n\n          {zones.map((z) => {\n            const words = [z.description, z.detail, footnote].filter(Boolean).join(\" \");\n            return (\n              <path\n                key={`hit-${z.id}`}\n                d={arcPath(z.from, z.to, R_HIT)}\n                fill=\"none\"\n                // Paints nothing: it is the hit area, only its width matters.\n                stroke=\"currentColor\"\n                strokeOpacity={0}\n                strokeWidth={HIT_WIDTH}\n                strokeLinecap=\"butt\"\n                tabIndex={0}\n                role=\"button\"\n                aria-pressed={pinnedZone === z.id}\n                aria-label={`${z.label}, ${rangeOf(z)}.${words ? ` ${words}` : \"\"}`}\n                className=\"cursor-help outline-none\"\n                onMouseEnter={() => setHoveredZone(z.id)}\n                onMouseLeave={() => setHoveredZone(null)}\n                onFocus={() => setHoveredZone(z.id)}\n                onBlur={() => setHoveredZone(null)}\n                onClick={() => togglePin(z.id)}\n                onKeyDown={(event) => {\n                  if (event.key === \"Enter\" || event.key === \" \") {\n                    event.preventDefault();\n                    togglePin(z.id);\n                  } else if (event.key === \"Escape\") {\n                    setPinnedZone(null);\n                    onZoneSelect?.(null);\n                  }\n                }}\n              />\n            );\n          })}\n        </svg>\n      </div>\n\n      <dl className=\"mt-2 w-full px-1\">\n        <div className=\"flex items-baseline justify-between gap-3 border-b border-rule-soft pb-1.5\">\n          <dt className=\"font-body text-[11px] font-bold tracking-[0.04em] text-dim uppercase lg:text-[12px]\">\n            {label}\n          </dt>\n          <dd className=\"font-body text-[22px] leading-none font-semibold tracking-[-0.03em] text-ink tabular-nums lg:text-[25px]\">\n            {shown === null ? \"—\" : shown.toFixed(2)}\n          </dd>\n        </div>\n\n        <div\n          className={cn(\n            \"flex items-baseline justify-between gap-3 py-1.5\",\n            readouts?.length ? \"border-b border-rule-soft\" : null,\n          )}\n        >\n          <dt className=\"font-body text-[12px] font-medium text-faint lg:text-[12.5px]\">Band</dt>\n          <dd\n            className={cn(\n              \"font-body text-[12.5px] font-semibold tracking-[-0.01em] lg:text-[13.5px]\",\n              zone ? TONE_TEXT[toneOf(zone)] : \"text-faint\",\n            )}\n          >\n            {clamped === null ? emptyLabel : (zone?.label ?? \"—\")}\n          </dd>\n        </div>\n\n        {readouts?.map((row, index) => (\n          <div\n            key={row.label}\n            className={cn(\n              \"flex items-baseline justify-between gap-3 pt-1.5\",\n              index < readouts.length - 1 ? \"border-b border-rule-soft pb-1.5\" : null,\n            )}\n          >\n            <dt className=\"font-body text-[12px] font-medium text-faint lg:text-[12.5px]\">\n              {row.label}\n            </dt>\n            <dd className=\"font-body text-[12.5px] font-semibold text-dim tabular-nums lg:text-[13px]\">\n              {row.value}\n            </dd>\n          </div>\n        ))}\n      </dl>\n\n      {/* The zone explainer, beside the gauge rather than over it. */}\n      <AnimatePresence>\n        {activeZone && placement && (\n          <motion.div\n            initial={{ opacity: 0, scale: 0.95 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.95 }}\n            transition={{ duration: 0.12, ease: \"easeOut\" }}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute z-50 w-[220px] rounded-inset border border-on-solid/10 bg-ink p-3 font-body shadow-[0_12px_32px_color-mix(in_oklab,var(--ink)_40%,transparent)]\"\n            style={placement.style}\n          >\n            <div className=\"flex items-baseline justify-between gap-1.5 border-b border-on-solid/10 pb-2\">\n              <div className=\"flex min-w-0 items-center gap-1.5\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-2 shrink-0 rounded-full\"\n                  style={{ backgroundColor: rampColour((activeZone.from + activeZone.to) / 2) }}\n                />\n                <span className=\"truncate text-[12px] font-semibold tracking-[-0.01em] text-on-solid\">\n                  {activeZone.label}\n                </span>\n              </div>\n              <span className=\"shrink-0 text-[10.5px] font-medium text-on-solid/50 tabular-nums\">\n                {rangeOf(activeZone)}\n              </span>\n            </div>\n\n            {activeZone.description && (\n              <p className=\"mt-2 text-[11.5px] leading-[1.5] text-on-solid/80\">\n                {activeZone.description}\n              </p>\n            )}\n\n            {activeZone.detail && (\n              <p className=\"mt-2 border-t border-on-solid/10 pt-1.5 text-[11px] leading-[1.4] text-on-solid/60\">\n                {activeZone.detail}\n              </p>\n            )}\n\n            {footnote && (\n              <p className=\"mt-1.5 text-[10.5px] leading-[1.35] font-medium text-on-solid/40\">\n                {footnote}\n              </p>\n            )}\n\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute z-50 size-2.5 rounded-[2px] bg-primary\"\n              style={placement.arrowStyle}\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction MiniDial({\n  value,\n  label = \"Priority\",\n  zones = DEFAULT_PRIORITY_ZONES,\n  emptyLabel = \"Not scored yet\",\n  className,\n}: InnerProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const clamped = value === null ? null : clamp01(value);\n  const shown = useNeedleSweep(clamped, ref);\n  const zone = shown === null ? null : zoneOf(zones, shown);\n  const finalZone = clamped === null ? null : zoneOf(zones, clamped);\n\n  return (\n    <span\n      ref={ref}\n      role=\"img\"\n      aria-label={\n        clamped === null\n          ? emptyLabel\n          : `${label} ${clamped.toFixed(1)} out of 1${\n              finalZone ? `, ${finalZone.label.toLowerCase()}` : \"\"\n            }`\n      }\n      className={cn(\"flex shrink-0 flex-col items-center\", className)}\n    >\n      <svg viewBox=\"2 2 196 160\" aria-hidden=\"true\" className=\"block h-auto w-[78px] select-none\">\n        <g\n          strokeWidth={GEOMETRY.mini.tickWidth}\n          strokeLinecap=\"round\"\n          opacity={clamped === null ? 0.3 : 1}\n        >\n          {MINI_TICKS.map((tick) => (\n            <line\n              key={tick.index}\n              x1={tick.x1}\n              y1={tick.y1}\n              x2={tick.x2}\n              y2={tick.y2}\n              style={{ stroke: tick.colour }}\n            />\n          ))}\n        </g>\n        <Hub shown={shown !== null}>\n          {shown !== null && (\n            <polygon points={needlePoints(shown, GEOMETRY.mini)} fill=\"currentColor\" />\n          )}\n        </Hub>\n      </svg>\n\n      <span className=\"mt-0.5 block font-body text-[19px] leading-none font-bold tracking-[-0.02em] text-ink tabular-nums\">\n        {shown === null ? \"—\" : shown.toFixed(1)}\n        <span className=\"ml-0.5 text-[10px] font-semibold text-faint\">/1</span>\n      </span>\n      <span\n        className={cn(\n          \"mt-1 block text-center font-body text-[10px] leading-[1.2] font-bold tracking-[0.02em]\",\n          zone ? TONE_TEXT[toneOf(zone)] : \"text-faint\",\n        )}\n      >\n        {zone?.label ?? emptyLabel}\n      </span>\n    </span>\n  );\n}\n\nexport default ScoreGauge;\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Reach for it when one bounded score (priority, fit, health) needs to be read against its whole scale, at full size with per-zone explanations in a record header or as a mini dial on list cards. Not for unbounded numbers, trends over time, or comparing several scores side by side.",
    "origin": {
      "project": "180Connect",
      "path": "src/app/(app)/clients/[id]/priority-dial.tsx (mini size from src/components/dashboard/priority-mini-dial.tsx; scale from src/lib/scoring/priority-scale.ts)"
    },
    "credits": []
  },
  "type": "registry:component"
}
