{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timer",
  "title": "Timer",
  "description": "A countdown the person starts: the time set large inside a hairline ring that empties clockwise behind the accent dot, the figures turning over like a counter's wheels. Done, a full stop lands.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/button.json",
    "https://thedirectors.agency/ui/r/radial-chart.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/timer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useDigitRoll } from \"@/lib/0nlytype/use-digit-roll\"\nimport { Button } from \"@/components/ui/button\"\nimport { Ring } from \"@/components/ui/radial-chart\"\n\ntype TimerState = \"idle\" | \"running\" | \"paused\" | \"done\"\n\ntype TimerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** How long a session lasts, in seconds. Changing it resets the timer. */\n  duration?: number\n  /** Seconds already spent when it first shows, for a session picked up again. */\n  defaultElapsed?: number\n  /** What the time is for, e.g. \"Writing\". It also names the timer for assistive tech. */\n  label?: React.ReactNode\n  /** ring: a full ring round the time, emptying clockwise. horizon: a half ring on a horizon, the dot crossing it like the sun. */\n  variant?: \"ring\" | \"horizon\"\n  /** Called once when the time runs out. */\n  onComplete?: () => void\n  /** Pins a state for documentation: \"running\", \"paused\" or \"done\". It does not count. */\n  \"data-force\"?: string\n}\n\nconst still = () => typeof matchMedia !== \"undefined\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches\nconst pad = (n: number) => String(n).padStart(2, \"0\")\nconst clock = (s: number) => {\n  const h = Math.floor(s / 3600)\n  const m = Math.floor((s % 3600) / 60)\n  return h ? `${h}:${pad(m)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`\n}\nconst plural = (n: number, w: string) => `${n} ${w}${n === 1 ? \"\" : \"s\"}`\nconst spoken = (s: number) => {\n  const [h, m, sec] = [Math.floor(s / 3600), Math.floor((s % 3600) / 60), s % 60]\n  return [h && plural(h, \"hour\"), m && plural(m, \"minute\"), sec && plural(sec, \"second\")].filter(Boolean).join(\" \") || \"0 seconds\"\n}\n// Worth saying: every five minutes, each of the last five, and thirty seconds. Crossed, not hit, so a tab that slept still says it.\nconst mark = (m: number) => (m % 60 === 0 && (m % 300 === 0 || m <= 300)) || m === 30\nconst passed = (from: number, to: number) => {\n  for (let m = to; m < from; m++) if (mark(m)) return true\n  return false\n}\nconst at = new Intl.DateTimeFormat(undefined, { hour: \"numeric\", minute: \"2-digit\" })\n\n/** The figures turn over like a counter's wheels, only those that changed, the units first. */\nfunction Figures({ text }: { text: string }) {\n  const { shown: valueShown, figs } = useDigitRoll(text, { order: text, distance: \"0.4em\" })\n  const shown = String(valueShown)\n  return (\n    <span className=\"ot-timer-figures\" dir=\"ltr\" aria-hidden=\"true\">\n      {[...shown].map((f, k) => (\n        <span key={shown.length - k} ref={(el) => void (figs.current[k] = el)} data-colon={f === \":\" || undefined}>\n          {f}\n        </span>\n      ))}\n    </span>\n  )\n}\n\n/**\n * A countdown the person starts. The time is set large in tabular figures inside a hairline ring; the ring empties\n * clockwise from the top as the time goes, the accent dot leading it like a clock's hand. It only counts after\n * Start, follows the wall clock (so a hidden tab doesn't fall behind), and tells screen readers the time politely:\n * on each press, every five minutes, each of the last five, at thirty seconds, and when it's up.\n */\nfunction Timer({ duration = 1500, defaultElapsed = 0, label, variant = \"ring\", onComplete, className, style, ...props }: TimerProps) {\n  const total = Math.max(1, Math.round(duration))\n  const force = [\"running\", \"paused\", \"done\"].find((s) => props[\"data-force\"]?.split(\" \").includes(s)) as TimerState | undefined\n  const [left, setLeft] = React.useState(() => Math.min(total, Math.max(0, total - Math.round(defaultElapsed))))\n  const [state, setState] = React.useState<TimerState>(left === 0 ? \"done\" : left < total ? \"paused\" : \"idle\")\n  const [ends, setEnds] = React.useState<number | null>(null)\n  const [said, setSaid] = React.useState(\"\")\n  const ring = React.useRef<HTMLSpanElement>(null)\n  const done = React.useRef(onComplete)\n  const was = React.useRef(total)\n  React.useEffect(() => void (done.current = onComplete), [onComplete])\n\n  // A new length starts over.\n  React.useEffect(() => {\n    if (was.current === total) return\n    was.current = total\n    setLeft(total)\n    setState(\"idle\")\n    setEnds(null)\n  }, [total])\n\n  // Before paint, so the ring is never seen empty between a render and the first frame.\n  React.useLayoutEffect(() => {\n    if (state !== \"running\" || ends == null) return\n    let frame = 0\n    let prev = -1\n    const smooth = !still()\n    // The ring follows the clock every frame (under reduced motion, once a second); it is written here, not by React.\n    const draw = (p: number) => {\n      ring.current?.style.setProperty(\"--ot-ring-p\", String(p))\n      ring.current?.style.setProperty(\"--ot-ring-from\", String(1 - p))\n    }\n    const tick = () => {\n      const s = Math.max(0, Math.ceil((ends - Date.now()) / 1000))\n      if (s === prev) return\n      const was = prev\n      prev = s\n      setLeft(s)\n      if (!smooth) draw(s / total)\n      if (s === 0) {\n        setState(\"done\")\n        setEnds(null)\n        setSaid(\"Time is up.\")\n        done.current?.()\n      } else if (was > 0 && passed(was, s)) setSaid(`${spoken(s)} left.`) // the press already said the first\n    }\n    const run = () => {\n      draw(Math.max(0, ends - Date.now()) / 1000 / total)\n      frame = requestAnimationFrame(run)\n    }\n    const beat = setInterval(tick, 250)\n    document.addEventListener(\"visibilitychange\", tick)\n    tick()\n    if (smooth) run()\n    return () => {\n      clearInterval(beat)\n      cancelAnimationFrame(frame)\n      document.removeEventListener(\"visibilitychange\", tick)\n    }\n  }, [state, ends, total])\n\n  const shown = force === \"done\" ? 0 : left\n  const now = force ?? state\n  function go() {\n    if (state === \"running\") {\n      const s = ends == null ? left : Math.max(0, Math.ceil((ends - Date.now()) / 1000))\n      setLeft(s)\n      setEnds(null)\n      setState(\"paused\")\n      setSaid(`Paused, ${spoken(s)} left.`)\n      return\n    }\n    const s = state === \"done\" ? total : left\n    setLeft(s)\n    setEnds(Date.now() + s * 1000)\n    setState(\"running\")\n    setSaid(`${state === \"paused\" ? \"Resumed\" : \"Started\"}, ${spoken(s)} left.`)\n  }\n  function reset() {\n    setLeft(total)\n    setEnds(null)\n    setState(\"idle\")\n    setSaid(`Reset to ${spoken(total)}.`)\n  }\n\n  const p = shown / total\n  // Running, the effect draws the arc from the clock, so React lets go of it rather than set it back a second.\n  const drawn = state === \"running\" && !force\n  const note = now === \"running\" ? (ends ? `Ends ${at.format(ends)}` : \"Running\") : now === \"paused\" ? \"Paused\" : now === \"done\" ? \"Done\" : spoken(total)\n  const named = typeof label === \"string\" ? `${label} timer` : \"Timer\"\n  return (\n    <div\n      role=\"group\"\n      aria-label={named}\n      data-slot=\"timer\"\n      data-variant={variant}\n      data-state={now}\n      className={cn(\"ot-timer\", className)}\n      style={{ \"--ot-timer-chars\": clock(total).length, ...style } as React.CSSProperties}\n      {...props}\n    >\n      <div data-slot=\"timer-face\" className=\"ot-timer-face\" aria-hidden=\"true\">\n        <Ring ref={ring} value={p} from={1 - p} head=\"start\" style={drawn ? ({ \"--ot-ring-p\": undefined, \"--ot-ring-from\": undefined } as React.CSSProperties) : undefined} />\n      </div>\n      <div data-slot=\"timer-read\" className=\"ot-timer-read\">\n        <span role=\"timer\" className=\"ot-timer-time\">\n          <Figures text={clock(shown)} />\n          <span className=\"ot-sr\">{spoken(shown)}</span>\n        </span>\n        <span className=\"ot-timer-words\">\n          {label != null ? <span className=\"ot-timer-label\">{label}</span> : null}\n          <span className=\"ot-timer-note\">{note}</span>\n        </span>\n      </div>\n      <div data-slot=\"timer-actions\" className=\"ot-timer-actions\">\n        <Button variant=\"bracket\" onClick={go}>\n          {now === \"running\" ? \"Pause\" : now === \"paused\" ? \"Resume\" : now === \"done\" ? \"Start again\" : \"Start\"}\n        </Button>\n        <Button variant=\"bracket\" onClick={reset} disabled={now === \"idle\"}>\n          Reset\n        </Button>\n      </div>\n      <p className=\"ot-sr\" aria-live=\"polite\">\n        {said}\n      </p>\n    </div>\n  )\n}\n\nexport { Timer, type TimerProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/timer.css",
      "content": "@layer components {\n  /* ── ot-timer ── a countdown the person starts: the time set large in tabular figures inside the calendar's hairline\n     ring (ot-ring, from radial-chart). The ring empties clockwise from the top as the time goes, the accent dot at its\n     leading end like a clock's hand; the figures turn over like a counter's wheels. Done, a full stop lands. */\n  .ot-timer {\n    display: grid; grid-template-columns: minmax(0, 1fr); inline-size: min(100%, var(--ot-timer-size, 22rem)); justify-items: center; row-gap: var(--ot-space-6); container-type: inline-size;\n  }\n  .ot-timer-face { grid-area: 1 / 1; position: relative; inline-size: 100%; aspect-ratio: 1; }\n  .ot-timer .ot-ring { --ot-ring-head: var(--ot-accent); }\n  /* Running, the ring is drawn from the clock every frame, so it doesn't also ease; Reset still sweeps it back. */\n  .ot-timer[data-state=\"running\"] .ot-ring { transition: none; }\n\n  .ot-timer-read { grid-area: 1 / 1; place-self: center; display: grid; justify-items: center; row-gap: var(--ot-space-3); text-align: center; }\n  .ot-timer-time {\n    position: relative; font-size: clamp(var(--ot-mf), calc(118cqi / var(--ot-timer-chars, 5)), var(--ot-fff)); line-height: 0.9; letter-spacing: var(--ot-ff-tr); font-weight: 200;\n    color: var(--ot-ink); font-variant-numeric: tabular-nums lining-nums; white-space: nowrap;\n    transition: color var(--ot-moderato) var(--ot-exhale);\n  }\n  .ot-timer-figures { position: relative; display: inline-flex; }\n  .ot-timer-figures > span { display: inline-block; }\n  .ot-timer-figures > [data-colon] { translate: 0 -0.06em; padding-inline: 0.02em; }\n  /* The full stop is always there, unseen and out of the flow, so the time stays centred; done, it drops onto the line. */\n  .ot-timer-figures::after {\n    content: \".\"; position: absolute; left: 100%; bottom: 0; opacity: 0; translate: 0 -0.4em;\n    transition: opacity var(--ot-allegro) var(--ot-exhale), translate var(--ot-moderato) var(--ot-spiccato);\n  }\n  .ot-timer[data-state=\"done\"] .ot-timer-figures::after { opacity: 1; translate: 0 0; transition-delay: var(--ot-moderato); }\n  /* Paused, the time is held in pencil until it goes on. */\n  .ot-timer[data-state=\"paused\"] .ot-timer-time { color: var(--ot-pencil); }\n\n  .ot-timer-words { display: grid; justify-items: center; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); letter-spacing: var(--ot-pp-tr); }\n  .ot-timer-label { font-weight: 500; color: var(--ot-ink); }\n  .ot-timer-note { color: var(--ot-pencil); font-variant-numeric: tabular-nums; }\n\n  /* The two presses stand either side of the centre line, so when Start becomes Pause or Start again, only it moves. */\n  .ot-timer-actions { display: grid; grid-template-columns: 1fr 1fr; inline-size: max-content; column-gap: var(--ot-space-5); }\n  .ot-timer-actions > .ot-btn:first-child { justify-self: end; }\n  .ot-timer-actions > .ot-btn:last-child { justify-self: start; }\n\n  /* horizon: the Eclipse. A half ring stands on a horizon that runs past it; the dot crosses it like the sun, from the\n     start of the line to its end, and what's left of the arc is the day still to go. The time stands on the line and\n     its words sit under it. Right to left, the sun crosses the other way. */\n  .ot-timer[data-variant=\"horizon\"] { --ot-ring-sweep: 0.5; --ot-ring-start: -90deg; grid-template-rows: auto auto auto; row-gap: 0; padding-inline: var(--ot-space-5); inline-size: min(100%, calc(var(--ot-timer-size, 22rem) + 2 * var(--ot-space-5))); }\n  .ot-timer[data-variant=\"horizon\"] > .ot-timer-face { aspect-ratio: 2; }\n  .ot-timer[data-variant=\"horizon\"] .ot-ring { inset-block-end: auto; aspect-ratio: 1; }\n  .ot-timer[data-variant=\"horizon\"] > .ot-timer-face::after { content: \"\"; position: absolute; inset-inline: calc(-1 * var(--ot-space-5)); inset-block-end: 0; block-size: var(--ot-hairline); background: var(--ot-rule-strong); }\n  .ot-timer[data-variant=\"horizon\"]:is([dir=\"rtl\"], [dir=\"rtl\"] *) > .ot-timer-face { scale: -1 1; }\n  .ot-timer[data-variant=\"horizon\"] > .ot-timer-read { grid-row: 1 / span 2; place-self: stretch; grid-template-rows: subgrid; row-gap: 0; }\n  .ot-timer[data-variant=\"horizon\"] .ot-timer-time { align-self: end; font-size: clamp(var(--ot-mf), calc(96cqi / var(--ot-timer-chars, 5)), var(--ot-fff)); line-height: 1; text-box: trim-end alphabetic; }\n  .ot-timer[data-variant=\"horizon\"] .ot-timer-words { padding-block-start: var(--ot-space-3); }\n  .ot-timer[data-variant=\"horizon\"] > .ot-timer-actions { grid-row: 3; padding-block-start: var(--ot-space-6); }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/timer.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/timer.css\"": {}
  },
  "meta": {
    "movement": "VII",
    "underneath": "hook",
    "contract": "ot-timer"
  },
  "type": "registry:ui"
}
