{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-dial",
  "title": "Time Dial",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Mostrador de 24 horas que você gira com o dedo ou com as setas, com a faixa do expediente desenhada no aro.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/time-dial.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Time Dial — o relógio de 24 horas que se escolhe girando.\n *\n * Seletor de hora em lista suspensa responde \"que horas\", e só. Quando a\n * pergunta é \"que horas PRA TODO MUNDO\" (a reunião com o time em Lisboa, o\n * lançamento que cai de madrugada no cliente de Tóquio), a hora precisa de\n * lugar no dia: manhã, expediente, noite. O mostrador dá isso de graça, porque\n * o ângulo É o momento do dia.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * 1. **Meio-dia em cima, meia-noite embaixo.** É o caminho do sol: seis da\n *    manhã à esquerda, seis da tarde à direita. Um relógio de 12h com\n *    meio-dia no topo leria como ponteiro de parede e mentiria sobre o dia.\n * 2. **A faixa de expediente é desenhada no aro** (`band`), com a cor da marca\n *    como fundo translúcido. Ver se o ponteiro está dentro dela é a resposta\n *    que a peça existe pra dar; o número no centro só confirma.\n * 3. **O giro acumula.** O ângulo exibido é um contador sem teto: passar da\n *    meia-noite soma, não salta 360° para trás. Sem isso o ponteiro dá uma\n *    volta inteira ao contrário a cada virada de dia.\n * 4. **Ângulo não sofre com a escala da vitrine.** O `atan2` usa o centro medido\n *    na tela e o ponteiro, e a razão entre eles é a mesma com ou sem `scale`.\n * 5. **Teclado de slider de verdade:** o ponteiro é `role=\"slider\"` com valor em\n *    minutos e texto \"17:45\". Setas andam `step`, PageUp/PageDown uma hora,\n *    Home/End vão à meia-noite e ao último passo.\n * 6. **O valor sai pronto pra uso** (minutos desde 00:00); fuso e cidade são\n *    conta de quem consome, como a demo mostra. A peça não carrega tabela de\n *    fuso que envelhece.\n */\n\nexport interface TimeDialProps {\n  /** Minutos desde 00:00 (0 a 1439). Controlado. */\n  value?: number;\n  /** Valor inicial quando não controlado. */\n  defaultValue?: number;\n  onValueChange?: (minutes: number) => void;\n  /** Passo em minutos, no gesto e nas setas. */\n  step?: number;\n  /** Faixa destacada no aro, em minutos: `[540, 1080]` = 9h às 18h. */\n  band?: [number, number];\n  /** Linha pequena embaixo da hora: a cidade, o dia. */\n  caption?: React.ReactNode;\n  /** Nome acessível do ponteiro. */\n  label?: string;\n  /** Diâmetro em px. */\n  size?: number;\n  className?: string;\n}\n\nconst DIA = 1440;\n\nconst norm = (m: number) => ((m % DIA) + DIA) % DIA;\n/** Minutos → graus a partir do topo, sentido horário (meio-dia = 0°). */\nconst grau = (m: number) => ((m - 720) / DIA) * 360;\n/** Minutos desde 00:00 em \"17:45\". */\nexport const formatDialTime = (m: number) => {\n  const v = norm(Math.round(m));\n  return `${String(Math.floor(v / 60)).padStart(2, \"0\")}:${String(v % 60).padStart(2, \"0\")}`;\n};\n\nconst MARCAS = [\n  { m: 720, t: \"12h\" },\n  { m: 1080, t: \"18h\" },\n  { m: 0, t: \"0h\" },\n  { m: 360, t: \"6h\" },\n];\n\nexport function TimeDial({\n  value,\n  defaultValue = 540,\n  onValueChange,\n  step = 15,\n  band,\n  caption,\n  label = \"Hora\",\n  size = 240,\n  className,\n}: TimeDialProps) {\n  const reduzir = useReducedMotion();\n  const [interno, setInterno] = React.useState(() => norm(defaultValue));\n  const atual = value !== undefined ? norm(value) : interno;\n  const atualRef = React.useRef(atual);\n  atualRef.current = atual;\n\n  const caixaRef = React.useRef<HTMLDivElement>(null);\n  const arrastando = React.useRef(false);\n  // Rotação exibida, acumulada (ver decisão 3).\n  const giro = useMotionValue(grau(atual));\n  const giroAlvo = React.useRef(grau(atual));\n\n  // Segue o valor com o menor arco a partir de onde o ponteiro está.\n  React.useEffect(() => {\n    const alvo = grau(atual);\n    const delta = ((((alvo - giroAlvo.current) % 360) + 540) % 360) - 180;\n    giroAlvo.current += delta;\n    if (arrastando.current || reduzir) giro.set(giroAlvo.current);\n    else animate(giro, giroAlvo.current, { type: \"spring\", visualDuration: 0.25, bounce: 0.1 });\n  }, [atual, giro, reduzir]);\n\n  const definir = (m: number) => {\n    const next = norm(Math.round(m / step) * step);\n    if (next === atualRef.current) return;\n    if (value === undefined) setInterno(next);\n    onValueChange?.(next);\n  };\n\n  const doPonteiro = (cx: number, cy: number) => {\n    const el = caixaRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const a = (Math.atan2(cx - (r.left + r.width / 2), -(cy - (r.top + r.height / 2))) * 180) / Math.PI;\n    definir(720 + (a / 360) * DIA);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const m = atualRef.current;\n    const mapa: Record<string, number> = {\n      ArrowRight: m + step,\n      ArrowUp: m + step,\n      ArrowLeft: m - step,\n      ArrowDown: m - step,\n      PageUp: m + 60,\n      PageDown: m - 60,\n      Home: 0,\n      End: DIA - step,\n    };\n    if (e.key in mapa) {\n      e.preventDefault();\n      definir(mapa[e.key]);\n    }\n  };\n\n  // Faixa no aro: um conic-gradient recortado por máscara radial. Sem SVG.\n  const faixa = band\n    ? (() => {\n        const ini = grau(band[0]);\n        const vao = ((((band[1] - band[0]) % DIA) + DIA) % DIA) * (360 / DIA);\n        return `conic-gradient(from ${ini}deg, color-mix(in oklab, var(--brand) 28%, transparent) 0deg ${vao}deg, transparent ${vao}deg)`;\n      })()\n    : undefined;\n\n  const R = size / 2;\n\n  return (\n    <div\n      ref={caixaRef}\n      className={cn(\"relative shrink-0 touch-none select-none rounded-full\", className)}\n      style={{ width: size, height: size }}\n      onPointerDown={(e) => {\n        if (e.button !== 0) return;\n        e.currentTarget.setPointerCapture(e.pointerId);\n        arrastando.current = true;\n        doPonteiro(e.clientX, e.clientY);\n      }}\n      onPointerMove={(e) => arrastando.current && doPonteiro(e.clientX, e.clientY)}\n      onPointerUp={() => (arrastando.current = false)}\n      onPointerCancel={() => (arrastando.current = false)}\n    >\n      {/* Disco */}\n      <div className=\"absolute inset-0 rounded-full border border-border bg-card [box-shadow:var(--e-raised)]\" />\n      {faixa && (\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute inset-[6px] rounded-full\"\n          style={{\n            background: faixa,\n            mask: \"radial-gradient(circle, transparent 58%, black 58.5%)\",\n            WebkitMask: \"radial-gradient(circle, transparent 58%, black 58.5%)\",\n          }}\n        />\n      )}\n\n      {/* 96 marcas de 15 min; a hora cheia é mais longa, a cada 6h mais ainda. */}\n      {Array.from({ length: 96 }, (_, i) => {\n        const hora = i % 4 === 0;\n        const seis = i % 24 === 0;\n        return (\n          <span\n            key={i}\n            aria-hidden=\"true\"\n            className={cn(\"absolute left-1/2 top-0 w-px origin-bottom\", hora ? \"bg-muted-foreground\" : \"bg-border\")}\n            style={{\n              height: R,\n              transform: `translateX(-50%) rotate(${i * 3.75}deg)`,\n              clipPath: `inset(4px 0 ${R - (seis ? 16 : hora ? 11 : 8)}px 0)`,\n            }}\n          />\n        );\n      })}\n      {MARCAS.map(({ m, t }) => {\n        const a = (grau(m) * Math.PI) / 180;\n        // Por dentro da faixa do aro: rótulo em cima do arco não se lê.\n        const d = R - 40;\n        return (\n          <span\n            key={t}\n            aria-hidden=\"true\"\n            className=\"absolute -translate-x-1/2 -translate-y-1/2 text-[10px] tabular-nums text-muted-foreground\"\n            style={{ left: R + Math.sin(a) * d, top: R - Math.cos(a) * d }}\n          >\n            {t}\n          </span>\n        );\n      })}\n\n      {/* Centro */}\n      <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-0.5\">\n        <span className=\"text-[34px] font-medium leading-none tabular-nums text-foreground\">{formatDialTime(atual)}</span>\n        {caption && <span className=\"text-xs text-muted-foreground\">{caption}</span>}\n      </div>\n\n      {/* Ponteiro: a haste e a pega giram juntas. */}\n      <motion.div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={{ rotate: giro }}>\n        <span className=\"absolute left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-foreground\" style={{ top: 22, height: Math.max(0, R - 80) }} />\n      </motion.div>\n      <motion.div className=\"pointer-events-none absolute inset-0\" style={{ rotate: giro }}>\n        <div\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={label}\n          aria-valuemin={0}\n          aria-valuemax={DIA - step}\n          aria-valuenow={atual}\n          aria-valuetext={formatDialTime(atual)}\n          onKeyDown={onKeyDown}\n          className={cn(\n            \"pointer-events-auto absolute left-1/2 top-[6px] size-8 -translate-x-1/2 cursor-grab rounded-full border-2 border-background bg-[var(--brand)] [box-shadow:var(--e-raised)] active:cursor-grabbing\",\n            \"focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\",\n          )}\n        />\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Escolher hora num mostrador de 24h com o expediente marcado, em agenda ou conversão de fuso",
      "evite": "Escolher data: date-reel; mostrar se a loja está aberta agora, store-status"
    }
  },
  "categories": [
    "controles",
    "hora",
    "relogio",
    "fuso",
    "seletor",
    "agenda"
  ],
  "type": "registry:ui"
}