{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timer",
  "title": "Timer",
  "description": "Timer is a production-ready interactive component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "lucide-react",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/misc/timer.tsx",
      "content": "\"use client\"\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\"\nimport { motion } from \"framer-motion\"\nimport { Pause, Play, RotateCcw } from \"lucide-react\"\nimport { BJORK_PALETTE } from \"@/components/bjork-ui/_core/palette\"\nimport { BJORK_SURFACE } from \"@/components/bjork-ui/_core/surface\"\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface TimerProps {\n  /** Countdown length in seconds. Default 25 minutes. */\n  duration?: number\n  /** Quick-set chips, in seconds. */\n  presets?: number[]\n  /** Accent for the progress arc, status and completion pulse. Defaults to the Bjork orange for the tone. */\n  accent?: string\n  /** Digit size. */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Show the hairline dial. When false the digits stand alone. */\n  showRing?: boolean\n  /** Fires once when the countdown reaches zero. */\n  onComplete?: () => void\n  /** Play a soft two-note chime on completion. Off by default. */\n  sound?: boolean\n  /** Force a tone. Follows the site theme when omitted. */\n  tone?: \"dark\" | \"light\"\n  /** Small label above the readout. */\n  title?: string\n  className?: string\n  /** @deprecated Kept for older call sites. Ignored. Colours now come from the tone. */\n  initialTime?: number\n  /** @deprecated Ignored. */\n  strokeWidth?: number\n  /** @deprecated Ignored. */\n  tickLength?: number\n  /** @deprecated Ignored. Use `accent`. */\n  tickColor?: string\n  /** @deprecated Ignored. */\n  textColor?: string\n  /** @deprecated Ignored. */\n  titleColor?: string\n  /** @deprecated Ignored. */\n  gradientFrom?: string\n  /** @deprecated Ignored. */\n  gradientVia?: string\n  /** @deprecated Ignored. */\n  gradientTo?: string\n  /** @deprecated Ignored. */\n  primaryButtonColor?: string\n  /** @deprecated Ignored. */\n  primaryButtonHover?: string\n  /** @deprecated Ignored. */\n  secondaryButtonColor?: string\n  /** @deprecated Ignored. */\n  secondaryButtonHover?: string\n}\n\ntype Status = \"idle\" | \"running\" | \"paused\" | \"done\"\n\nconst DEFAULT_DURATION = 25 * 60\nconst DEFAULT_PRESETS = [60, 5 * 60, 25 * 60]\nconst MIN_DURATION = 60\nconst MAX_DURATION = 99 * 60\n// The dial always spans at least an hour, one tick per minute.\nconst MIN_DIAL = 60 * 60\nconst DIGIT_CQW = { sm: 17, md: 21, lg: 25 } as const\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\"\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]\nconst DIGIT_NUDGE = 0.012\n\n// Dial geometry in viewBox units (300 x 300).\nconst C = 150\nconst R = 128\nconst PATH = 1000\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n))\nconst clampDuration = (seconds: number) =>\n  Math.min(MAX_DURATION, Math.max(MIN_DURATION, Math.round(seconds)))\nconst dialSpanFor = (duration: number) => Math.max(MIN_DIAL, Math.ceil(duration / 60) * 60)\nconst dashFor = (ms: number, span: number) => PATH * (1 - clamp01(ms / 1000 / span))\nconst sweepFor = (ms: number, span: number) => 360 * clamp01(ms / 1000 / span)\nconst pad = (n: number) => String(n).padStart(2, \"0\")\n\nfunction spoken(totalSeconds: number) {\n  const m = Math.floor(totalSeconds / 60)\n  const s = totalSeconds % 60\n  const parts: string[] = []\n  if (m) parts.push(`${m} minute${m === 1 ? \"\" : \"s\"}`)\n  if (s || !m) parts.push(`${s} second${s === 1 ? \"\" : \"s\"}`)\n  return parts.join(\" \")\n}\n\nfunction presetLabel(seconds: number) {\n  return seconds % 60 === 0 ? `${seconds / 60}m` : `${Math.floor(seconds / 60)}:${pad(seconds % 60)}`\n}\n\nfunction playChime(ctx: AudioContext) {\n  const t = ctx.currentTime\n  // Two soft sine partials a fifth apart, staggered, with a long fade.\n  ;[880, 1320].forEach((frequency, i) => {\n    const start = t + i * 0.18\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n    osc.type = \"sine\"\n    osc.frequency.value = frequency\n    gain.gain.setValueAtTime(0.0001, start)\n    gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02)\n    gain.gain.exponentialRampToValueAtTime(0.0001, start + 1.6)\n    osc.connect(gain).connect(ctx.destination)\n    osc.start(start)\n    osc.stop(start + 1.7)\n  })\n}\n\n// Reads the media query directly. Framer's hook can keep its first-render value, so the digit rolls would ignore the setting.\nfunction usePrefersReducedMotion() {\n  const [reduce, setReduce] = useState(false)\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const update = () => setReduce(mq.matches)\n    update()\n    mq.addEventListener(\"change\", update)\n    return () => mq.removeEventListener(\"change\", update)\n  }, [])\n  return reduce\n}\n\n// One digit as a vertical strip of 0-9. Only the digit that changes moves; the cell width is fixed, so nothing reflows.\nfunction RollDigit({ value, instant }: { value: number; instant: boolean }) {\n  return (\n    <span aria-hidden className=\"relative inline-block overflow-hidden\" style={{ width: \"0.62em\", height: \"1em\" }}>\n      <span\n        className=\"block will-change-transform\"\n        style={{\n          // Ink test (cap-centre of each digit, Bjork Display at line-height 1) averages ~0.012em high; nudge down.\n          transform: `translate3d(0, calc(${-value}em + ${DIGIT_NUDGE}em), 0)`,\n          transition: instant ? \"none\" : `transform 180ms ${EASE_OUT}`,\n          fontVariantNumeric: \"tabular-nums\",\n        }}\n      >\n        {DIGITS.map((d) => (\n          <span key={d} className=\"block text-center\" style={{ height: \"1em\", lineHeight: 1 }}>\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  )\n}\n\n// Two dots centred on the digit cap-height (0.5em), so the separator does not depend on the font's colon glyph.\nfunction Colon() {\n  return (\n    <span aria-hidden className=\"relative inline-block\" style={{ width: \"0.3em\", height: \"1em\", opacity: 0.5 }}>\n      {[-0.19, 0.19].map((offset) => (\n        <span\n          key={offset}\n          className=\"absolute rounded-full\"\n          style={{\n            left: \"50%\",\n            top: `calc(50% + ${offset}em)`,\n            width: \"0.1em\",\n            height: \"0.1em\",\n            background: \"currentColor\",\n            transform: \"translate(-50%, -50%)\",\n          }}\n        />\n      ))}\n    </span>\n  )\n}\n\nexport function Timer({\n  duration: durationProp = DEFAULT_DURATION,\n  presets = DEFAULT_PRESETS,\n  accent,\n  size = \"md\",\n  showRing = true,\n  onComplete,\n  sound = false,\n  tone,\n  title = \"Timer\",\n  className,\n}: TimerProps) {\n  const resolvedTone = useBjorkTone(tone)\n  const isDark = resolvedTone === \"dark\"\n  const palette = BJORK_PALETTE[resolvedTone]\n  const ink = palette.text\n  const muted = palette.textMuted\n  const soft = palette.textSoft\n  const accentColor = accent ?? palette.accent\n  const accentText = isDark ? accentColor : palette.accentInk\n  const surface = isDark ? \"#080808\" : palette.surface\n  const frameColor = palette.border\n  const hair = palette.hair\n  const instant = usePrefersReducedMotion()\n\n  const [duration, setDuration] = useState(() => clampDuration(durationProp))\n  const [status, setStatus] = useState<Status>(\"idle\")\n  const [remainingMs, setRemainingMs] = useState(() => clampDuration(durationProp) * 1000)\n  const [shownSec, setShownSec] = useState(() => clampDuration(durationProp))\n  const [dragging, setDragging] = useState(false)\n  const [pulse, setPulse] = useState(0)\n  const [announcement, setAnnouncement] = useState(\"\")\n\n  const running = status === \"running\"\n  const dialSpan = dialSpanFor(duration)\n\n  const arcRef = useRef<SVGCircleElement>(null)\n  const knobRef = useRef<SVGGElement>(null)\n  const svgRef = useRef<SVGSVGElement>(null)\n  const readoutRef = useRef<HTMLDivElement>(null)\n  const runRef = useRef<{ startedAt: number; base: number; span: number } | null>(null)\n  const lastSecRef = useRef(0)\n  const audioRef = useRef<AudioContext | null>(null)\n  const ringDragRef = useRef(false)\n  const lastDragRef = useRef(duration)\n  const dragStartRef = useRef<{ y: number; sec: number } | null>(null)\n  const wheelAccRef = useRef(0)\n  const onCompleteRef = useRef(onComplete)\n  const mountedRef = useRef(false)\n\n  useEffect(() => {\n    onCompleteRef.current = onComplete\n  }, [onComplete])\n\n  const announce = useCallback((text: string) => setAnnouncement(text), [])\n\n  // Every change to the set time goes through here. It is only reached while the timer is not counting down.\n  const setTotal = useCallback(\n    (seconds: number, message?: string) => {\n      const next = clampDuration(seconds)\n      runRef.current = null\n      lastDragRef.current = next\n      setDuration(next)\n      setStatus(\"idle\")\n      setRemainingMs(next * 1000)\n      setShownSec(next)\n      if (message) announce(message)\n    },\n    [announce]\n  )\n\n  // A prop change re-seeds the timer. The first mount is skipped so it does not clobber user edits.\n  useEffect(() => {\n    if (!mountedRef.current) {\n      mountedRef.current = true\n      return\n    }\n    setTotal(durationProp)\n  }, [durationProp, setTotal])\n\n  const complete = useCallback(() => {\n    runRef.current = null\n    setRemainingMs(0)\n    setShownSec(0)\n    setStatus(\"done\")\n    setPulse((n) => n + 1)\n    announce(\"Time is up\")\n    if (sound && audioRef.current) playChime(audioRef.current)\n    try {\n      navigator.vibrate?.([30, 80, 30])\n    } catch {\n      // Haptics are unsupported on this device.\n    }\n    onCompleteRef.current?.()\n  }, [announce, sound])\n\n  const start = () => {\n    const base = remainingMs > 0 ? remainingMs : duration * 1000\n    if (sound && typeof window !== \"undefined\") {\n      const Ctx =\n        window.AudioContext ??\n        (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext\n      if (Ctx) {\n        audioRef.current ??= new Ctx()\n        void audioRef.current.resume()\n      }\n    }\n    lastSecRef.current = Math.ceil(base / 1000)\n    runRef.current = { startedAt: performance.now(), base, span: dialSpan }\n    setRemainingMs(base)\n    setShownSec(Math.ceil(base / 1000))\n    setStatus(\"running\")\n    announce(`Started, ${spoken(Math.ceil(base / 1000))} left`)\n  }\n\n  const pause = () => {\n    const run = runRef.current\n    if (!run) return\n    const rem = Math.max(0, run.base - (performance.now() - run.startedAt))\n    runRef.current = null\n    setRemainingMs(rem)\n    setShownSec(Math.ceil(rem / 1000))\n    setStatus(\"paused\")\n    announce(`Paused, ${spoken(Math.ceil(rem / 1000))} left`)\n  }\n\n  const toggle = () => (running ? pause() : start())\n\n  const reset = () => {\n    runRef.current = null\n    setStatus(\"idle\")\n    setRemainingMs(duration * 1000)\n    setShownSec(duration)\n    announce(`Reset to ${spoken(duration)}`)\n  }\n\n  const nudge = (deltaSeconds: number) => {\n    if (running) return false\n    const next = clampDuration(duration + deltaSeconds)\n    setTotal(next, spoken(next))\n    return true\n  }\n\n  // Idle and paused: a layout effect writes the arc and knob, so the fill can ease with a CSS transition.\n  // Running: the frame loop below writes the same properties directly and transitions are off.\n  useLayoutEffect(() => {\n    if (status === \"running\") return\n    const arc = arcRef.current\n    const knob = knobRef.current\n    const frac = clamp01(remainingMs / 1000 / dialSpan)\n    if (arc) {\n      arc.style.transition = dragging ? \"none\" : `stroke-dashoffset 420ms ${EASE_OUT}`\n      arc.style.strokeDashoffset = String(PATH * (1 - frac))\n      arc.style.opacity = frac > 0 ? \"1\" : \"0\"\n    }\n    if (knob) {\n      knob.style.transition = dragging ? \"none\" : `transform 420ms ${EASE_OUT}`\n      knob.style.transform = `rotate(${frac * 360}deg)`\n      knob.style.opacity = frac > 0 ? \"1\" : \"0\"\n    }\n  }, [status, remainingMs, dialSpan, dragging])\n\n  // Count-down loop. Time comes from performance.now(), so it stays correct when the tab was hidden.\n  useEffect(() => {\n    if (status !== \"running\") return\n    const run = runRef.current\n    if (!run) return\n    const arc = arcRef.current\n    const knob = knobRef.current\n    if (arc) arc.style.transition = \"none\"\n    if (knob) knob.style.transition = \"none\"\n\n    let frame = 0\n    const loop = (now: number) => {\n      const rem = Math.max(0, run.base - (now - run.startedAt))\n      if (arc) arc.style.strokeDashoffset = String(dashFor(rem, run.span))\n      if (knob) knob.style.transform = `rotate(${sweepFor(rem, run.span)}deg)`\n\n      const sec = Math.ceil(rem / 1000)\n      if (sec !== lastSecRef.current) {\n        lastSecRef.current = sec\n        setShownSec(sec)\n        // Announce on whole minutes only, so screen readers are not flooded every second.\n        if (sec > 0 && sec % 60 === 0) {\n          announce(`${sec / 60} ${sec === 60 ? \"minute\" : \"minutes\"} remaining`)\n        }\n      }\n      if (rem <= 0) {\n        complete()\n        return\n      }\n      frame = requestAnimationFrame(loop)\n    }\n    frame = requestAnimationFrame(loop)\n    return () => cancelAnimationFrame(frame)\n  }, [status, complete, announce])\n\n  // Wheel over the readout steps by minutes. Non-passive so the page does not scroll under it.\n  useEffect(() => {\n    const el = readoutRef.current\n    if (!el) return\n    const onWheel = (event: WheelEvent) => {\n      if (status === \"running\") return\n      event.preventDefault()\n      wheelAccRef.current += event.deltaY\n      if (Math.abs(wheelAccRef.current) < 40) return\n      const step = wheelAccRef.current < 0 ? 60 : -60\n      wheelAccRef.current = 0\n      setTotal(duration + step)\n    }\n    el.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => el.removeEventListener(\"wheel\", onWheel)\n  }, [duration, status, setTotal])\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    // Native buttons already turn Space into a click, so only handle Space elsewhere.\n    const onButton = (event.target as HTMLElement).tagName === \"BUTTON\"\n    switch (event.key) {\n      case \" \":\n      case \"Spacebar\":\n        if (onButton) return\n        event.preventDefault()\n        toggle()\n        return\n      case \"r\":\n      case \"R\":\n        event.preventDefault()\n        reset()\n        return\n      case \"ArrowUp\":\n      case \"ArrowRight\":\n        if (nudge(event.shiftKey ? 300 : 60)) event.preventDefault()\n        return\n      case \"ArrowDown\":\n      case \"ArrowLeft\":\n        if (nudge(event.shiftKey ? -300 : -60)) event.preventDefault()\n        return\n      default:\n        return\n    }\n  }\n\n  const onReadoutDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (running) return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    dragStartRef.current = { y: event.clientY, sec: duration }\n  }\n  const onReadoutMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const start = dragStartRef.current\n    if (!start) return\n    // 14px of vertical travel per minute.\n    const steps = Math.round((start.y - event.clientY) / 14)\n    const next = clampDuration(start.sec + steps * 60)\n    if (next !== duration) setTotal(next)\n  }\n  const onReadoutUp = () => {\n    if (dragStartRef.current) announce(spoken(lastDragRef.current))\n    dragStartRef.current = null\n  }\n\n  const ringSeconds = (clientX: number, clientY: number) => {\n    const svg = svgRef.current\n    if (!svg) return duration\n    const rect = svg.getBoundingClientRect()\n    const x = clientX - (rect.left + rect.width / 2)\n    const y = clientY - (rect.top + rect.height / 2)\n    let angle = Math.atan2(x, -y) // 0 at 12 o'clock, clockwise\n    if (angle < 0) angle += Math.PI * 2\n    return Math.round((angle / (Math.PI * 2)) * dialSpan / 60) * 60\n  }\n  const onRingDown = (event: ReactPointerEvent<SVGSVGElement>) => {\n    if (running) return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    ringDragRef.current = true\n    setDragging(true)\n    setTotal(ringSeconds(event.clientX, event.clientY))\n  }\n  const onRingMove = (event: ReactPointerEvent<SVGSVGElement>) => {\n    if (!ringDragRef.current) return\n    setTotal(ringSeconds(event.clientX, event.clientY))\n  }\n  const onRingUp = () => {\n    if (!ringDragRef.current) return\n    ringDragRef.current = false\n    setDragging(false)\n    announce(spoken(lastDragRef.current))\n  }\n\n  const ticks = useMemo(() => {\n    const count = dialSpan / 60\n    return Array.from({ length: count }, (_, i) => ({\n      i,\n      major: i % 5 === 0,\n      angle: (i / count) * 360,\n    }))\n  }, [dialSpan])\n\n  const mm = Math.floor(shownSec / 60)\n  const ss = shownSec % 60\n  const statusLabel = { idle: \"Ready\", running: \"Running\", paused: \"Paused\", done: \"Complete\" }[status]\n  const statusColor = status === \"running\" || status === \"done\" ? accentText : soft\n  const primaryFg = isDark ? palette.bg : palette.surface\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={title}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"mx-auto flex w-full max-w-[360px] flex-col items-center gap-7 rounded-[24px] border px-6 py-7 sm:px-8\",\n        className\n      )}\n      style={{ background: surface, borderColor: frameColor, color: ink, boxShadow: BJORK_SURFACE[resolvedTone].shadowSurface }}\n    >\n      <div className=\"flex w-full items-center justify-between font-mono text-[11px] uppercase tracking-[0.14em]\">\n        <span style={{ color: muted }}>{title}</span>\n        <span className=\"flex items-center gap-2\" style={{ color: statusColor }}>\n          <span aria-hidden className=\"h-1.5 w-1.5 rounded-full\" style={{ background: statusColor }} />\n          {statusLabel}\n        </span>\n      </div>\n\n      <div\n        className={cn(\"relative w-full\", showRing ? \"aspect-square max-w-[300px]\" : \"h-[120px]\")}\n        style={{ containerType: \"inline-size\" }}\n      >\n        {showRing && (\n          <svg\n            ref={svgRef}\n            viewBox=\"0 0 300 300\"\n            aria-hidden\n            className=\"absolute inset-0 h-full w-full touch-none select-none\"\n            style={{ cursor: running ? \"default\" : \"pointer\" }}\n            onPointerDown={onRingDown}\n            onPointerMove={onRingMove}\n            onPointerUp={onRingUp}\n            onPointerCancel={onRingUp}\n          >\n            {ticks.map((t) => (\n              <line\n                key={t.i}\n                x1={C}\n                y1={C - 138}\n                x2={C}\n                y2={C - (t.major ? 146 : 142)}\n                stroke={t.major ? soft : hair}\n                strokeWidth={1}\n                transform={`rotate(${t.angle} ${C} ${C})`}\n              />\n            ))}\n            <circle cx={C} cy={C} r={R} fill=\"none\" stroke={hair} strokeWidth={1} />\n            <circle\n              ref={arcRef}\n              cx={C}\n              cy={C}\n              r={R}\n              fill=\"none\"\n              stroke={accentColor}\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              pathLength={PATH}\n              strokeDasharray={`${PATH} ${PATH}`}\n              transform={`rotate(-90 ${C} ${C})`}\n            />\n            <g ref={knobRef} style={{ transformOrigin: `${C}px ${C}px` }}>\n              <circle cx={C} cy={C - R} r={4.5} fill={accentColor} stroke={surface} strokeWidth={2} />\n            </g>\n          </svg>\n        )}\n\n        {showRing && pulse > 0 && !instant && (\n          <motion.span\n            key={pulse}\n            aria-hidden\n            className=\"pointer-events-none absolute inset-[4%] rounded-full\"\n            style={{ border: `1px solid ${accentColor}` }}\n            initial={{ opacity: 0.6, scale: 1 }}\n            animate={{ opacity: 0, scale: 1.12 }}\n            transition={{ duration: 1.1, ease: [0.23, 1, 0.32, 1] }}\n          />\n        )}\n\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div\n            ref={readoutRef}\n            role=\"timer\"\n            aria-label=\"Time remaining\"\n            tabIndex={0}\n            onPointerDown={onReadoutDown}\n            onPointerMove={onReadoutMove}\n            onPointerUp={onReadoutUp}\n            onPointerCancel={onReadoutUp}\n            className={cn(\n              \"pointer-events-auto touch-none select-none rounded-xl font-display leading-none outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[12px]\",\n              running ? \"cursor-default\" : \"cursor-ns-resize\"\n            )}\n            style={{\n              fontSize: `${DIGIT_CQW[size]}cqw`,\n              fontFamily:\n                '\"Bjork Grotesk Display\", \"Bjork Grotesk Alpha\", var(--font-geist-sans), sans-serif',\n              fontVariantNumeric: \"tabular-nums\",\n              letterSpacing: \"-0.02em\",\n              color: ink,\n              outlineColor: accentColor,\n            }}\n          >\n            <span className=\"flex items-center justify-center\">\n              <RollDigit value={Math.floor(mm / 10)} instant={instant} />\n              <RollDigit value={mm % 10} instant={instant} />\n              <Colon />\n              <RollDigit value={Math.floor(ss / 10)} instant={instant} />\n              <RollDigit value={ss % 10} instant={instant} />\n            </span>\n            <span className=\"sr-only\">{spoken(shownSec)}</span>\n          </div>\n        </div>\n      </div>\n\n      <div role=\"group\" aria-label=\"Quick durations\" className=\"flex items-center justify-center gap-2\">\n        {presets.map((seconds) => {\n          const active = duration === seconds\n          return (\n            <button\n              key={seconds}\n              type=\"button\"\n              aria-pressed={active}\n              disabled={running}\n              onClick={() => setTotal(seconds, `Set to ${spoken(seconds)}`)}\n              className=\"h-7 rounded-full border px-3 font-mono text-[11px] tabular-nums tracking-[0.04em] transition-[transform,color,background-color,border-color] duration-150 ease-out enabled:active:scale-[0.97] disabled:opacity-35\"\n              style={{\n                borderColor: active ? accentColor : hair,\n                color: active ? accentText : muted,\n                background: active ? (isDark ? \"rgba(236,92,19,0.12)\" : palette.accentSoft) : \"transparent\",\n              }}\n            >\n              {presetLabel(seconds)}\n            </button>\n          )\n        })}\n      </div>\n\n      <div className=\"flex items-center justify-center gap-5\">\n        <button\n          type=\"button\"\n          onClick={reset}\n          aria-label=\"Reset timer\"\n          className=\"flex h-12 w-12 items-center justify-center rounded-full border transition-transform duration-[120ms] ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-4\"\n          style={{ borderColor: hair, color: muted, outlineColor: accentColor }}\n        >\n          <RotateCcw className=\"h-[18px] w-[18px]\" strokeWidth={1.75} aria-hidden />\n        </button>\n\n        <button\n          type=\"button\"\n          onClick={toggle}\n          aria-label={running ? \"Pause timer\" : \"Start timer\"}\n          className=\"flex h-16 w-16 items-center justify-center rounded-full transition-transform duration-[120ms] ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-4\"\n          style={{ background: ink, color: primaryFg, outlineColor: accentColor }}\n        >\n          <motion.span\n            key={running ? \"pause\" : \"play\"}\n            initial={instant ? false : { opacity: 0, scale: 0.85 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ duration: instant ? 0 : 0.15, ease: [0.23, 1, 0.32, 1] }}\n            className=\"flex\"\n          >\n            {running ? (\n              <Pause className=\"h-[22px] w-[22px]\" fill=\"currentColor\" strokeWidth={0} aria-hidden />\n            ) : (\n              // Blur test: the play triangle's weight sits left of its box centre; nudge 1px right.\n              <Play className=\"h-[22px] w-[22px] translate-x-px\" fill=\"currentColor\" strokeWidth={0} aria-hidden />\n            )}\n          </motion.span>\n        </button>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/misc/timer.tsx"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/_core/surface.ts",
      "content": "import type { CSSProperties } from \"react\";\nimport type { BjorkTone } from \"./palette\";\n\n// The library surface (CANON.md §1). Values mirror app/globals.css :root and .dark. Change both together.\nexport const BJORK_SURFACE = {\n  light: {\n    surface: \"#fffcf6\", surfaceMuted: \"rgba(250, 246, 237, 0.94)\", surfaceHover: \"#f5efe3\", surfaceActive: \"#efe7d8\",\n    panel: \"#f8f2e7\", menu: \"rgba(255, 252, 246, 0.98)\", field: \"rgba(255, 252, 246, 0.96)\",\n    fieldMuted: \"rgba(248, 242, 231, 0.82)\", fieldInset: \"rgba(239, 231, 216, 0.72)\",\n    border: \"#eee6db\", borderMuted: \"#f5ede2\", borderStrong: \"#e1d7c8\",\n    shadowSurface: \"inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)\",\n    shadowSoft: \"inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)\",\n    shadowPanel: \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)\",\n    shadowMenu: \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)\",\n    shadowInset: \"inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)\",\n  },\n  dark: {\n    surface: \"#121212\", surfaceMuted: \"rgba(22, 22, 22, 0.92)\", surfaceHover: \"#161616\", surfaceActive: \"#202020\",\n    panel: \"#0d0d0d\", menu: \"rgba(18, 18, 18, 0.98)\", field: \"rgba(18, 18, 18, 0.95)\",\n    fieldMuted: \"rgba(18, 18, 18, 0.55)\", fieldInset: \"rgba(9, 9, 9, 0.75)\",\n    border: \"#232323\", borderMuted: \"#1c1c1c\", borderStrong: \"#343434\",\n    shadowSurface: \"inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)\",\n    shadowSoft: \"inset 0 1px 0 rgba(255, 255, 255, 0.045)\",\n    shadowPanel: \"inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)\",\n    shadowMenu: \"inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)\",\n    shadowInset: \"inset 0 1px 10px rgba(0, 0, 0, 0.45)\",\n  },\n} as const;\n\n/** Writes the surface tokens for a pinned tone as --bjork-* custom properties. Spread first; kit-specific keys may override. */\nexport function surfaceVars(tone: BjorkTone): CSSProperties {\n  const s = BJORK_SURFACE[tone];\n  return {\n    \"--bjork-surface\": s.surface, \"--bjork-surface-muted\": s.surfaceMuted, \"--bjork-surface-hover\": s.surfaceHover,\n    \"--bjork-surface-active\": s.surfaceActive, \"--bjork-panel\": s.panel, \"--bjork-menu\": s.menu, \"--bjork-field\": s.field,\n    \"--bjork-field-muted\": s.fieldMuted, \"--bjork-field-inset\": s.fieldInset, \"--bjork-border\": s.border,\n    \"--bjork-border-muted\": s.borderMuted, \"--bjork-border-strong\": s.borderStrong,\n    \"--bjork-shadow-surface\": s.shadowSurface, \"--bjork-shadow-soft\": s.shadowSoft, \"--bjork-shadow-panel\": s.shadowPanel,\n    \"--bjork-shadow-menu\": s.shadowMenu, \"--bjork-shadow-inset\": s.shadowInset,\n  } as CSSProperties;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/surface.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Interactive",
    "registryUrl": "https://ui.isaiahbjork.com/timer.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/timer.json"
  },
  "type": "registry:component"
}