{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "badge-unread-tarnish",
  "title": "Badge Unread Tarnish",
  "description": "Unread badge that tarnishes like brass: solid when fresh, an outline within a day, a muted ring after a week — new activity instantly re-polishes it with a small flare.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/badge-unread-tarnish/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// PatinaPip — an unread badge that ages like brass instead of just counting.\n// Its data-stage is derived from the newest item's timestamp, not the count:\n// fresh (< 1 day) is a solid --foreground fill with background-token digits,\n// waning (< 1 week) thins to a transparent fill with a 1.5px --foreground\n// ring, dormant (>= 1 week) settles into a 1px --ns-muted ring with --ns-muted\n// digits — fill, outline weight and text tone all move together so the three\n// stages read apart under monochrome viewing, never by hue alone. Ordinary\n// aging (the interval tick, or a prop simply reflecting more elapsed time)\n// crossfades those properties over 400ms via a plain CSS transition; new\n// activity is different in kind, not degree, so it's fast-pathed around that\n// transition (a one-frame transitionDuration:0 clamp) straight to \"fresh\"\n// and topped with a 200ms spring-eased scale(1 → 1.12 → 1) flare via the Web\n// Animations API, so re-polish always reads as instantaneous, not eased.\n// The pip itself is decorative (role=\"img\", not a control) — the accessible\n// name lives on it as an aria-label (\"3 unread, newest 2 days ago\") for a\n// focusable nav item to pull in via aria-describedby, and a visually-hidden\n// aria-live region separately announces new arrivals as they happen. Pure\n// DOM + CSS, zero dependencies, no canvas.\n// ---------------------------------------------------------------------------\n\nexport type PatinaPipStage = \"fresh\" | \"waning\" | \"dormant\";\n\nexport interface PatinaPipProps {\n  /** unread item count. Incidental to the component's point — see newestTimestamp. */\n  count: number;\n  /** timestamp of the newest unread item; the sole driver of the tarnish stage. */\n  newestTimestamp: number | Date;\n  /** id placed on the pip itself, e.g. so a nav item can aria-describedby it. Defaults to an internal useId. */\n  id?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst MINUTE_MS = 60_000;\nconst HOUR_MS = 60 * MINUTE_MS;\nconst DAY_MS = 24 * HOUR_MS;\nconst WEEK_MS = 7 * DAY_MS;\n\n// how often the pip re-checks its own age while mounted, so a badge left\n// open on screen tarnishes on its own without any new prop ever arriving.\nconst TICK_MS = MINUTE_MS;\n\nconst FLARE_KEYFRAMES: Keyframe[] = [\n  { transform: \"scale(1)\" },\n  { transform: \"scale(1.12)\", offset: 0.5 },\n  { transform: \"scale(1)\" },\n];\nconst FLARE_OPTIONS: KeyframeAnimationOptions = {\n  duration: 200,\n  easing: \"cubic-bezier(0.34, 1.56, 0.64, 1)\", // spring-like overshoot, not linear/ease\n};\n\nfunction toMs(ts: number | Date): number {\n  return ts instanceof Date ? ts.getTime() : ts;\n}\n\nfunction computeStage(ageMs: number): PatinaPipStage {\n  if (ageMs < DAY_MS) return \"fresh\";\n  if (ageMs < WEEK_MS) return \"waning\";\n  return \"dormant\";\n}\n\nfunction formatAge(ageMs: number): string {\n  const ms = Math.max(0, ageMs);\n  if (ms < MINUTE_MS) return \"just now\";\n  if (ms < HOUR_MS) {\n    const m = Math.round(ms / MINUTE_MS);\n    return `${m} minute${m === 1 ? \"\" : \"s\"} ago`;\n  }\n  if (ms < DAY_MS) {\n    const h = Math.round(ms / HOUR_MS);\n    return `${h} hour${h === 1 ? \"\" : \"s\"} ago`;\n  }\n  if (ms < WEEK_MS) {\n    const d = Math.round(ms / DAY_MS);\n    return `${d} day${d === 1 ? \"\" : \"s\"} ago`;\n  }\n  const w = Math.round(ms / WEEK_MS);\n  return `${w} week${w === 1 ? \"\" : \"s\"} ago`;\n}\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nconst STAGE_CLASSES: Record<PatinaPipStage, string> = {\n  fresh: \"bg-foreground text-background border border-foreground font-semibold\",\n  waning: \"bg-transparent text-foreground border-[1.5px] border-foreground font-medium\",\n  dormant: \"bg-transparent text-ns-muted border border-ns-muted font-normal\",\n};\n\nconst BASE_CLASSES =\n  \"ns-badge-unread-tarnish inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 font-mono text-[11px] leading-none tabular-nums select-none transition-[background-color,border-color,color,border-width] duration-[400ms] ease-out\";\n\nexport function PatinaPip({ count, newestTimestamp, id, className }: PatinaPipProps) {\n  const autoId = useId();\n  const pipId = id ?? autoId;\n  const nodeRef = useRef<HTMLSpanElement>(null);\n  const prevTsRef = useRef(toMs(newestTimestamp));\n  const mountedRef = useRef(false);\n  const reducedMotion = useReducedMotion();\n\n  // SSR-safe placeholder: never reads Date.now() during render (server and\n  // client's pre-effect render must match exactly), corrected to the real\n  // elapsed age the moment effects can run on the client.\n  const [ageMs, setAgeMs] = useState(0);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  useEffect(() => {\n    const ts = toMs(newestTimestamp);\n\n    if (!mountedRef.current) {\n      // first client-side pass after hydration: adopt the real elapsed time\n      // silently, no crossfade suppression and no flare — this is settling\n      // in, not new activity.\n      mountedRef.current = true;\n      prevTsRef.current = ts;\n      setAgeMs(Math.max(0, Date.now() - ts));\n      return;\n    }\n\n    const prev = prevTsRef.current;\n    prevTsRef.current = ts;\n    const isNewer = ts > prev;\n    const nextAge = Math.max(0, Date.now() - ts);\n\n    if (isNewer) {\n      const node = nodeRef.current;\n      // clamp the transition to instant for exactly this update, so the\n      // reset to \"fresh\" reads as a snap, not a 400ms crossfade — then\n      // restore normal crossfade timing for whatever ages naturally next.\n      if (node) node.style.transitionDuration = \"0s\";\n      setAgeMs(nextAge);\n      setAnnouncement(`New unread activity, ${count} unread`);\n      requestAnimationFrame(() => {\n        if (node) node.style.transitionDuration = \"\";\n        if (node && !reducedMotion) node.animate(FLARE_KEYFRAMES, FLARE_OPTIONS);\n      });\n    } else {\n      setAgeMs(nextAge);\n    }\n  }, [newestTimestamp, count, reducedMotion]);\n\n  useEffect(() => {\n    const tick = window.setInterval(() => {\n      setAgeMs(Math.max(0, Date.now() - prevTsRef.current));\n    }, TICK_MS);\n    return () => window.clearInterval(tick);\n  }, []);\n\n  if (count <= 0) return null;\n\n  const stage = computeStage(ageMs);\n  const label = `${count} unread, newest ${formatAge(ageMs)}`;\n\n  return (\n    <>\n      <span\n        ref={nodeRef}\n        id={pipId}\n        role=\"img\"\n        aria-label={label}\n        data-stage={stage}\n        className={[BASE_CLASSES, STAGE_CLASSES[stage], className].filter(Boolean).join(\" \")}\n      >\n        <span aria-hidden=\"true\">{count}</span>\n      </span>\n      {/* async re-polish announcements — the resting description above already\n          covers \"how stale\", this only ever speaks up when new mail lands */}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/badge-unread-tarnish.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "badge",
      "notification",
      "unread-count",
      "navigation",
      "recency",
      "micro-interaction"
    ],
    "instruction": "An unread-count badge for nav items and sidebars whose whole point is a second dimension alongside the count: how stale the newest item is, readable at rest with zero motion. It's a single rounded-full <span role=\"img\"> (not a control — the nav item it decorates is the focusable element) whose data-stage is derived purely from a newestTimestamp prop, never from the count: fresh (age under 24h) renders a solid --foreground fill with background-token digits and semibold weight; waning (under 7 days) drops the fill to transparent and thins to a 1.5px --foreground border at medium weight; dormant (7 days or more) settles to a 1px --ns-muted border with --ns-muted digits at normal weight — fill, border width and text tone all move together so the three stages differ by more than hue and survive monochrome viewing. Ordinary aging (a re-check timer, or a prop simply reflecting more elapsed time) crossfades those CSS properties over a plain 400ms transition; new activity is categorically different, so it's fast-pathed around that transition with a one-frame transitionDuration:0 clamp straight to the fresh stage, topped with a 200ms spring-eased (cubic-bezier back-out) scale(1 to 1.12 back to 1) flare via the Web Animations API, so re-polish always reads as an instant snap rather than an eased fade. Accessibility: the pip's own aria-label spells out both dimensions as text (\"3 unread, newest 2 days ago\"), meant to be pulled in by the decorated nav item's aria-describedby pointing at the pip's id (falls back to an internal useId if none is passed); a separate visually-hidden aria-live=\"polite\" region announces only the re-polish moment itself (\"New unread activity, N unread\"), kept apart from the resting description so routine renders never spam a live region. A count of zero or less renders nothing, matching how unread badges actually get used. Zero dependencies, no canvas — DOM and CSS only, colors entirely from --background, --foreground, --ns-muted and --border. Rendering never touches Date.now() during the initial render (server and the pre-effect client render both compute an age of zero), so there is no hydration mismatch; the real elapsed age is adopted the moment effects can run, and a per-minute interval keeps a badge left open on screen decaying through its stages on its own. prefers-reduced-motion drops the spring flare (the instant stage snap and the 400ms crossfade both stay, since neither is extra motion)."
  },
  "type": "registry:ui"
}