{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-card",
  "title": "Time card",
  "description": "A punch clock's time card: each session is a row with its date, time in, time out and how long it ran, and the key stamps the time now onto the next line. For timesheets, focus sessions and on-call shifts.",
  "dependencies": [
    "@base-ui/react",
    "cn"
  ],
  "registryDependencies": [
    "https://bevel-ui.vercel.app/r/bevel.json",
    "https://bevel-ui.vercel.app/r/feedback.json"
  ],
  "files": [
    {
      "path": "registry/bevel/ui/time-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\"\nimport { cn } from \"cn\"\nimport { useBevelFeedback } from \"@/lib/feedback\"\n\nconst DAY = 86_400_000\n\n/** One session on the card. No `out` yet means the clock is still running. */\ntype TimeCardEntry = { in: Date | string; out?: Date | string | null }\ntype TimeCardPunchEvent = { kind: \"in\" | \"out\"; at: Date }\n\ntype Formatters = {\n  date: (d: Date) => string\n  time: (d: Date) => string\n  day: (d: Date) => string\n  duration: (ms: number) => string\n}\n\ntype TimeCardContextValue = {\n  open: boolean\n  last: { in: Date; out: Date | null } | undefined\n  total: number\n  format: Formatters | undefined\n  punch: () => void\n  statusId: string\n}\n\nconst TimeCardContext = React.createContext<TimeCardContextValue | null>(null)\n\nfunction useTimeCard() {\n  const context = React.useContext(TimeCardContext)\n  if (!context) throw new Error(\"TimeCard parts must be inside <TimeCard>\")\n  return context\n}\n\nconst toDate = (d: Date | string) => (typeof d === \"string\" ? new Date(d) : d)\n\nfunction formatters(locale: string | undefined, timeZone: string): Formatters {\n  const date = new Intl.DateTimeFormat(locale, { weekday: \"short\", day: \"numeric\", month: \"short\", timeZone })\n  const time = new Intl.DateTimeFormat(locale, { hour: \"2-digit\", minute: \"2-digit\", timeZone })\n  const day = new Intl.DateTimeFormat(\"en-CA\", { year: \"numeric\", month: \"2-digit\", day: \"2-digit\", timeZone })\n  const hours = new Intl.NumberFormat(locale, { style: \"unit\", unit: \"hour\", unitDisplay: \"narrow\" })\n  const minutes = new Intl.NumberFormat(locale, { style: \"unit\", unit: \"minute\", unitDisplay: \"narrow\" })\n  return {\n    date: (d) => date.format(d),\n    time: (d) => time.format(d),\n    day: (d) => day.format(d),\n    duration: (ms) => {\n      const total = Math.max(0, Math.round(ms / 60_000))\n      const h = Math.floor(total / 60)\n      const m = total % 60\n      return h ? (m ? `${hours.format(h)} ${minutes.format(m)}` : hours.format(h)) : minutes.format(m)\n    },\n  }\n}\n\ntype TimeCardProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** Whose card, and for when: printed at the top and used as the table's caption. */\n  title: string\n  /** Sessions, oldest first. */\n  entries: TimeCardEntry[]\n  /** The clock was punched: add a session (in) or close the open one (out). */\n  onPunch?: (event: TimeCardPunchEvent) => void\n  /** Ruled lines on the card; it grows past this. */\n  rows?: number\n  locale?: string\n  /** Pass one to print times on the server too; otherwise they print once the browser's zone is known. */\n  timeZone?: string\n}\n\n/**\n * A punched time card: each session is a row with its date, the time in,\n * the time out and how long it lasted. TimeCardPunch stamps the current\n * time on the next line.\n */\nfunction TimeCard({\n  className,\n  title,\n  entries,\n  onPunch,\n  rows = 6,\n  locale,\n  timeZone: timeZoneProp,\n  children,\n  ...props\n}: TimeCardProps) {\n  const statusId = React.useId()\n  const feedback = useBevelFeedback()\n  // The browser's zone is only known after mount; printing before then would\n  // disagree with the server's render.\n  const [clientZone, setClientZone] = React.useState<string>()\n  React.useEffect(() => {\n    if (!timeZoneProp) setClientZone(Intl.DateTimeFormat().resolvedOptions().timeZone)\n  }, [timeZoneProp])\n  const timeZone = timeZoneProp ?? clientZone\n  const format = React.useMemo(() => (timeZone ? formatters(locale, timeZone) : undefined), [locale, timeZone])\n\n  const sessions = entries.map((e) => ({ in: toDate(e.in), out: e.out ? toDate(e.out) : null }))\n  const last = sessions.at(-1)\n  const open = !!last && !last.out\n  const total = sessions.reduce((sum, s) => (s.out ? sum + (s.out.getTime() - s.in.getTime()) : sum), 0)\n\n  // Stamps already on the card when it appeared don't land again.\n  const [initial] = React.useState(() => new Set(sessions.flatMap((s, i) => (s.out ? [`${i}in`, `${i}out`] : [`${i}in`]))))\n\n  const punch = React.useCallback(() => {\n    feedback.thunk()\n    onPunch?.({ kind: open ? \"out\" : \"in\", at: new Date() })\n  }, [feedback, onPunch, open])\n\n  const stamp = (d: Date | null, key: string) =>\n    d ? (\n      <time\n        dateTime={d.toISOString()}\n        data-slot=\"time-card-stamp\"\n        data-fresh={initial.has(key) ? undefined : \"\"}\n        className={cn(\n          \"bevel-ink inline-block font-mono tabular-nums [--bevel-ink:var(--time-card-ink)]\",\n          \"data-fresh:transition-[scale,opacity] data-fresh:duration-(--bevel-duration-move) data-fresh:ease-(--bevel-ease-spring)\",\n          \"data-fresh:starting:scale-(--time-card-land-scale) data-fresh:starting:opacity-0 motion-reduce:transition-none\"\n        )}\n      >\n        {format ? format.time(d) : \"··:··\"}\n      </time>\n    ) : null\n\n  return (\n    <TimeCardContext value={{ open, last, total, format, punch, statusId }}>\n      <div\n        data-slot=\"time-card\"\n        data-open={open ? \"\" : undefined}\n        className={cn(\"flex w-full max-w-sm flex-col items-stretch gap-3\", className)}\n        {...props}\n      >\n        <div data-slot=\"time-card-card\" className=\"bevel-paper rounded-(--bevel-radius-xs) px-3 pt-3 pb-4\">\n          <p\n            aria-hidden\n            className=\"border-b-2 border-(--time-card-header) pb-1 text-center text-sm font-black tracking-(--bevel-tracking-label) text-(--time-card-header)\"\n          >\n            TIME CARD\n          </p>\n          <table className=\"w-full border-collapse text-sm\">\n            <caption className=\"py-1.5 text-left text-xs font-semibold\">{title}</caption>\n            <thead className=\"text-(length:--bevel-fine) tracking-(--bevel-tracking-print) text-(--bevel-paper-muted) uppercase\">\n              <tr className=\"border-y border-(--time-card-rule)\">\n                <th scope=\"col\" className=\"py-1 pr-2 text-left font-semibold\">Date</th>\n                <th scope=\"col\" className=\"border-l border-(--time-card-rule) px-2 py-1 text-left font-semibold\">In</th>\n                <th scope=\"col\" className=\"border-l border-(--time-card-rule) px-2 py-1 text-left font-semibold\">Out</th>\n                <th scope=\"col\" className=\"border-l border-(--time-card-rule) py-1 pl-2 text-right font-semibold\">Total</th>\n              </tr>\n            </thead>\n            <tbody>\n              {sessions.map((s, i) => {\n                const overnight = format && s.out ? Math.round((Date.parse(format.day(s.out)) - Date.parse(format.day(s.in))) / DAY) : 0\n                return (\n                  <tr key={`${s.in.getTime()}-${i}`} className=\"h-(--time-card-line) border-b border-(--time-card-rule)\">\n                    <th scope=\"row\" className=\"pr-2 text-left text-xs font-medium whitespace-nowrap\">\n                      {format ? format.date(s.in) : \"\"}\n                    </th>\n                    <td className=\"border-l border-(--time-card-rule) px-2\">{stamp(s.in, `${i}in`)}</td>\n                    <td className=\"border-l border-(--time-card-rule) px-2 whitespace-nowrap\">\n                      {stamp(s.out, `${i}out`)}\n                      {overnight > 0 && (\n                        <sup className=\"ml-0.5 text-(length:--bevel-fine) text-(--bevel-paper-muted)\">\n                          +{overnight}\n                          <span className=\"sr-only\"> day{overnight === 1 ? \"\" : \"s\"}</span>\n                        </sup>\n                      )}\n                      {!s.out && <span className=\"sr-only\">Still clocked in</span>}\n                    </td>\n                    <td className=\"border-l border-(--time-card-rule) pl-2 text-right font-mono text-xs whitespace-nowrap tabular-nums\">\n                      {s.out && format ? format.duration(s.out.getTime() - s.in.getTime()) : \"\"}\n                    </td>\n                  </tr>\n                )\n              })}\n              {Array.from({ length: Math.max(0, rows - sessions.length) }, (_, i) => (\n                <tr key={`empty-${i}`} aria-hidden className=\"h-(--time-card-line) border-b border-(--time-card-rule)\">\n                  <td />\n                  <td className=\"border-l border-(--time-card-rule)\" />\n                  <td className=\"border-l border-(--time-card-rule)\" />\n                  <td className=\"border-l border-(--time-card-rule)\" />\n                </tr>\n              ))}\n            </tbody>\n            <tfoot>\n              <tr>\n                <th scope=\"row\" colSpan={3} className=\"pt-2 text-right text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-print) uppercase\">\n                  Total\n                </th>\n                <td className=\"pt-2 pl-2 text-right font-mono text-sm font-bold whitespace-nowrap tabular-nums\">\n                  {format ? format.duration(total) : \"\"}\n                </td>\n              </tr>\n            </tfoot>\n          </table>\n        </div>\n        {children}\n      </div>\n    </TimeCardContext>\n  )\n}\n\n/** Clocked in or out, in words: “Clocked in at 09:02.” Describes the punch button. */\nfunction TimeCardStatus({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { open, last, total, format, statusId } = useTimeCard()\n  const text = !last\n    ? \"Not clocked in yet.\"\n    : open\n      ? `Clocked in${format ? ` at ${format.time(last.in)}` : \"\"}.`\n      : `Clocked out${format && last.out ? ` at ${format.time(last.out)}` : \"\"}. ${format ? format.duration(total) : \"\"} on this card.`\n  return (\n    <p\n      id={statusId}\n      role=\"status\"\n      data-slot=\"time-card-status\"\n      className={cn(\"flex items-center gap-2 text-sm\", className)}\n      {...props}\n    >\n      <span aria-hidden className=\"bevel-led size-2 shrink-0 rounded-full\" />\n      {text}\n    </p>\n  )\n}\n\n/** The punch clock's key. Stamps the time now: in if you're out, out if you're in. */\nfunction TimeCardPunch({ className, children, onClick, ...props }: ButtonPrimitive.Props) {\n  const { open, punch, statusId } = useTimeCard()\n  return (\n    <ButtonPrimitive\n      data-slot=\"time-card-punch\"\n      data-kind={open ? \"out\" : \"in\"}\n      aria-describedby={statusId}\n      onClick={(event) => {\n        onClick?.(event)\n        punch()\n      }}\n      className={cn(\n        \"bevel-raised flex h-12 items-center justify-center gap-2 rounded-(--bevel-radius-md) px-5 text-sm font-bold tracking-(--bevel-tracking-label) text-(--time-card-key-foreground) uppercase outline-none\",\n        \"transition-[translate,box-shadow] duration-(--bevel-duration-press) ease-(--bevel-ease-out) active:translate-y-1 active:bevel-pressed motion-reduce:transition-none\",\n        \"focus-visible:ring-3 focus-visible:ring-ring/60 data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? (open ? \"Punch out\" : \"Punch in\")}\n    </ButtonPrimitive>\n  )\n}\n\nexport { TimeCard, TimeCardStatus, TimeCardPunch }\nexport type { TimeCardProps, TimeCardEntry, TimeCardPunchEvent }\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    ":root": {
      "--time-card-paper": "oklch(0.91 0.045 85)",
      "--time-card-rule": "oklch(0.62 0.06 150 / 0.45)",
      "--time-card-header": "oklch(0.45 0.09 150)",
      "--time-card-ink": "oklch(0.42 0.14 300)",
      "--time-card-key": "oklch(0.3 0.01 260)",
      "--time-card-key-edge": "oklch(0.18 0.01 260)",
      "--time-card-key-foreground": "oklch(0.95 0.01 90)",
      "--time-card-in": "oklch(0.68 0.16 150)",
      "--time-card-out": "oklch(0.45 0.01 260)",
      "--time-card-line": "2rem",
      "--time-card-land-scale": "1.4"
    },
    ".dark": {
      "--time-card-paper": "oklch(0.84 0.04 85)"
    },
    "[data-slot=\"time-card-card\"]": {
      "--bevel-paper": "var(--time-card-paper)"
    },
    "[data-slot=\"time-card\"]": {
      "--bevel-led": "var(--time-card-out)"
    },
    "[data-slot=\"time-card\"][data-open]": {
      "--bevel-led": "var(--time-card-in)"
    },
    "[data-slot=\"time-card-punch\"]": {
      "--bevel-material": "var(--time-card-key)",
      "--bevel-material-edge": "var(--time-card-key-edge)"
    },
    "@media (forced-colors: active)": {
      "[data-slot=\"time-card-punch\"]": {
        "border": "1px solid ButtonText"
      }
    }
  },
  "categories": [
    "records"
  ],
  "type": "registry:ui"
}