{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ticket-number-ticker",
  "type": "registry:component",
  "title": "Ticket Number Ticker",
  "description": "An odometer that runs digits up on tabular-nums and middle-truncates long ids to start…end, with an optional GitHub PR status badge.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://lab.moumen.dev/r/lab-theme.json"
  ],
  "files": [
    {
      "path": "registry/lab/ticket-number-ticker/ticket-number-ticker.tsx",
      "type": "registry:component",
      "target": "@components/lab/ticket-number-ticker.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { motion, MotionConfig, useAnimationControls, useReducedMotion } from \"motion/react\";\n\n// Ticket number ticker - a ticket id shown as one celebratory value.\n//\n// The pill hugs its value and grows with it up to a max-width; a short #42 is a\n// small pill, a longer id fills out toward the cap. Past the cap it uses the\n// idiom every commit-SHA and wallet-address UI already uses: MIDDLE-TRUNCATE to\n// `start…end`, keeping the two ends that identify and disambiguate and dropping\n// the middle you can recover on hover (tooltip) or copy:\n//\n//   #100000000042            → #10000…0042\n//   #billing-webhook-...2026 → #billing-…-2026\n//\n// How much survives is measured against the CAP's budget (not the current hug\n// width) - a hidden clone is binary-searched to the last `#start…end` that fits.\n//\n// Numbers additionally get the odometer run-up: each surviving digit is a reel -\n// a 1em window over a 0-9 strip - snapped to 0 then released to `10 + digit` so\n// it scrolls a full turn and lands on target, cascading left→right. Bump\n// `runKey` to play it (e.g. when a new ticket arrives). tabular-nums keeps every\n// column exactly 1ch so nothing shifts as it rolls or truncates. Drop the digits\n// entirely and it's a plain name: no reels.\n//\n// Pass `status` to show a GitHub pull-request state badge (open/draft/merged/\n// closed, GitHub's own colours); pass `onStatusClick` to make it interactive.\n// `width=\"fixed\"` gives every pill one footprint for steady columns. As you\n// type, motion's `layout` glides the centred pill to its new spot instead of\n// snapping (position only - the width change stays instant so the new digit is\n// never clipped). Animation via motion/react; honours prefers-reduced-motion.\n// Requires the lab-theme tokens. Fully Tailwind, no CSS files.\n//\n// NOTE ON SIZE: the pill is deliberately compact (text-xl value, h-8 actions)\n// so it drops straight into dashboards, list rows and toolbars. The lab demo\n// page renders it inside a scale wrapper purely for presentation - what you\n// install is the dashboard size you see in your own app.\n\nconst MEASURE_SAFETY = 2; // px of slack so the value never kisses the clip edge\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\nconst EASE_ICON = [0.2, 0, 0, 1] as const;\n\nexport type PrStatus = \"open\" | \"draft\" | \"merged\" | \"closed\";\n\nexport interface TicketNumberState {\n  kind: \"numeric\" | \"text\";\n  truncated: boolean;\n  full: string;\n  status: PrStatus | null;\n}\n\n// GitHub's own state colours; the icon shape carries the meaning, the colour\n// reinforces it. The inset ring is the status colour at 22%.\nconst STATUS_STYLES: Record<PrStatus, { label: string; className: string; Icon: () => React.JSX.Element }> = {\n  open: {\n    label: \"Open\",\n    className: \"text-[#1a7f37] bg-[#dafbe1] shadow-[inset_0_0_0_1px_rgba(26,127,55,0.22)]\",\n    Icon: PrOpenIcon,\n  },\n  draft: {\n    label: \"Draft\",\n    className: \"text-[#57606a] bg-[#eaeef2] shadow-[inset_0_0_0_1px_rgba(87,96,106,0.22)]\",\n    Icon: PrOpenIcon,\n  },\n  merged: {\n    label: \"Merged\",\n    className: \"text-[#8250df] bg-[#f5edff] shadow-[inset_0_0_0_1px_rgba(130,80,223,0.22)]\",\n    Icon: PrMergedIcon,\n  },\n  closed: {\n    label: \"Closed\",\n    className: \"text-[#cf222e] bg-[#ffebe9] shadow-[inset_0_0_0_1px_rgba(207,34,46,0.22)]\",\n    Icon: PrClosedIcon,\n  },\n};\n\n// The contextual icon swap states (copy ⇄ check).\nconst ICON_SHOWN = { opacity: 1, scale: 1, filter: \"blur(0px)\" };\nconst ICON_HIDDEN = { opacity: 0, scale: 0.25, filter: \"blur(4px)\" };\n\n// The value's typography, shared verbatim by the visible value and the hidden\n// measuring clone so the fit is pixel-accurate. 1.25rem = text-xl - dashboard\n// scale, not display scale.\nconst VALUE_TYPE = \"text-xl font-semibold tracking-[-0.01em] tabular-nums whitespace-nowrap\";\n\nexport default function TicketNumber({\n  value = \"#0\",\n  runKey = 0,\n  status,\n  onStatusClick,\n  width = \"max\",\n  maxWidth = \"14rem\",\n  copyable = true,\n  inspect = false,\n  onStateChange,\n}: {\n  /** The ticket id - \"#1042\", \"1042\" or \"ticket name here\" (spaces become dashes). */\n  value?: string;\n  /** Increment to play the odometer run-up (numeric values only). */\n  runKey?: number;\n  /** Show a GitHub pull-request state badge. */\n  status?: PrStatus;\n  /** Makes the status badge a button (e.g. to cycle states in a demo). */\n  onStatusClick?: () => void;\n  /** \"max\" hugs the value up to the cap; \"fixed\" always takes the full cap width. */\n  width?: \"max\" | \"fixed\";\n  /** The cap the pill grows to before middle-truncating. */\n  maxWidth?: string;\n  copyable?: boolean;\n  inspect?: boolean;\n  onStateChange?: (state: TicketNumberState) => void;\n}) {\n  // Normalise: drop a leading \"#\", collapse whitespace to \"-\" (a ticket \"name\n  // here\" is really a slug), trim stray edge dashes. A pure-digit body is\n  // numeric (odometer); anything else - letters, dashes, mixed - is a text slug.\n  const body = String(value).trim().replace(/^#/, \"\").replace(/\\s+/g, \"-\").replace(/^-+|-+$/g, \"\");\n  const kind: \"numeric\" | \"text\" = /^\\d+$/.test(body) && body.length > 0 ? \"numeric\" : \"text\";\n  const fullId = `#${body}`;\n\n  const pillRef = useRef<HTMLDivElement>(null);\n  const actionsRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n\n  const [display, setDisplay] = useState({ head: body, tail: \"\", truncated: false });\n  const [copied, setCopied] = useState(false);\n\n  useEffect(() => {\n    onStateChange?.({ kind, truncated: display.truncated, full: fullId, status: status ?? null });\n  }, [kind, display.truncated, fullId, status, onStateChange]);\n\n  // The value's budget = the pill AT ITS CAP, minus chrome (padding + gap +\n  // actions). All of that is constant regardless of the current hug width, so we\n  // can compute the cap budget without ever forcing the pill wide.\n  function budgetPx() {\n    const pill = pillRef.current;\n    if (!pill) return 0;\n    const cs = getComputedStyle(pill);\n    const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);\n    const gap = parseFloat(cs.columnGap || cs.gap) || 0;\n    // offsetWidth (layout px) rather than getBoundingClientRect, so an ancestor\n    // CSS `scale` (e.g. a demo presentation wrapper) never skews the budget.\n    const actionsW = actionsRef.current ? actionsRef.current.offsetWidth : 0;\n    const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n    const capPx = (parseFloat(cs.getPropertyValue(\"--ticket-max\")) || 14) * rootPx;\n    let parentAvail = Infinity;\n    const parent = pill.parentElement;\n    if (parent) {\n      const pcs = getComputedStyle(parent);\n      parentAvail = parent.clientWidth - parseFloat(pcs.paddingLeft) - parseFloat(pcs.paddingRight);\n    }\n    return Math.min(parentAvail, capPx) - padX - gap - actionsW - MEASURE_SAFETY;\n  }\n\n  // Measure the longest `#start…end` that fits the cap budget and commit it.\n  // Runs before paint and on resize / font load - the clone it reads is never\n  // the animated value, so there is no measure→render→measure loop.\n  useLayoutEffect(() => {\n    const cloneEl = measureRef.current;\n    if (!pillRef.current || !cloneEl) return undefined;\n\n    const widthOf = (text: string) => {\n      cloneEl.textContent = text;\n      return cloneEl.scrollWidth;\n    };\n\n    const measure = () => {\n      const avail = budgetPx();\n      if (avail <= 0) return;\n\n      let next;\n      if (body.length <= 2 || widthOf(fullId) <= avail) {\n        next = { head: body, tail: \"\", truncated: false };\n      } else {\n        // Binary-search how many characters (split head-heavy) fit around a \"…\".\n        let lo = 2;\n        let hi = body.length - 1;\n        let keep = 2;\n        while (lo <= hi) {\n          const mid = (lo + hi) >> 1;\n          const head = Math.ceil(mid / 2);\n          const tail = mid - head;\n          if (widthOf(`#${body.slice(0, head)}…${body.slice(body.length - tail)}`) <= avail) {\n            keep = mid;\n            lo = mid + 1;\n          } else {\n            hi = mid - 1;\n          }\n        }\n        const head = Math.ceil(keep / 2);\n        const tail = keep - head;\n        next = { head: body.slice(0, head), tail: body.slice(body.length - tail), truncated: true };\n      }\n\n      setDisplay((prev) =>\n        prev.head === next.head && prev.tail === next.tail && prev.truncated === next.truncated ? prev : next,\n      );\n    };\n\n    measure();\n    // Observe the parent (the width source) so a viewport change re-truncates.\n    const target = pillRef.current.parentElement || pillRef.current;\n    const observer = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(measure) : null;\n    observer?.observe(target);\n    document.fonts?.ready?.then(measure).catch(() => {});\n    return () => observer?.disconnect();\n    // `status` matters: adding the badge grows the actions row and shrinks the\n    // value's budget - so the truncation must be re-measured, or the last chars clip.\n  }, [fullId, body, kind, status, copyable, maxWidth]);\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(fullId);\n      setCopied(true);\n      window.setTimeout(() => setCopied(false), 1200);\n    } catch {\n      /* clipboard blocked (insecure context / denied) - no-op */\n    }\n  }\n\n  // Reels are indexed continuously across head + tail so the run-up stagger\n  // cascades through the whole visible value, not restarting after the \"…\".\n  const headDigits = display.head.split(\"\");\n  const tailDigits = display.tail.split(\"\");\n  const statusStyle = status ? STATUS_STYLES[status] : null;\n\n  const ellipsisClass = `text-muted-foreground/70 px-[0.06em]${inspect ? \" outline outline-[1.5px] outline-dashed outline-[#f59e0b] outline-offset-1 rounded-[2px]\" : \"\"}`;\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      {/* layout=\"position\": as the hugging pill changes width it glides to its\n          new centred spot instead of snapping - position only, so the width\n          change stays instant and the just-typed digit is never clipped. */}\n      <motion.div\n        ref={pillRef}\n        layout=\"position\"\n        transition={{ duration: 0.22, ease: EASE }}\n        className={[\n          \"relative inline-flex items-center gap-2.5 max-w-[min(100%,var(--ticket-max))] py-2 pl-3.5 pr-2.5 bg-card rounded-xl text-card-foreground transition-shadow duration-200\",\n          width === \"fixed\" ? \"w-[min(100%,var(--ticket-max))]\" : \"w-fit\",\n          inspect\n            ? \"shadow-none outline outline-[1.5px] outline-dashed outline-[#3b82f6]\"\n            : \"shadow-border hover:shadow-border-hover\",\n        ].join(\" \")}\n        style={{ \"--ticket-max\": maxWidth } as CSSProperties}\n        data-kind={kind}\n      >\n        <div\n          className={[\n            \"relative flex items-center min-w-0\",\n            width === \"fixed\" ? \"flex-1\" : \"flex-[0_1_auto]\",\n            // The clip is only a safety net; clip-margin leaves slack so sub-pixel\n            // flex rounding never shaves the right edge of the last glyph.\n            inspect ? \"overflow-visible\" : \"[overflow:clip] [overflow-clip-margin:0.3em]\",\n          ].join(\" \")}\n        >\n          <span\n            className={`inline-flex items-baseline min-w-0 leading-[1.09] ${VALUE_TYPE}`}\n            title={fullId}\n            aria-hidden=\"true\"\n          >\n            <span className=\"mr-[0.06em] text-muted-foreground/70 font-medium\">#</span>\n            {kind === \"numeric\" ? (\n              <span className=\"inline-flex\">\n                {/* Key by index+digit: changing a digit (typing a new id) remounts\n                    that reel so it snaps to the value instantly, while a replay -\n                    same digits - keeps the element and rolls it via runKey. */}\n                {headDigits.map((digit, index) => (\n                  <Reel\n                    key={`h${index}-${digit}`}\n                    digit={Number(digit)}\n                    index={index}\n                    runKey={runKey}\n                    inspect={inspect && index === 0}\n                  />\n                ))}\n                {display.truncated && <span className={ellipsisClass}>…</span>}\n                {tailDigits.map((digit, index) => (\n                  <Reel\n                    key={`t${index}-${digit}`}\n                    digit={Number(digit)}\n                    index={headDigits.length + index}\n                    runKey={runKey}\n                    inspect={false}\n                  />\n                ))}\n              </span>\n            ) : (\n              // Keyed by its content so an edit remounts it and replays the soft-in.\n              <motion.span\n                className={`whitespace-nowrap${inspect ? \" outline outline-[1.5px] outline-dashed outline-[#ef4444]\" : \"\"}`}\n                key={`${display.head}|${display.tail}`}\n                initial={{ opacity: 0, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                transition={{ duration: 0.17, ease: EASE }}\n              >\n                {display.head}\n                {display.truncated && <span className={ellipsisClass}>…</span>}\n                {display.tail}\n              </motion.span>\n            )}\n          </span>\n\n          {/* Hidden clone JS drives to measure candidate widths - same typography\n              as the value, so the fit is pixel-accurate. */}\n          <span\n            className={`absolute top-0 left-0 invisible pointer-events-none ${VALUE_TYPE}`}\n            ref={measureRef}\n            aria-hidden=\"true\"\n          />\n\n          {/* Screen readers get the whole id as text; the visual is aria-hidden. */}\n          <span className=\"sr-only\">Ticket {fullId}</span>\n\n          {inspect && (\n            <>\n              <span className=\"absolute -top-[1.7rem] left-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#bfdbfe] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#2563eb] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none\">\n                {width === \"fixed\" ? \"width: fixed\" : display.truncated ? \"width: capped at max\" : \"width: fit-content\"}\n              </span>\n              <span className=\"absolute -bottom-[1.7rem] left-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#fecaca] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#dc2626] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none\">\n                {kind === \"numeric\" ? \"reel · 1ch × 1em\" : \"text · start … end\"}\n              </span>\n              {display.truncated && (\n                <span className=\"absolute -top-[1.7rem] right-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#fde68a] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#b45309] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none\">\n                  middle dropped →\n                </span>\n              )}\n            </>\n          )}\n        </div>\n\n        {(statusStyle || copyable) && (\n          <div className=\"flex-none inline-flex items-center gap-1\" ref={actionsRef}>\n            {statusStyle &&\n              (onStatusClick ? (\n                <button\n                  type=\"button\"\n                  className={`grid place-items-center w-8 h-8 rounded-lg transition-[color,background-color,box-shadow,scale,filter] hover:brightness-[0.97] active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${statusStyle.className}`}\n                  onClick={onStatusClick}\n                  aria-label={`Pull request status: ${statusStyle.label}. Click to change`}\n                  title={`Pull request · ${statusStyle.label}`}\n                >\n                  <StatusIcon status={status!} Icon={statusStyle.Icon} />\n                </button>\n              ) : (\n                <span\n                  className={`grid place-items-center w-8 h-8 rounded-lg transition-[color,background-color,box-shadow] ${statusStyle.className}`}\n                  role=\"img\"\n                  aria-label={`Pull request status: ${statusStyle.label}`}\n                  title={`Pull request · ${statusStyle.label}`}\n                >\n                  <StatusIcon status={status!} Icon={statusStyle.Icon} />\n                </span>\n              ))}\n            {copyable && (\n              <button\n                type=\"button\"\n                className=\"grid place-items-center w-8 h-8 rounded-lg text-muted-foreground transition-[scale,background-color,color] hover:bg-accent hover:text-foreground active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n                onClick={copy}\n                aria-label={copied ? \"Copied\" : `Copy ${fullId}`}\n              >\n                {/* Contextual icon swap: both icons stay in the DOM, stacked in\n                    one grid cell, cross-fading on opacity + scale + blur. */}\n                <motion.span\n                  className=\"[grid-area:1/1] inline-flex\"\n                  initial={false}\n                  animate={copied ? ICON_HIDDEN : ICON_SHOWN}\n                  transition={{ duration: 0.3, ease: EASE_ICON }}\n                >\n                  <CopyIcon />\n                </motion.span>\n                <motion.span\n                  className=\"[grid-area:1/1] inline-flex text-[#16a34a]\"\n                  initial={false}\n                  animate={copied ? ICON_SHOWN : ICON_HIDDEN}\n                  transition={{ duration: 0.3, ease: EASE_ICON }}\n                >\n                  <CheckIcon />\n                </motion.span>\n              </button>\n            )}\n          </div>\n        )}\n      </motion.div>\n    </MotionConfig>\n  );\n}\n\n// Keyed by status → remounts on each change → replays the pop-in.\nfunction StatusIcon({ status, Icon }: { status: PrStatus; Icon: () => React.JSX.Element }) {\n  return (\n    <motion.span\n      className=\"[grid-area:1/1] inline-flex\"\n      key={status}\n      initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n      transition={{ duration: 0.3, ease: EASE_ICON }}\n    >\n      <Icon />\n    </motion.span>\n  );\n}\n\n// One odometer column. Two 0-9 cycles stacked (20 figures); the strip rests on\n// `10 + digit`. A runKey bump snaps it to 0 (no animation) and releases it to\n// the target, so it scrolls a full turn before landing - the \"run up\". The\n// per-column delay cascades the settle left→right. Soft-in on remount: a\n// changed digit fades + unblurs in instead of hard-cutting.\nfunction Reel({ digit, index, runKey, inspect }: { digit: number; index: number; runKey: number; inspect: boolean }) {\n  const reduced = useReducedMotion();\n  const controls = useAnimationControls();\n  const target = `${-(10 + digit)}em`;\n\n  useEffect(() => {\n    if (runKey <= 0 || reduced) return;\n    controls.set({ y: \"0em\" });\n    controls.start({ y: target, transition: { duration: 0.64, ease: EASE, delay: index * 0.055 } });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [runKey]);\n\n  return (\n    <motion.span\n      className={`relative w-[1ch] h-[1em] overflow-hidden text-center${inspect ? \" outline outline-[1.5px] outline-dashed outline-[#ef4444]\" : \"\"}`}\n      aria-hidden=\"true\"\n      initial={{ opacity: 0, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, filter: \"blur(0px)\" }}\n      transition={{ duration: 0.17, ease: EASE }}\n    >\n      <motion.span className=\"flex flex-col\" style={{ y: target }} animate={controls}>\n        {REEL_FIGURES.map((n, i) => (\n          <span className=\"h-[1em] leading-[1em]\" key={i}>\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </motion.span>\n  );\n}\n\nconst REEL_FIGURES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\n// ── GitHub PR status icons ──────────────────────────────────────────────\n// Open + draft share GitHub's pull-request glyph; the badge colour tells them\n// apart (green vs grey), exactly as GitHub does.\nfunction GitBranch({ children }: { children: React.ReactNode }) {\n  return (\n    <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      {children}\n    </svg>\n  );\n}\n\nfunction PrOpenIcon() {\n  return (\n    <GitBranch>\n      <circle cx=\"18\" cy=\"18\" r=\"3\" />\n      <circle cx=\"6\" cy=\"6\" r=\"3\" />\n      <path d=\"M13 6h3a2 2 0 0 1 2 2v7\" />\n      <line x1=\"6\" y1=\"9\" x2=\"6\" y2=\"21\" />\n    </GitBranch>\n  );\n}\n\nfunction PrMergedIcon() {\n  return (\n    <GitBranch>\n      <circle cx=\"18\" cy=\"18\" r=\"3\" />\n      <circle cx=\"6\" cy=\"6\" r=\"3\" />\n      <path d=\"M6 21V9a9 9 0 0 0 9 9\" />\n    </GitBranch>\n  );\n}\n\nfunction PrClosedIcon() {\n  return (\n    <GitBranch>\n      <circle cx=\"6\" cy=\"6\" r=\"3\" />\n      <path d=\"M6 9v12\" />\n      <path d=\"m21 3-6 6\" />\n      <path d=\"m21 9-6-6\" />\n      <circle cx=\"18\" cy=\"18\" r=\"3\" />\n    </GitBranch>\n  );\n}\n\nfunction CopyIcon() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\" />\n      <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M5 13l4 4L19 7\" />\n    </svg>\n  );\n}\n"
    }
  ]
}
