{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "whats-new",
  "type": "registry:ui",
  "title": "What's New",
  "description": "A changelog popover with a rolling unread count and pages you step through.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/whats-new.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight, ChevronLeft, ChevronRight, Sparkle } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ChangelogEntry = {\n  id: string;\n  title: string;\n  /** An ISO date (“2026-09-18”) or a Date. */\n  date: string | Date;\n  /** Short kind label: “Feature”, “Improvement”, “Fix”. */\n  tag?: string;\n  /** An image URL, or any node (a video, an illustration). Shown 16:9. */\n  image?: string | React.ReactNode;\n  imageAlt?: string;\n  /** One line under the title in the list. */\n  summary?: string;\n  /** The full entry, shown when opened. */\n  body?: React.ReactNode;\n  /** Link to the full post. */\n  href?: string;\n};\n\nexport type UseWhatsNewOptions = {\n  entries: ChangelogEntry[];\n  /** Ids already read (controlled). Persist it per user. */\n  read?: string[];\n  defaultRead?: string[];\n  onReadChange?: (read: string[]) => void;\n};\n\n/** Read tracking without the popover: counts, and marking one or all as read. */\nexport function useWhatsNew({ entries, read, defaultRead = [], onReadChange }: UseWhatsNewOptions) {\n  const [ids, setIds] = useControllableState<string[]>({ value: read, defaultValue: defaultRead, onChange: onReadChange });\n  const isRead = (id: string) => ids.includes(id);\n  return {\n    read: ids,\n    isRead,\n    unread: entries.filter((e) => !ids.includes(e.id)).length,\n    markRead: (id: string) => setIds((prev) => (prev.includes(id) ? prev : [...prev, id])),\n    markAllRead: () => setIds((prev) => [...new Set([...prev, ...entries.map((e) => e.id)])]),\n  };\n}\n\ntype Ctx = ReturnType<typeof useWhatsNew> & { entries: ChangelogEntry[] };\nconst WhatsNewCtx = createContext<Ctx | null>(null);\nconst useCtx = () => {\n  const ctx = useContext(WhatsNewCtx);\n  if (!ctx) throw new Error(\"WhatsNew parts must be inside <WhatsNew>\");\n  return ctx;\n};\n\nexport type WhatsNewProps = UseWhatsNewOptions & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  children: React.ReactNode;\n};\n\n/** Holds the entries and what has been read. Put a trigger and the content inside. */\nexport function WhatsNew({ entries, read, defaultRead, onReadChange, open, defaultOpen, onOpenChange, children }: WhatsNewProps) {\n  const state = useWhatsNew({ entries, read, defaultRead, onReadChange });\n  return (\n    <WhatsNewCtx.Provider value={{ ...state, entries }}>\n      <Popover.Root open={open} defaultOpen={defaultOpen} onOpenChange={(next) => onOpenChange?.(next)}>\n        {children}\n      </Popover.Root>\n    </WhatsNewCtx.Provider>\n  );\n}\n\nexport type WhatsNewTriggerProps = Omit<Popover.Trigger.Props, \"className\" | \"children\"> & {\n  className?: string;\n  label?: string;\n  /** Square button with just the icon and the badge. The label becomes its name. */\n  iconOnly?: boolean;\n  variant?: \"secondary\" | \"ghost\";\n};\n\n/** The button with the unread badge. The count rolls down as entries are read. */\nexport function WhatsNewTrigger({ label = \"What’s new\", iconOnly = false, variant = \"ghost\", className, ...rest }: WhatsNewTriggerProps) {\n  const { unread } = useCtx();\n  const reduce = useReducedMotion();\n  const name = unread ? `${label}, ${unread} unread` : label;\n  return (\n    <Popover.Trigger\n      aria-label={iconOnly || unread ? name : undefined}\n      data-variant={variant}\n      className={cn(\n        \"relative inline-flex h-8 shrink-0 select-none items-center gap-2 rounded-lg text-[12.5px] font-medium outline-none\",\n        \"transition-[background-color,border-color,color,scale] duration-150 active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        iconOnly ? \"w-8 justify-center\" : \"pl-2 pr-2.5\",\n        variant === \"secondary\"\n          ? \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover data-popup-open:border-fg-4 data-popup-open:bg-hover\"\n          : \"text-fg-2 hover:bg-hover hover:text-fg data-popup-open:bg-hover data-popup-open:text-fg\",\n        className,\n      )}\n      {...rest}\n    >\n      <Sparkle className=\"shrink-0\" />\n      {!iconOnly && <span>{label}</span>}\n      <AnimatePresence initial={false}>\n        {unread > 0 && (\n          <motion.span\n            key=\"badge\"\n            aria-hidden\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, transition: { duration: 0.14 } }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n            className={cn(\n              \"tabular grid h-4 min-w-4 place-items-center rounded-full bg-fg px-1 text-[10.5px] font-medium leading-none text-frame\",\n              iconOnly && \"absolute -right-1 -top-1 ring-2 ring-frame\",\n            )}\n          >\n            <NumberFlow value={unread} />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </Popover.Trigger>\n  );\n}\n\nexport type WhatsNewContentProps = {\n  title?: string;\n  /** Link to the complete changelog, shown in the footer. */\n  changelogHref?: string;\n  changelogLabel?: string;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Portal target. Defaults to document.body. */\n  container?: HTMLElement | null;\n  className?: string;\n};\n\ntype View = { entry: number | null; dir: 1 | -1 };\n\n/**\n * The popover: a list of entries, each opening into a page you can step\n * through. Opening an entry marks it read.\n */\nexport function WhatsNewContent({\n  title = \"What’s new\",\n  changelogHref,\n  changelogLabel = \"Full changelog\",\n  side = \"bottom\",\n  align = \"end\",\n  container,\n  className,\n}: WhatsNewContentProps) {\n  const { entries, unread, isRead, markRead, markAllRead } = useCtx();\n  const [view, setView] = useState<View>({ entry: null, dir: 1 });\n  const [instant, setInstant] = useState(false);\n  const listRef = useRef<HTMLDivElement>(null);\n  const pagesRef = useRef<HTMLDivElement>(null);\n  const returnTo = useRef<string | null>(null);\n  const reduce = useReducedMotion();\n\n  const focusPage = useRef(false);\n\n  const open = (index: number, dir: 1 | -1, via: \"pointer\" | \"key\" = \"pointer\") => {\n    const entry = entries[index];\n    if (!entry) return;\n    // Coming from the list, or reading the page itself: follow to the new page.\n    // Pressing the arrows in the header: leave focus on the arrow.\n    focusPage.current = view.entry == null || !!document.activeElement?.closest(\"article[data-page]\");\n    markRead(entry.id);\n    setInstant(via === \"key\" || !!reduce);\n    setView({ entry: index, dir });\n  };\n  const back = () => {\n    returnTo.current = view.entry != null ? (entries[view.entry]?.id ?? null) : null;\n    setInstant(!!reduce);\n    setView({ entry: null, dir: -1 });\n  };\n\n  // Move focus with the view, so it never falls out of the popover when the element it was on leaves.\n  useEffect(() => {\n    if (view.entry != null) {\n      // Query rather than hold a ref: during a page turn the leaving article is still mounted.\n      const id = entries[view.entry]?.id;\n      if (focusPage.current && id) pagesRef.current?.querySelector<HTMLElement>(`article[data-page=\"${CSS.escape(id)}\"]`)?.focus({ preventScroll: true });\n      focusPage.current = false;\n    } else if (returnTo.current) {\n      listRef.current?.querySelector<HTMLElement>(`[data-entry=\"${CSS.escape(returnTo.current)}\"]`)?.focus({ preventScroll: true });\n      returnTo.current = null;\n    }\n  }, [view, entries]);\n\n  // Direction and instant-ness travel through AnimatePresence's `custom`, so the\n  // leaving page exits the right way even though it rendered before the change.\n  const motionProps = {\n    variants: pageVariants,\n    initial: \"enter\",\n    animate: \"center\",\n    exit: \"exit\",\n    transition: { duration: 0.26, ease: ease.out },\n  } as const;\n\n  const index = view.entry ?? 0;\n  const current = view.entry != null ? entries[view.entry] : null;\n\n  return (\n    <Popover.Portal container={container ?? undefined}>\n      <Popover.Positioner side={side} align={align} sideOffset={8} collisionPadding={12} className=\"z-(--z-popover)\">\n        <Popover.Popup\n          initialFocus={() => listRef.current?.querySelector<HTMLElement>(\"[data-entry]\") ?? true}\n          className={cn(\n            \"flex w-[360px] max-w-[var(--available-width)] flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n            \"origin-[var(--transform-origin)] transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n            \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n            \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n            \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n            \"data-instant:transition-none\",\n            \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n            className,\n          )}\n          onKeyDown={(e) => {\n            if (view.entry == null) return;\n            if (e.key === \"ArrowRight\" && view.entry < entries.length - 1) {\n              e.preventDefault();\n              open(view.entry + 1, 1, \"key\");\n            } else if (e.key === \"ArrowLeft\" && view.entry > 0) {\n              e.preventDefault();\n              open(view.entry - 1, -1, \"key\");\n            } else if (e.key === \"Backspace\") {\n              e.preventDefault();\n              back();\n            }\n          }}\n        >\n          <AutoHeight instant={instant}>\n            <AnimatePresence initial={false} mode=\"popLayout\" custom={{ dir: view.dir, instant }}>\n              {current && view.entry != null ? (\n                <motion.div key=\"detail\" custom={{ dir: view.dir, instant }} {...motionProps}>\n                  <div className=\"flex h-11 items-center gap-1 border-b border-line px-1.5\">\n                    <button type=\"button\" onClick={back} className={cn(ghost, \"gap-1 pl-1 pr-2\")}>\n                      <ChevronLeft className=\"size-3.5\" />\n                      All updates\n                    </button>\n                    <div className=\"ml-auto flex items-center gap-0.5\">\n                      <span className=\"tabular px-1.5 text-[11.5px] text-fg-3\">\n                        {index + 1} of {entries.length}\n                      </span>\n                      {/* aria-disabled, not disabled: a button that disables under the pointer or focus would drop focus to the page. */}\n                      <button\n                        type=\"button\"\n                        aria-label=\"Newer update\"\n                        aria-disabled={index === 0 || undefined}\n                        onClick={() => index > 0 && open(index - 1, -1)}\n                        className={cn(ghost, \"w-7 justify-center\")}\n                      >\n                        <ChevronLeft />\n                      </button>\n                      <button\n                        type=\"button\"\n                        aria-label=\"Older update\"\n                        aria-disabled={index === entries.length - 1 || undefined}\n                        onClick={() => index < entries.length - 1 && open(index + 1, 1)}\n                        className={cn(ghost, \"w-7 justify-center\")}\n                      >\n                        <ChevronRight />\n                      </button>\n                    </div>\n                  </div>\n                  {/* The header stays put; only the page under it turns. */}\n                  <div ref={pagesRef} className=\"relative\">\n                    <AnimatePresence initial={false} mode=\"popLayout\" custom={{ dir: view.dir, instant }}>\n                  <motion.article\n                        key={current.id}\n                        data-page={current.id}\n                        tabIndex={-1}\n                        aria-labelledby={`wn-${current.id}`}\n                        custom={{ dir: view.dir, instant }}\n                        {...motionProps}\n                        className=\"p-4 outline-none\"\n                      >\n                    <Media entry={current} className=\"aspect-[16/9] rounded-lg\" />\n                    <Meta entry={current} className=\"mt-3.5\" />\n                    <h3 id={`wn-${current.id}`} className=\"mt-1 text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance\">\n                      {current.title}\n                    </h3>\n                    {(current.body ?? current.summary) && (\n                      <div className=\"mt-1.5 text-[12.5px] leading-[1.55] text-fg-2 text-pretty [&_p+p]:mt-2\">{current.body ?? current.summary}</div>\n                    )}\n                    {current.href && (\n                      <a href={current.href} target=\"_blank\" rel=\"noopener noreferrer\" className={cn(link, \"mt-3\")}>\n                        Read the full post\n                        <ArrowUpRight className=\"size-3.5 transition-transform duration-150 ease-out-expo group-hover/link:-translate-y-px group-hover/link:translate-x-px\" />\n                      </a>\n                    )}\n                  </motion.article>\n                    </AnimatePresence>\n                  </div>\n                </motion.div>\n              ) : (\n                <motion.div key=\"list\" custom={{ dir: view.dir, instant }} {...motionProps}>\n                  <div className=\"flex h-11 items-center gap-2 border-b border-line pl-4 pr-1.5\">\n                    <Popover.Title className=\"text-[13.5px] font-medium tracking-[-0.01em] text-fg\">{title}</Popover.Title>\n                    <span className=\"tabular text-[11.5px] text-fg-3\">\n                      {unread ? (\n                        <>\n                          <NumberFlow value={unread} /> new\n                        </>\n                      ) : (\n                        \"Up to date\"\n                      )}\n                    </span>\n                    <button type=\"button\" disabled={!unread} onClick={markAllRead} className={cn(ghost, \"ml-auto px-2\")}>\n                      Mark all as read\n                    </button>\n                  </div>\n                  {entries.length === 0 ? (\n                    <div className=\"px-4 py-10 text-center\">\n                      <p className=\"text-[13px] text-fg\">No updates yet</p>\n                      <p className=\"mt-1 text-[12px] text-fg-3\">New features and fixes show up here.</p>\n                    </div>\n                  ) : (\n                    <div\n                      ref={listRef}\n                      role=\"list\"\n                      className=\"max-h-[320px] overflow-y-auto overscroll-contain p-1.5\"\n                      onKeyDown={(e) => {\n                        if (e.key !== \"ArrowDown\" && e.key !== \"ArrowUp\") return;\n                        const rows = [...e.currentTarget.querySelectorAll<HTMLElement>(\"[data-entry]\")];\n                        const at = rows.indexOf(document.activeElement as HTMLElement);\n                        const to = rows[Math.min(Math.max(at + (e.key === \"ArrowDown\" ? 1 : -1), 0), rows.length - 1)];\n                        if (to) {\n                          e.preventDefault();\n                          to.focus();\n                        }\n                      }}\n                    >\n                      {entries.map((entry, i) => {\n                        const fresh = !isRead(entry.id);\n                        return (\n                          <div role=\"listitem\" key={entry.id}>\n                            <button\n                              type=\"button\"\n                              data-entry={entry.id}\n                              data-unread={fresh ? \"\" : undefined}\n                              onClick={() => open(i, 1)}\n                              className={cn(\n                                \"group/row flex w-full items-center gap-3 rounded-lg p-2 text-left outline-none\",\n                                \"transition-[background-color] duration-150 hover:bg-hover focus-visible:bg-hover\",\n                                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                              )}\n                            >\n                              <Media entry={entry} className=\"h-10 w-16 shrink-0 rounded-md\" thumbnail />\n                              <span className=\"min-w-0 flex-1\">\n                                <span className={cn(\"block truncate text-[13px] leading-[1.35]\", fresh ? \"font-medium text-fg\" : \"text-fg-2\")}>\n                                  {entry.title}\n                                  {fresh && <span className=\"sr-only\">, unread</span>}\n                                </span>\n                                <Meta entry={entry} className=\"mt-0.5 block\" />\n                              </span>\n                              {/* The unread dot shrinks away the moment the entry is opened. */}\n                              <span\n                                aria-hidden\n                                className={cn(\n                                  \"size-1.5 shrink-0 rounded-full bg-fg transition-[scale,opacity] duration-200 ease-out-expo\",\n                                  fresh ? \"scale-100 opacity-100\" : \"scale-0 opacity-0\",\n                                )}\n                              />\n                            </button>\n                          </div>\n                        );\n                      })}\n                    </div>\n                  )}\n                  {changelogHref && (\n                    <div className=\"border-t border-line px-2 py-1.5\">\n                      <a href={changelogHref} target=\"_blank\" rel=\"noopener noreferrer\" className={cn(link, \"px-2\")}>\n                        {changelogLabel}\n                        <ArrowUpRight className=\"size-3.5 transition-transform duration-150 ease-out-expo group-hover/link:-translate-y-px group-hover/link:translate-x-px\" />\n                      </a>\n                    </div>\n                  )}\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </AutoHeight>\n          <p role=\"status\" className=\"sr-only\">\n            {current ? `${current.title}, ${(view.entry ?? 0) + 1} of ${entries.length}` : \"\"}\n          </p>\n        </Popover.Popup>\n      </Popover.Positioner>\n    </Popover.Portal>\n  );\n}\n\ntype Page = { dir: 1 | -1; instant: boolean };\nconst pageVariants = {\n  enter: ({ dir, instant }: Page) => (instant ? { opacity: 1, x: 0, filter: \"blur(0px)\" } : { opacity: 0, x: dir * 24, filter: \"blur(2px)\" }),\n  center: { opacity: 1, x: 0, filter: \"blur(0px)\" },\n  exit: ({ dir, instant }: Page) =>\n    instant\n      ? { opacity: 0, transition: { duration: 0 } }\n      : { opacity: 0, x: dir * -24, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } },\n};\n\nconst ghost = cn(\n  \"relative inline-flex h-7 shrink-0 items-center rounded-md text-[12px] text-fg-2 outline-none\",\n  \"transition-[background-color,color,opacity,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n  \"disabled:pointer-events-none disabled:opacity-40 aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent aria-disabled:hover:text-fg-2 aria-disabled:active:scale-100\",\n);\n\nconst link = cn(\n  \"group/link inline-flex h-7 items-center gap-1 rounded-md text-[12px] text-fg-2 outline-none\",\n  \"transition-colors duration-150 hover:text-fg\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n);\n\nfunction Media({ entry, className, thumbnail }: { entry: ChangelogEntry; className?: string; thumbnail?: boolean }) {\n  const [failed, setFailed] = useState(false);\n  const box = cn(\"relative block overflow-hidden border border-line bg-hover\", className);\n  if (!entry.image || failed) return <span aria-hidden className={cn(box, \"grid place-items-center text-fg-4\")}><Sparkle className={thumbnail ? \"size-3.5\" : undefined} /></span>;\n  if (typeof entry.image !== \"string\") return <span aria-hidden={thumbnail || undefined} className={box}>{entry.image}</span>;\n  return (\n    <span className={box}>\n      {/* eslint-disable-next-line @next/next/no-img-element -- copied into any React app, not only Next */}\n      <img src={entry.image} alt={thumbnail ? \"\" : (entry.imageAlt ?? \"\")} loading=\"lazy\" onError={() => setFailed(true)} className=\"absolute inset-0 size-full object-cover\" />\n    </span>\n  );\n}\n\nfunction Meta({ entry, className }: { entry: ChangelogEntry; className?: string }) {\n  return (\n    <span className={cn(\"text-[11.5px] text-fg-3\", className)}>\n      {entry.tag && (\n        <>\n          {entry.tag}\n          <span aria-hidden className=\"px-1 text-fg-4\">·</span>\n        </>\n      )}\n      <time dateTime={toDate(entry.date).toISOString().slice(0, 10)}>{formatDate(entry.date)}</time>\n    </span>\n  );\n}\n\nfunction toDate(d: string | Date) {\n  return typeof d === \"string\" ? new Date(/^\\d{4}-\\d{2}-\\d{2}$/.test(d) ? `${d}T00:00:00Z` : d) : d;\n}\n\n// Recent dates read relative (“Today”, “3 days ago”), older ones as a short date.\n// Date-only strings are calendar days, so they're compared and printed in UTC.\nfunction formatDate(d: string | Date) {\n  const date = toDate(d);\n  const now = new Date();\n  const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate());\n  const days = Math.round((day(now) - day(date)) / 86_400_000);\n  if (days >= 0 && days < 7) return new Intl.RelativeTimeFormat(\"en\", { numeric: \"auto\" }).format(-days, \"day\").replace(/^./, (c) => c.toUpperCase());\n  return new Intl.DateTimeFormat(\"en\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: date.getUTCFullYear() === now.getUTCFullYear() ? undefined : \"numeric\",\n    timeZone: \"UTC\",\n  }).format(date);\n}\n\n// Eases the popover's height between the list and an entry, and between entries of different lengths.\nfunction AutoHeight({ children, instant }: { children: React.ReactNode; instant: boolean }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize[0]?.blockSize ?? el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <motion.div\n      initial={false}\n      animate={{ height }}\n      transition={instant ? { duration: 0 } : { duration: 0.3, ease: ease.inOut }}\n      className=\"relative overflow-hidden\"\n    >\n      <div ref={inner}>{children}</div>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/whats-new"
}
