{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-picker-sundial",
  "title": "Time Picker Sundial",
  "description": "A time-of-day picker as a flat sundial: drag the outer ring to set the hour and the inner ring for 5-minute detents, while a soft blurred gnomon shadow points at the hour and lengthens from a short AM wedge into a long PM one as you keep dragging past noon.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/time-picker-sundial/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// GnomonSet — a time picker as a flat sundial. Two concentric draggable\n// rings (role=\"slider\" each): the outer sets the hour, the inner sets\n// minutes in 5-minute detents. A gnomon shadow (a soft blurred wedge)\n// points at the current hour; its LENGTH is what tells AM from PM (short =\n// high sun = AM, long = PM), not a separate control — dragging the hour\n// ring continuously past the 12 mark rolls the hour past 11 -> 12 and the\n// shadow lengthens, exactly like a real day turning into a real night.\n//\n// Dragging is the animation hot path: pointermove accumulates a float hour\n// (or minute) in a ref and writes the wedge/tick/readout DOM directly —\n// no React state per pointer event. The drag's FINAL value is the only\n// thing that ever reaches React state (on pointerup), so a keyboard arrow\n// press (which legitimately re-renders from state) can never fight a\n// stale mid-drag DOM write.\n// ---------------------------------------------------------------------------\n\nexport interface GnomonSetProps {\n  /** 0-23, controlled. */\n  hour?: number;\n  /** 0-59, controlled — only 5-minute values are reachable via the UI. */\n  minute?: number;\n  /** uncontrolled initial hour, 0-23 */\n  defaultHour?: number;\n  /** uncontrolled initial minute, 0-59 */\n  defaultMinute?: number;\n  /** called with the new hour and minute on any change */\n  onChange?: (hour: number, minute: number) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst VIEW = 160;\nconst C = 80;\nconst R_OUT_TICK_OUT = 66;\nconst R_OUT_TICK_IN = 59;\nconst R_NUMERAL = 48;\nconst R_OUT_HIT = 62;\nconst OUT_BAND = 18;\nconst R_IN_TICK_OUT = 36;\nconst R_IN_TICK_IN = 31;\nconst R_IN_HIT = 34;\nconst IN_BAND = 12;\nconst SHADOW_AM = 32;\nconst SHADOW_PM = 58;\nconst WEDGE_HALF_DEG = 6;\n\nfunction toRad(deg: number): number {\n  return (deg * Math.PI) / 180;\n}\n// Rounded to a fixed precision: server (Node) and client (browser) trig can\n// differ in the last bit or two, which is enough to fail hydration when the\n// raw float is serialized into the SSR-ed markup. A few decimals of pixel\n// precision is plenty for a 160-unit dial and makes both renders identical.\nfunction round(n: number): number {\n  return Math.round(n * 1000) / 1000;\n}\nfunction pointAt(angleDeg: number, r: number): [number, number] {\n  const rad = toRad(angleDeg);\n  return [round(C + r * Math.cos(rad)), round(C + r * Math.sin(rad))];\n}\nfunction hourAngle(hour24: number): number {\n  return -90 + (hour24 % 12) * 30;\n}\nfunction minuteAngle(minute: number): number {\n  return -90 + (minute / 5) * 30;\n}\nfunction shortestDelta(a: number, b: number): number {\n  return ((((a - b + 540) % 360) + 360) % 360) - 180;\n}\nfunction wrap(n: number, mod: number): number {\n  return ((n % mod) + mod) % mod;\n}\n\nfunction wedgePoints(angleDeg: number, length: number): string {\n  const [tx, ty] = pointAt(angleDeg, length);\n  const [lx, ly] = pointAt(angleDeg - WEDGE_HALF_DEG, length * 0.86);\n  const [rx, ry] = pointAt(angleDeg + WEDGE_HALF_DEG, length * 0.86);\n  return `${C},${C} ${lx},${ly} ${tx},${ty} ${rx},${ry}`;\n}\n\nfunction formatReadout(hour24: number, minute: number): string {\n  const h12 = hour24 % 12 === 0 ? 12 : hour24 % 12;\n  const mm = String(minute).padStart(2, \"0\");\n  const meridiem = hour24 < 12 ? \"AM\" : \"PM\";\n  return `${String(h12).padStart(2, \"0\")}:${mm} ${meridiem}`;\n}\n\nexport function GnomonSet({\n  hour,\n  minute,\n  defaultHour = 9,\n  defaultMinute = 0,\n  onChange,\n  className = \"\",\n}: GnomonSetProps) {\n  const autoId = useId();\n  const fid = `gs-blur-${autoId.replace(/:/g, \"\")}`;\n\n  const isControlled = hour !== undefined && minute !== undefined;\n  const [uHour, setUHour] = useState(defaultHour);\n  const [uMinute, setUMinute] = useState(defaultMinute);\n  const curHour = isControlled ? hour! : uHour;\n  const curMinute = isControlled ? minute! : uMinute;\n\n  const wedgeRef = useRef<SVGPolygonElement | null>(null);\n  const meridiemRef = useRef<SVGTextElement | null>(null);\n  const readoutRef = useRef<HTMLSpanElement | null>(null);\n  const hourHandleRef = useRef<SVGCircleElement | null>(null);\n  const minuteHandleRef = useRef<SVGCircleElement | null>(null);\n  const outerRingRef = useRef<SVGCircleElement | null>(null);\n  const innerRingRef = useRef<SVGCircleElement | null>(null);\n  const hoverTickRef = useRef<SVGLineElement | null>(null);\n  const svgRef = useRef<SVGSVGElement | null>(null);\n\n  const dragHourFloat = useRef(curHour);\n  const dragMinuteFloat = useRef(curMinute);\n  const prevAngleRef = useRef(0);\n  const draggingRef = useRef<\"hour\" | \"minute\" | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const paint = (h: number, m: number) => {\n    const hAngle = hourAngle(h);\n    const length = h < 12 ? SHADOW_AM : SHADOW_PM;\n    wedgeRef.current?.setAttribute(\"points\", wedgePoints(hAngle, length));\n    const [hx, hy] = pointAt(hAngle, R_OUT_HIT);\n    hourHandleRef.current?.setAttribute(\"cx\", String(hx));\n    hourHandleRef.current?.setAttribute(\"cy\", String(hy));\n    const mAngle = minuteAngle(m);\n    const [mx, my] = pointAt(mAngle, R_IN_HIT);\n    minuteHandleRef.current?.setAttribute(\"cx\", String(mx));\n    minuteHandleRef.current?.setAttribute(\"cy\", String(my));\n    if (meridiemRef.current) meridiemRef.current.textContent = h < 12 ? \"AM\" : \"PM\";\n    if (readoutRef.current) readoutRef.current.textContent = formatReadout(h, m);\n    outerRingRef.current?.setAttribute(\"aria-valuenow\", String(h));\n    outerRingRef.current?.setAttribute(\n      \"aria-valuetext\",\n      `${h % 12 === 0 ? 12 : h % 12} o'clock ${h < 12 ? \"AM\" : \"PM\"}`\n    );\n    innerRingRef.current?.setAttribute(\"aria-valuenow\", String(m));\n    innerRingRef.current?.setAttribute(\"aria-valuetext\", `${m} minutes`);\n  };\n\n  useEffect(() => paint(curHour, curMinute), [curHour, curMinute]);\n\n  const commit = (h: number, m: number) => {\n    if (!isControlled) {\n      setUHour(h);\n      setUMinute(m);\n    }\n    onChange?.(h, m);\n    setAnnounce(formatReadout(h, m));\n  };\n\n  // One getBoundingClientRect() per event, in SVG-local (viewBox) coordinates.\n  const toLocal = (clientX: number, clientY: number): { x: number; y: number } | null => {\n    const svg = svgRef.current;\n    if (!svg) return null;\n    const rect = svg.getBoundingClientRect();\n    const scale = VIEW / rect.width;\n    return { x: (clientX - rect.left) * scale, y: (clientY - rect.top) * scale };\n  };\n\n  const angleFromEvent = (e: PointerEvent | ReactPointerEvent) => {\n    const p = toLocal(e.clientX, e.clientY);\n    if (!p) return 0;\n    return (Math.atan2(p.y - C, p.x - C) * 180) / Math.PI;\n  };\n\n  const startDrag = (which: \"hour\" | \"minute\") => (e: ReactPointerEvent) => {\n    (e.target as Element).setPointerCapture(e.pointerId);\n    draggingRef.current = which;\n    dragHourFloat.current = curHour;\n    dragMinuteFloat.current = curMinute;\n    prevAngleRef.current = angleFromEvent(e);\n    e.preventDefault();\n  };\n\n  useEffect(() => {\n    const onMove = (e: PointerEvent) => {\n      const which = draggingRef.current;\n      if (!which) return;\n      const angle = angleFromEvent(e);\n      const delta = shortestDelta(angle, prevAngleRef.current);\n      prevAngleRef.current = angle;\n      if (which === \"hour\") {\n        dragHourFloat.current += delta / 30;\n        const h = wrap(Math.round(dragHourFloat.current), 24);\n        paint(h, Math.round(dragMinuteFloat.current / 5) * 5);\n      } else {\n        dragMinuteFloat.current += (delta / 30) * 5;\n        const m = wrap(Math.round(dragMinuteFloat.current / 5) * 5, 60);\n        paint(wrap(Math.round(dragHourFloat.current), 24), m);\n      }\n    };\n    const onUp = () => {\n      const which = draggingRef.current;\n      if (!which) return;\n      draggingRef.current = null;\n      const h = wrap(Math.round(dragHourFloat.current), 24);\n      const m = wrap(Math.round(dragMinuteFloat.current / 5) * 5, 60);\n      commit(h, m);\n    };\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerup\", onUp);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isControlled]);\n\n  const onHoverMove = (e: ReactPointerEvent) => {\n    if (e.pointerType === \"touch\") return;\n    const p = toLocal(e.clientX, e.clientY);\n    if (!p) return;\n    const angle = (Math.atan2(p.y - C, p.x - C) * 180) / Math.PI;\n    const dist = Math.hypot(p.x - C, p.y - C);\n    const [x1, y1] = pointAt(angle, dist - 4);\n    const [x2, y2] = pointAt(angle, dist + 6);\n    const tick = hoverTickRef.current;\n    if (tick) {\n      tick.setAttribute(\"x1\", String(x1));\n      tick.setAttribute(\"y1\", String(y1));\n      tick.setAttribute(\"x2\", String(x2));\n      tick.setAttribute(\"y2\", String(y2));\n      tick.style.opacity = \"1\";\n    }\n  };\n  const onHoverLeave = () => {\n    if (hoverTickRef.current) hoverTickRef.current.style.opacity = \"0\";\n  };\n\n  const stepHour = (delta: number) => {\n    const h = wrap(curHour + delta, 24);\n    commit(h, curMinute);\n  };\n  const stepMinute = (delta: number) => {\n    const m = wrap(curMinute + delta * 5, 60);\n    commit(curHour, m);\n  };\n\n  return (\n    <div className={`inline-flex flex-col items-center gap-4 ${className}`}>\n      <style>{CSS}</style>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announce}\n      </span>\n\n      <svg\n        ref={svgRef}\n        viewBox={`0 0 ${VIEW} ${VIEW}`}\n        width={VIEW}\n        height={VIEW}\n        className=\"ns-gs-dial\"\n        onPointerMove={onHoverMove}\n        onPointerLeave={onHoverLeave}\n      >\n        <defs>\n          <filter id={fid} x=\"-60%\" y=\"-60%\" width=\"220%\" height=\"220%\">\n            <feGaussianBlur stdDeviation=\"1.6\" />\n          </filter>\n        </defs>\n\n        <circle cx={C} cy={C} r={R_OUT_TICK_OUT} fill=\"none\" stroke=\"var(--border)\" strokeWidth={1} />\n        <circle cx={C} cy={C} r={R_IN_TICK_OUT} fill=\"none\" stroke=\"var(--border)\" strokeWidth={1} />\n\n        <g aria-hidden=\"true\">\n          {Array.from({ length: 12 }, (_, i) => {\n            const a = -90 + i * 30;\n            const [x1, y1] = pointAt(a, R_OUT_TICK_IN);\n            const [x2, y2] = pointAt(a, R_OUT_TICK_OUT);\n            const [nx, ny] = pointAt(a, R_NUMERAL);\n            return (\n              <g key={i}>\n                <line x1={x1} y1={y1} x2={x2} y2={y2} stroke=\"var(--ns-muted)\" strokeWidth={1} />\n                <text x={nx} y={ny + 3} textAnchor=\"middle\" className=\"font-mono\" style={{ fontSize: 8 }} fill=\"var(--ns-muted)\">\n                  {i === 0 ? 12 : i}\n                </text>\n              </g>\n            );\n          })}\n          {Array.from({ length: 12 }, (_, i) => {\n            const a = -90 + i * 30;\n            const [x1, y1] = pointAt(a, R_IN_TICK_IN);\n            const [x2, y2] = pointAt(a, R_IN_TICK_OUT);\n            return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke=\"var(--ns-muted)\" strokeWidth={0.75} />;\n          })}\n          <line ref={hoverTickRef} stroke=\"var(--foreground)\" strokeWidth={1} opacity={0} className=\"ns-gs-hovertick\" />\n        </g>\n\n        <polygon\n          ref={wedgeRef}\n          points={wedgePoints(hourAngle(curHour), curHour < 12 ? SHADOW_AM : SHADOW_PM)}\n          fill=\"var(--foreground)\"\n          opacity={0.3}\n          filter={`url(#${fid})`}\n          className=\"ns-gs-wedge\"\n          aria-hidden=\"true\"\n        />\n        <circle cx={C} cy={C} r={2.5} fill=\"var(--foreground)\" aria-hidden=\"true\" />\n        <text ref={meridiemRef} x={C} y={C + 16} textAnchor=\"middle\" className=\"font-mono\" style={{ fontSize: 7 }} fill=\"var(--foreground)\" aria-hidden=\"true\">\n          {curHour < 12 ? \"AM\" : \"PM\"}\n        </text>\n\n        <circle\n          ref={outerRingRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Hour\"\n          aria-valuemin={0}\n          aria-valuemax={23}\n          aria-valuenow={curHour}\n          aria-valuetext={`${curHour % 12 === 0 ? 12 : curHour % 12} o'clock ${curHour < 12 ? \"AM\" : \"PM\"}`}\n          cx={C}\n          cy={C}\n          r={R_OUT_HIT}\n          fill=\"none\"\n          stroke=\"transparent\"\n          strokeWidth={OUT_BAND}\n          pointerEvents=\"stroke\"\n          className=\"ns-gs-ring\"\n          onPointerDown={startDrag(\"hour\")}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") {\n              e.preventDefault();\n              stepHour(1);\n            } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") {\n              e.preventDefault();\n              stepHour(-1);\n            } else if (e.key === \"Home\") {\n              e.preventDefault();\n              commit(0, curMinute);\n            } else if (e.key === \"End\") {\n              e.preventDefault();\n              commit(12, curMinute);\n            }\n          }}\n        />\n        <circle\n          ref={innerRingRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Minute\"\n          aria-valuemin={0}\n          aria-valuemax={55}\n          aria-valuenow={curMinute}\n          aria-valuetext={`${curMinute} minutes`}\n          cx={C}\n          cy={C}\n          r={R_IN_HIT}\n          fill=\"none\"\n          stroke=\"transparent\"\n          strokeWidth={IN_BAND}\n          pointerEvents=\"stroke\"\n          className=\"ns-gs-ring\"\n          onPointerDown={startDrag(\"minute\")}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") {\n              e.preventDefault();\n              stepMinute(1);\n            } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") {\n              e.preventDefault();\n              stepMinute(-1);\n            } else if (e.key === \"Home\") {\n              e.preventDefault();\n              commit(curHour, 0);\n            } else if (e.key === \"End\") {\n              e.preventDefault();\n              commit(curHour, 55);\n            }\n          }}\n        />\n\n        <circle ref={hourHandleRef} cx={pointAt(hourAngle(curHour), R_OUT_HIT)[0]} cy={pointAt(hourAngle(curHour), R_OUT_HIT)[1]} r={3.5} fill=\"var(--foreground)\" aria-hidden=\"true\" />\n        <circle\n          ref={minuteHandleRef}\n          cx={pointAt(minuteAngle(curMinute), R_IN_HIT)[0]}\n          cy={pointAt(minuteAngle(curMinute), R_IN_HIT)[1]}\n          r={2.6}\n          fill=\"var(--background)\"\n          stroke=\"var(--foreground)\"\n          strokeWidth={1.4}\n          aria-hidden=\"true\"\n        />\n      </svg>\n\n      <span ref={readoutRef} className=\"font-mono text-3xl tabular-nums text-foreground\">\n        {formatReadout(curHour, curMinute)}\n      </span>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-gs-ring { cursor: pointer; outline: none; touch-action: none; }\n.ns-gs-ring:focus-visible { stroke: color-mix(in srgb, var(--ns-accent) 22%, transparent); }\n.ns-gs-wedge { transition: opacity 150ms ease-out; }\n.ns-gs-dial:hover .ns-gs-wedge { opacity: 0.42; }\n.ns-gs-hovertick { transition: opacity 150ms ease-out; }\n@media (prefers-reduced-motion: reduce) {\n  .ns-gs-wedge, .ns-gs-hovertick { transition: none; }\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/time-picker-sundial.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": [
      "time-picker",
      "slider",
      "dial",
      "svg",
      "drag",
      "keyboard-navigation",
      "accessibility",
      "sundial"
    ],
    "instruction": "Build a time-of-day picker shaped like a flat sundial: a 160x160 SVG dial with two concentric draggable rings. The OUTER ring (radius ~62, a wide invisible hit band using stroke=\"transparent\" strokeWidth=18 and pointer-events=\"stroke\" so only the ring band — not the interior — captures pointer events) sets the HOUR as an internal 0-23 value; 12 hairline tick marks plus Geist Mono numerals (12 at top, 1-11 clockwise) sit just inside it, matching standard clock-face placement (angle = -90 + hourOf12*30 degrees, hourOf12 = hour24 % 12, so 0/12/24-equivalent sits at the top). The INNER ring (radius ~34, hit band width 12) sets MINUTES in 5-minute detents via 12 finer tick marks at the same angular spacing, no numerals. A gnomon shadow — a soft, blurred (feGaussianBlur stdDeviation ~1.6) triangular wedge polygon from the dial center out to the current hour's angle, var(--foreground) at ~0.3 opacity — is the picker's headline feature: its length is SHORT (32 of the 80-unit radius) when hour24 < 12 (AM, 'high sun') and LONG (58) when hour24 >= 12 (PM), so continuing to drag the hour ring clockwise past the 12-o'clock mark doesn't just wrap the numeral back to 1 — it visibly lengthens the shadow, reading as morning turning into afternoon. A small Geist Mono 'AM'/'PM' caption sits just below the dial's center hub, updating live with the same condition. Both rings are role=\"slider\" (aria-valuemin/max/now/valuetext, e.g. \"3 o'clock PM\" / \"35 minutes\"), keyboard-operable with ArrowUp/ArrowRight to step +1 (hour or 5-minute unit) and ArrowLeft/ArrowDown to step -1, Home jumping to the reference minimum (hour ring: midnight, 00:00; minute ring: :00) and End jumping to the reference maximum (hour ring: noon, 12:00; minute ring: :55). Dragging is the animation hot path: pointerdown captures the pointer and starts accumulating a floating-point hour (or minute) value in a ref from the signed angular delta between successive pointermove events (delta/30 degrees per hour, or per 5 minutes for the inner ring) — NOT from an absolute snap-to-nearest-tick each event, which is what makes continuous multi-revolution dragging (and the AM/PM rollover) feel natural. Every pointermove writes the wedge polygon's `points`, both ring handles' cx/cy, the AM/PM caption's textContent, the big readout's textContent, and both rings' aria-valuenow/aria-valuetext directly via refs (setAttribute/textContent) — no React state during the drag. Only on pointerup does the final rounded value get committed through React state (or the controlled `onChange` callback) once. A separate hover affordance — independent of dragging, tracked via a lightweight pointermove-over-the-dial handler that also writes directly to a ref'd line element rather than React state — draws a faint short radial tick at the exact (unsnapped) angle under the pointer, and the shadow wedge's opacity rises slightly on hover via a plain CSS `:hover` rule (a legitimate CSS transition, not JS, since it's cosmetic and not per-drag-frame). The chosen time reads out large (text-3xl) in Geist Mono tabular-nums below the dial as 'HH:MM AM/PM'. A dedicated sr-only span (role=\"status\", aria-live=\"polite\", aria-atomic=\"true\") announces the same readout string whenever a value is committed (drag release or keyboard step) — not during the live drag itself, to avoid spamming. prefers-reduced-motion doesn't change the interaction model (there was never a continuous tween on hour/minute changes to begin with — the wedge always jumps directly to its new angle, whether via drag-follow or a keyboard step) but does drop the hover-opacity and hover-tick CSS transitions to instant. Props: hour/minute (controlled, 0-23 / 0-59), defaultHour/defaultMinute (uncontrolled default), onChange(hour, minute), className. Zero dependencies."
  },
  "type": "registry:ui"
}