{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating-stamp",
  "title": "Rating Stamp",
  "description": "Rating / level input rendered as a row of seal impressions: chosen levels stamp solid with a physical thud and an expanding impression ring, the rest wait as faint unfilled outlines, so the value reads unambiguously even in a still frame. A radio-group underneath, not a decoration.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/rating-stamp/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChopPress — a rating / level input rendered as a row of seal impressions.\n// Chosen levels are stamped solid with a short physical \"thud\" (scale\n// 1.15 -> 1.0, ease-out-expo) while a single concentric ring in --border\n// expands and fades outward, reading as paper taking the impression.\n// Unfilled levels wait as faint --ns-muted outlines. Lowering the value drains\n// marks right-to-left with a 30ms stagger, as if the ink is lifting off in\n// sequence. Hovering (or focusing) a level dashes the outline of whichever\n// marks would change if you committed there, without committing anything.\n//\n// Real ARIA radiogroup semantics underneath: one role=\"radio\" per level,\n// only the committed level's radio is aria-checked, roving tabindex, arrow\n// keys move AND commit (Home/End jump to the ends), and each radio's\n// accessible name reads \"N of M\" plus an optional word (\"3 of 5 - High\").\n// The stamp is a purely visual layer on top — nothing here depends on a\n// screen reader ever seeing the seal glyph. Reduced motion: stamps land and\n// drain instantly, no scale/ring/stagger.\n//\n// Pure DOM/SVG/CSS — no canvas. All ink is token-relative: --foreground for\n// the stamp body, --ns-muted for resting outlines, --border for the impression\n// ring, --ns-accent only for the keyboard focus ring.\n// ---------------------------------------------------------------------------\n\nconst PRESS_MS = 140; // press-landing scale, ease-out-expo\nconst RING_MS = 380; // impression ring expand + fade\nconst PRESS_FILL_MS = 90; // ink appears fast — a thud, not a fade-in\nconst DRAIN_FILL_MS = 170; // ink lifts gradually\nconst STAGGER_MS = 30; // per-mark delay, both directions\n\nconst SIZE_PX = { sm: 26, md: 34, lg: 42 } as const;\n\ntype Kind = \"press\" | \"drain\";\n\nexport interface ChopPressProps {\n  /** Accessible name for the radiogroup, e.g. \"Priority\". */\n  label: string;\n  /** Number of levels. @default 5 */\n  max?: number;\n  /** Controlled value, 0..max. 0 = nothing stamped yet. */\n  value?: number;\n  /** Initial value when uncontrolled. @default 0 */\n  defaultValue?: number;\n  /** Fires on commit — click, or an arrow/Home/End key move. Never on hover. */\n  onValueChange?: (value: number) => void;\n  /**\n   * Optional word per level (index 0 = level 1), appended to that level's\n   * accessible name — e.g. `[\"Low\", \"\", \"\", \"\", \"Critical\"]` reads level 5\n   * as \"5 of 5 - Critical\". Sparse arrays are fine, empty entries are\n   * skipped.\n   */\n  levelNames?: string[];\n  /** @default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ntype Vars = React.CSSProperties & Record<`--${string}`, string | number>;\n\nfunction clampLevel(v: number, max: number) {\n  if (!Number.isFinite(v)) return 0;\n  return Math.min(max, Math.max(0, Math.round(v)));\n}\n\n// Deterministic per-index variation (rotation + corner radius) so seals read\n// as individually struck rather than one glyph rubber-stamped M times.\n// Seeded off the index only — identical across renders and between the two\n// themes.\nfunction sealVariant(i: number) {\n  const s = Math.sin(i * 12.9898 + 4.1414) * 43758.5453;\n  const frac = s - Math.floor(s);\n  return {\n    rotate: (frac - 0.5) * 5, // -2.5..2.5deg\n    rx: 3.2 + frac * 1.8, // 3.2..5 corner radius, viewBox units\n  };\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\nexport function ChopPress({\n  label,\n  max = 5,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  levelNames,\n  size = \"md\",\n  className = \"\",\n}: ChopPressProps) {\n  const M = Math.max(1, Math.floor(max));\n  const isControlled = value !== undefined;\n  const [internal, setInternal] = useState(() => clampLevel(defaultValue, M));\n  const committed = clampLevel(isControlled ? (value as number) : internal, M);\n\n  const reducedMotion = useReducedMotion();\n  const prevRef = useRef(committed);\n  const [batch, setBatch] = useState(0);\n  const [transitions, setTransitions] = useState<\n    Map<number, { kind: Kind; delay: number }>\n  >(new Map());\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const groupId = useId();\n\n  // Runs before paint whenever the committed value changes, whichever path\n  // caused it (click, arrow key, or a controlled prop update from outside).\n  // Building the transition map here — not inside the click handler — means\n  // both paths animate identically and there's no one-frame flash of the\n  // final state before the animation classes attach.\n  useLayoutEffect(() => {\n    const prev = prevRef.current;\n    if (committed !== prev) {\n      if (reducedMotion) {\n        setTransitions(new Map());\n      } else {\n        const map = new Map<number, { kind: Kind; delay: number }>();\n        if (committed > prev) {\n          for (let i = prev; i < committed; i++) {\n            map.set(i, { kind: \"press\", delay: (i - prev) * STAGGER_MS });\n          }\n        } else {\n          for (let i = committed; i < prev; i++) {\n            // right-to-left: the highest index (rightmost) drains first.\n            map.set(i, { kind: \"drain\", delay: (prev - 1 - i) * STAGGER_MS });\n          }\n        }\n        setTransitions(map);\n        setBatch((b) => b + 1);\n      }\n      prevRef.current = committed;\n    }\n  }, [committed, reducedMotion]);\n\n  const commit = useCallback(\n    (next: number) => {\n      const clamped = clampLevel(next, M);\n      if (!isControlled) setInternal(clamped);\n      if (clamped !== committed) onValueChange?.(clamped);\n    },\n    [M, isControlled, committed, onValueChange]\n  );\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    let next: number | null = null;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") {\n      next = Math.min(M, Math.max(1, committed + 1));\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") {\n      next = Math.max(1, committed - 1);\n    } else if (e.key === \"Home\") {\n      next = 1;\n    } else if (e.key === \"End\") {\n      next = M;\n    } else {\n      return;\n    }\n    e.preventDefault();\n    commit(next);\n    btnRefs.current[next - 1]?.focus();\n  };\n\n  const levels = useMemo(() => Array.from({ length: M }, (_, i) => i), [M]);\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      onKeyDown={onKeyDown}\n      data-reduced={reducedMotion || undefined}\n      className={`ns-cp-group ${className}`}\n      style={{ [\"--cp-size\"]: `${SIZE_PX[size]}px` } as Vars}\n    >\n      {levels.map((i) => {\n        const filled = i < committed;\n        const previewTarget = hoverIndex !== null ? i <= hoverIndex : filled;\n        const previewing = hoverIndex !== null && previewTarget !== filled;\n        const checked = i === committed - 1;\n        const t = transitions.get(i);\n        const variant = sealVariant(i);\n        const level = i + 1;\n        const name = levelNames?.[i];\n        const ariaLabel = `${level} of ${M}${name ? ` - ${name}` : \"\"}`;\n        const tabbable = committed === 0 ? i === 0 : i === committed - 1;\n        const fillMs = t?.kind === \"drain\" ? DRAIN_FILL_MS : PRESS_FILL_MS;\n\n        return (\n          <button\n            key={i}\n            ref={(el) => {\n              btnRefs.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={ariaLabel}\n            id={`${groupId}-${i}`}\n            tabIndex={tabbable ? 0 : -1}\n            data-transition={t?.kind}\n            className=\"ns-cp-seal\"\n            style={\n              {\n                \"--cp-delay\": `${t?.delay ?? 0}ms`,\n                \"--cp-fill-ms\": `${fillMs}ms`,\n              } as Vars\n            }\n            onClick={() => commit(level)}\n            onMouseEnter={() => setHoverIndex(i)}\n            onMouseLeave={() => setHoverIndex((h) => (h === i ? null : h))}\n            onFocus={() => setHoverIndex(i)}\n            onBlur={() => setHoverIndex((h) => (h === i ? null : h))}\n          >\n            <svg\n              viewBox=\"0 0 32 32\"\n              aria-hidden=\"true\"\n              focusable=\"false\"\n              className=\"ns-cp-glyph\"\n              style={{ transform: `rotate(${variant.rotate.toFixed(2)}deg)` }}\n            >\n              <rect\n                x={3}\n                y={3}\n                width={26}\n                height={26}\n                rx={variant.rx}\n                className=\"ns-cp-base\"\n                data-filled={filled || undefined}\n                data-preview={previewing || undefined}\n              />\n              <path\n                d={`M 6 ${(6 + variant.rx).toFixed(1)} L 6 6 L ${(6 + variant.rx).toFixed(1)} 6`}\n                className=\"ns-cp-edge-hi\"\n                data-filled={filled || undefined}\n              />\n              <path\n                d={`M ${(26 - variant.rx).toFixed(1)} 26 L 26 26 L 26 ${(26 - variant.rx).toFixed(1)}`}\n                className=\"ns-cp-edge-lo\"\n                data-filled={filled || undefined}\n              />\n            </svg>\n            {t?.kind === \"press\" && (\n              <span key={`ring-${batch}-${i}`} className=\"ns-cp-ring\" aria-hidden=\"true\" />\n            )}\n          </button>\n        );\n      })}\n\n      <style>{`\n        .ns-cp-group {\n          display: inline-flex;\n          align-items: center;\n          gap: 8px;\n        }\n        .ns-cp-seal {\n          position: relative;\n          width: var(--cp-size, 34px);\n          height: var(--cp-size, 34px);\n          padding: 0;\n          border: none;\n          border-radius: 6px;\n          background: transparent;\n          color: inherit;\n          cursor: pointer;\n          display: grid;\n          place-items: center;\n          outline: none;\n          -webkit-tap-highlight-color: transparent;\n        }\n        .ns-cp-seal:hover {\n          background: color-mix(in oklab, var(--foreground) 4%, transparent);\n        }\n        .ns-cp-seal:focus-visible {\n          outline: 2px solid var(--ns-accent);\n          outline-offset: 2px;\n        }\n        .ns-cp-glyph {\n          width: 68%;\n          height: 68%;\n          overflow: visible;\n        }\n        .ns-cp-base {\n          /* fill is always --foreground; only fill-opacity animates. a\n             color <-> \"none\" transition is a discrete 50% flip in every\n             engine, not an interpolation — fill-opacity is a plain number\n             and always interpolates smoothly. */\n          fill: var(--foreground);\n          fill-opacity: 0;\n          stroke: var(--ns-muted);\n          stroke-width: 1.5px;\n          transition:\n            fill-opacity var(--cp-fill-ms, 140ms) ease var(--cp-delay, 0ms),\n            stroke 140ms ease var(--cp-delay, 0ms),\n            stroke-dasharray 120ms ease;\n        }\n        .ns-cp-base[data-filled] {\n          fill-opacity: 1;\n          stroke-opacity: 0.4;\n        }\n        .ns-cp-base[data-preview] {\n          stroke-dasharray: 3 2.5;\n          stroke: color-mix(in oklab, var(--foreground) 55%, var(--ns-muted));\n        }\n        .ns-cp-edge-hi,\n        .ns-cp-edge-lo {\n          fill: none;\n          stroke-width: 1px;\n          opacity: 0;\n          transition: opacity var(--cp-fill-ms, 140ms) ease var(--cp-delay, 0ms);\n        }\n        .ns-cp-edge-hi[data-filled],\n        .ns-cp-edge-lo[data-filled] {\n          opacity: 1;\n        }\n        .ns-cp-edge-hi {\n          stroke: color-mix(in oklab, var(--background) 45%, transparent);\n        }\n        .ns-cp-edge-lo {\n          stroke: var(--border);\n        }\n\n        .ns-cp-seal[data-transition=\"press\"] {\n          animation: ns-cp-press ${PRESS_MS}ms cubic-bezier(0.16, 1, 0.3, 1) both;\n          animation-delay: var(--cp-delay, 0ms);\n        }\n\n        .ns-cp-ring {\n          position: absolute;\n          inset: 2px;\n          border-radius: 6px;\n          border: 1px solid var(--border);\n          pointer-events: none;\n          opacity: 0.9;\n          transform: scale(0.85);\n          animation: ns-cp-ring ${RING_MS}ms ease-out both;\n          animation-delay: var(--cp-delay, 0ms);\n        }\n\n        @keyframes ns-cp-press {\n          from { transform: scale(1.15); }\n          to { transform: scale(1); }\n        }\n        @keyframes ns-cp-ring {\n          from { opacity: 0.9; transform: scale(0.9); }\n          to { opacity: 0; transform: scale(1.6); }\n        }\n\n        .ns-cp-group[data-reduced] .ns-cp-base,\n        .ns-cp-group[data-reduced] .ns-cp-edge-hi,\n        .ns-cp-group[data-reduced] .ns-cp-edge-lo {\n          transition: none;\n        }\n        .ns-cp-group[data-reduced] .ns-cp-seal[data-transition],\n        .ns-cp-group[data-reduced] .ns-cp-ring {\n          animation: none;\n          display: none;\n        }\n\n        @media (prefers-reduced-motion: reduce) {\n          .ns-cp-base,\n          .ns-cp-edge-hi,\n          .ns-cp-edge-lo {\n            transition: none;\n          }\n          .ns-cp-seal[data-transition],\n          .ns-cp-ring {\n            animation: none;\n          }\n        }\n      `}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/rating-stamp.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": [
      "rating",
      "radio-group",
      "form",
      "level-picker",
      "stamp",
      "micro-interaction"
    ],
    "instruction": "A rating / level picker (priority, difficulty, satisfaction) rendered as a row of physical seal impressions instead of stars. Each level is one SVG square seal glyph, subtly varied per index by a deterministic seed (small +/-2.5deg rotation, 3.2-5 corner radius) so the row reads as individually struck rather than one glyph rubber-stamped M times. STRUCTURE: real ARIA radiogroup — one role=radio button per level, only the committed level's radio carries aria-checked=true (the rest of the visually-filled marks below it are aria-checked=false, mirroring how sighted-only 'fill up to N' works in a star rating: screen readers hear a single-select list, not a range), roving tabindex so Tab reaches exactly one stop, and each radio's accessible name is 'N of M' plus an optional word from levelNames (e.g. '3 of 5 - High'). MECHANISM: committing upward — click any level, or ArrowRight/Up/Home/End on the group (arrow keys move AND commit, matching native radiogroup behavior; DOM focus is moved imperatively to the newly-committed radio so the visual focus ring always tracks the ARIA-checked one) — scales each newly-filled mark 1.15 -> 1.0 over 140ms ease-out-expo (cubic-bezier(0.16,1,0.3,1)), the press landing, while a single concentric ring in --border expands from scale(0.9) to scale(1.6) and fades opacity 0.9 -> 0 over 380ms, reading as paper taking the impression; ink itself (fill: none -> var(--foreground)) appears fast, over 90ms, a thud rather than a fade-in. Lowering the value drains the marks that fall out of range right-to-left (highest index first) with a 30ms stagger per mark, fill fading out over 170ms — ink lifting off in sequence, the mirror image of stamping down. Both directions are driven by one CSS custom-property-timed transition/animation pair (--cp-delay, --cp-fill-ms) computed in a layout effect keyed off the committed value, so a controlled value change from outside animates identically to a click. Unfilled marks are a 1px --ns-muted outline square; filled marks add two small L-shaped corner strokes (top-left in a --background-mixed highlight, bottom-right in --border) suggesting an embossed bevel. HOVER / FOCUS PREVIEW: hovering (or keyboard-focusing) a level dashes the outline (stroke-dasharray, stroke shifted toward --foreground) of every mark whose filled state would change if you committed there — whether that means marks about to gain ink or marks about to lose it — without touching the committed value; the same state variable drives both pointer and focus so the preview genuinely mirrors between mouse and keyboard. REDUCED MOTION: the press/ring/drain animations and their transition-delays are stripped entirely (both via a JS matchMedia check that skips building the transition map, and a belt-and-suspenders prefers-reduced-motion CSS block) — marks snap straight to their filled/unfilled state, fully legible and functional, just not animated. Pure DOM+SVG+CSS, no canvas; all ink is token-relative (--foreground, --ns-muted, --border, --background, --ns-accent only for the keyboard focus ring) so both themes restyle for free. Distinct from pricing-scale (a two-pan pricing/plan balance-scale with canvas beam physics) by being a compact inline rating field for ordinary forms whose entire mechanism is the stamp impression, not a weighing metaphor — the registry otherwise has no rating input at all."
  },
  "type": "registry:ui"
}