{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"profile-menu","type":"registry:ui","title":"Profile Menu","description":"Account card with the signed-in user, menu actions and profile setup progress","dependencies":[],"files":[{"path":"registry/profile-menu/profile-menu.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\n\n/*\n * Profile Menu – an account card with the signed-in user, menu actions and profile setup progress, in a single self-contained file.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ProfileMenuItem = {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  /** Small pill on the right, e.g. \"New\" */\n  badge?: string;\n  onSelect?: () => void;\n};\n\nexport type ProfileSetup = {\n  /** Steps finished so far */\n  done: number;\n  total: number;\n  /** The next open step, shown as a shortcut under the bar */\n  next?: string;\n  onContinue?: () => void;\n};\n\nexport type ProfileMenuProps = {\n  name: string;\n  email: string;\n  /** Replaces the default avatar */\n  avatar?: ReactNode;\n  items?: ProfileMenuItem[];\n  /** Profile setup progress under the actions – leave empty to hide */\n  setup?: ProfileSetup;\n  className?: string;\n};\n\n// Counts get separators; anything that isn't a finite number reads as 0\nconst count = new Intl.NumberFormat(\"en\");\nconst finite = (n: unknown) => (typeof n === \"number\" && Number.isFinite(n) ? n : 0);\n\nexport function ProfileMenu({\n  name,\n  email,\n  avatar,\n  items = defaultItems,\n  setup,\n  className,\n}: ProfileMenuProps) {\n  const listRef = useRef<HTMLDivElement>(null);\n  // Gaps in the list are skipped, an image that fails to load falls back to the default avatar\n  const list = (Array.isArray(items) ? items : []).filter((item): item is ProfileMenuItem => !!item && typeof item === \"object\");\n  const [broken, setBroken] = useState(false);\n  const shownName = typeof name === \"string\" ? name.trim() : \"\";\n  // The progress bar fills up once the card is on screen\n  const [filled, setFilled] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([e]) => e.isIntersecting && (setFilled(true), io.disconnect()), {\n      threshold: 0.3,\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  // Arrow keys move between the actions, like in a native menu\n  const onKey = (e: KeyboardEvent) => {\n    const buttons = [...(listRef.current?.querySelectorAll<HTMLButtonElement>(\"button\") ?? [])];\n    const i = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    const next =\n      e.key === \"ArrowDown\" ? (i + 1) % buttons.length\n      : e.key === \"ArrowUp\" ? (i - 1 + buttons.length) % buttons.length\n      : e.key === \"Home\" ? 0\n      : e.key === \"End\" ? buttons.length - 1\n      : null;\n    if (next === null) return;\n    e.preventDefault();\n    buttons[next]?.focus();\n  };\n\n  return (\n    <>\n      <style href=\"ui-profile-menu\" precedence=\"default\">\n        {css}\n      </style>\n      <section ref={rootRef} className={[\"ui-pm\", className].filter(Boolean).join(\" \")} data-filled={filled || undefined}>\n        <div className=\"ui-pm__card\">\n        <header className=\"ui-pm__user\">\n          <span className=\"ui-pm__avatar\" onErrorCapture={() => setBroken(true)}>\n            {avatar && !broken ? avatar : <DefaultAvatar />}\n          </span>\n          <span className=\"ui-pm__who\">\n            {shownName && (\n              <span className=\"ui-pm__name\" dir=\"auto\" title={shownName}>\n                {shownName}\n              </span>\n            )}\n            <span className=\"ui-pm__email\" dir=\"auto\" title={email}>\n              {email}\n            </span>\n          </span>\n        </header>\n\n        <div ref={listRef} className=\"ui-pm__items\" role=\"menu\" aria-label=\"Account\" onKeyDown={onKey}>\n          {list.map((item, i) => (\n            <button key={`${item.id}-${i}`} type=\"button\" role=\"menuitem\" className=\"ui-pm__item\" onClick={item.onSelect}>\n              <span className=\"ui-pm__icon\">{item.icon}</span>\n              <span className=\"ui-pm__label\" dir=\"auto\" title={item.label}>\n                {item.label}\n              </span>\n              {item.badge && (\n                <span className=\"ui-pm__badge\" dir=\"auto\" title={item.badge}>\n                  {item.badge}\n                </span>\n              )}\n            </button>\n          ))}\n        </div>\n\n        </div>\n        {setup && <SetupProgress {...setup} />}\n      </section>\n    </>\n  );\n}\n\n/** Compact setup progress in the gray extension: count, bar and the next step on one line */\nfunction SetupProgress({ done: rawDone, total: rawTotal, next, onContinue }: ProfileSetup) {\n  const total = Math.max(0, finite(rawTotal));\n  const done = Math.min(total, Math.max(0, finite(rawDone)));\n  const ratio = total > 0 ? done / total : 0;\n  const percent = Math.round(ratio * 100);\n  const open = next && done < total;\n  return (\n    <div className=\"ui-pm__setup\">\n      <div className=\"ui-pm__setup-head\">\n        <span className=\"ui-pm__setup-title\">Profile setup</span>\n        <span className=\"ui-pm__setup-count\">\n          {count.format(done)}/{count.format(total)}\n        </span>\n      </div>\n      <span\n        className=\"ui-pm__track\"\n        role=\"progressbar\"\n        aria-label=\"Profile setup\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={percent}\n      >\n        <span className=\"ui-pm__fill\" style={{ transform: `scaleX(${ratio})` }} />\n      </span>\n      {open && (\n        <button type=\"button\" className=\"ui-pm__next\" onClick={onContinue}>\n          <span className=\"ui-pm__next-label\" dir=\"auto\" title={next}>\n            <span className=\"ui-pm__next-hint\">Next:</span> {next}\n          </span>\n          <ArrowIcon />\n        </button>\n      )}\n    </div>\n  );\n}\n\n/* ---------- Icons ---------- */\n\nconst Svg = ({ children }: { children: ReactNode }) => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    {children}\n  </svg>\n);\n\nexport const SettingsIcon = () => (\n  <Svg>\n    <path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\" />\n    <circle cx=\"12\" cy=\"12\" r=\"3\" />\n  </Svg>\n);\n\nexport const LanguageIcon = () => (\n  <Svg>\n    <path d=\"m5 8 6 6M4 14l6-6 2-3M2 5h12M7 2h1M22 22l-5-10-5 10M14 18h6\" />\n  </Svg>\n);\n\nexport const DocsIcon = () => (\n  <Svg>\n    <path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" />\n    <path d=\"M14 2v4a2 2 0 0 0 2 2h4M16 13H8M16 17H8M10 9H8\" />\n  </Svg>\n);\n\nexport const AppsIcon = () => (\n  <Svg>\n    <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\" />\n    <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\" />\n    <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\" />\n    <path d=\"M17.5 3v7M14 6.5h7\" />\n  </Svg>\n);\n\nexport const LogoutIcon = () => (\n  <Svg>\n    <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9\" />\n  </Svg>\n);\n\n/** Default avatar: a person icon centered on a soft blue disc */\nconst DefaultAvatar = () => (\n  <svg viewBox=\"0 0 40 40\" aria-hidden=\"true\">\n    <circle cx=\"20\" cy=\"20\" r=\"20\" fill=\"currentColor\" opacity=\"0.1\" />\n    <circle cx=\"20\" cy=\"16\" r=\"4.75\" fill=\"currentColor\" />\n    <path d=\"M11.5 28.25c.9-4 4.3-6.75 8.5-6.75s7.6 2.75 8.5 6.75c.15.7-.4 1.25-1.1 1.25H12.6c-.7 0-1.25-.55-1.1-1.25Z\" fill=\"currentColor\" />\n  </svg>\n);\n\nconst ArrowIcon = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M5 12h14M13 6l6 6-6 6\" />\n  </svg>\n);\n\nconst defaultItems: ProfileMenuItem[] = [\n  { id: \"settings\", label: \"Settings\", icon: <SettingsIcon /> },\n  { id: \"docs\", label: \"Docs\", icon: <DocsIcon /> },\n  { id: \"apps\", label: \"Apps\", icon: <AppsIcon />, badge: \"New\" },\n  { id: \"logout\", label: \"Logout\", icon: <LogoutIcon /> },\n];\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n.ui-pm {\n  --ui-pm-card: var(--aui-overlay, #ffffff);\n  --ui-pm-line: var(--aui-border, #ececec);\n  --ui-pm-ink: var(--aui-ink, #171717);\n  --ui-pm-text: var(--aui-ink, #404040);\n  --ui-pm-muted: var(--aui-muted, #8f8f8f);\n  --ui-pm-icon: var(--aui-muted, #737373);\n  --ui-pm-hover: var(--aui-subtle, #f5f5f5);\n  --ui-pm-off: #f0f0f0;\n  --ui-pm-fill: var(--aui-blue, #4fb0fe);\n  --ui-pm-badge: #e12a1d;\n  --ui-pm-badge-wash: #fff0f3;\n  --ui-pm-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  --ui-pm-tray: #f0f0f0;\n  box-sizing: border-box;\n  width: 100%;\n  max-width: 320px;\n  /* Like the Chat Composer: the white menu card sits on a gray tray, the setup progress lives in the tray below */\n  background: var(--ui-pm-tray);\n  /* The gray tray frames the white card on every side */\n  padding: 6px;\n  border-radius: calc(var(--aui-radius-lg, 16px) + 6px);\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  color: var(--ui-pm-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-pm *, .ui-pm *::before, .ui-pm *::after { box-sizing: border-box; }\n.ui-pm__card {\n  padding: 16px 8px 8px;\n  background: var(--ui-pm-card);\n  border: 1px solid var(--aui-border, #e5e5e5);\n  border-radius: var(--aui-radius-lg, 16px);\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n}\n\n.ui-pm__user {\n  display: flex;\n  align-items: center;\n  gap: 14px;\n  padding: 0 10px 12px;\n}\n.ui-pm__avatar {\n  flex: none;\n  width: 42px;\n  height: 42px;\n  overflow: hidden;\n  border-radius: 50%;\n  color: var(--ui-pm-fill);\n}\n.ui-pm__avatar > * { display: block; width: 100%; height: 100%; object-fit: cover; }\n.ui-pm__who {\n  display: grid;\n  min-width: 0;\n}\n.ui-pm__name {\n  overflow-wrap: anywhere;\n  font-size: 15px;\n  font-weight: 500;\n  line-height: 22px;\n}\n.ui-pm__email {\n  overflow: hidden;\n  font-size: 13px;\n  line-height: 20px;\n  color: var(--ui-pm-muted);\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n/* The column may shrink, so long labels shorten instead of widening the card; a long list scrolls */\n.ui-pm__items {\n  display: grid;\n  grid-template-columns: minmax(0, 1fr);\n  gap: 2px;\n  max-height: 380px;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n}\n.ui-pm__item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  width: 100%;\n  height: 36px;\n  padding: 0 12px;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 5px);\n}\n.ui-pm__item {\n  margin: 0;\n  border: 0;\n  background: transparent;\n  font: inherit;\n  text-align: left;\n  color: var(--ui-pm-text);\n  cursor: pointer;\n  transition: background-color 160ms ease, color 160ms ease;\n}\n.ui-pm__item:hover,\n.ui-pm__item:focus-visible {\n  background: var(--ui-pm-hover);\n  color: var(--ui-pm-ink);\n  outline: none;\n}\n.ui-pm__item:active { background: #efefef; }\n\n.ui-pm__icon {\n  display: grid;\n  place-items: center;\n  flex: none;\n  width: 20px;\n  height: 20px;\n  color: var(--ui-pm-icon);\n  transition: color 160ms ease;\n}\n.ui-pm__icon svg { width: 20px; height: 20px; }\n.ui-pm__item:hover .ui-pm__icon,\n.ui-pm__item:focus-visible .ui-pm__icon { color: var(--ui-pm-ink); }\n\n.ui-pm__label {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  font-size: 14px;\n  line-height: 20px;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n.ui-pm__badge {\n  flex: none;\n  max-width: 50%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  padding: 2px 8px;\n  border-radius: 999px;\n  background: var(--ui-pm-badge-wash);\n  font-size: 12px;\n  font-weight: 500;\n  line-height: 18px;\n  color: var(--ui-pm-badge);\n}\n\n/* Profile setup in the gray extension – kept to two short lines and a bar */\n.ui-pm__setup {\n  padding: 10px 18px 12px;\n}\n.ui-pm__setup-head {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  font-size: 13px;\n  line-height: 18px;\n}\n.ui-pm__setup-title { color: var(--ui-pm-text); font-weight: 500; }\n.ui-pm__setup-count {\n  font-variant-numeric: tabular-nums;\n  color: var(--ui-pm-muted);\n}\n.ui-pm__track {\n  display: block;\n  height: 4px;\n  margin-top: 7px;\n  overflow: hidden;\n  border-radius: 2px;\n  background: rgba(0, 0, 0, 0.07);\n}\n.ui-pm__fill {\n  display: block;\n  height: 100%;\n  border-radius: inherit;\n  background: var(--ui-pm-fill);\n  transform-origin: left center;\n  scale: 0 1;\n  transition: scale 900ms var(--ui-pm-ease) 150ms;\n}\n.ui-pm[data-filled] .ui-pm__fill { scale: 1 1; }\n.ui-pm__next {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: calc(100% + 16px);\n  margin: 6px -8px 0;\n  padding: 3px 8px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 1px);\n  background: transparent;\n  font: inherit;\n  text-align: left;\n  color: var(--ui-pm-ink);\n  cursor: pointer;\n  transition: background-color 160ms ease;\n}\n.ui-pm__next:hover,\n.ui-pm__next:focus-visible {\n  background: var(--aui-subtle, rgba(0, 0, 0, 0.04));\n  outline: none;\n}\n.ui-pm__next-label {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  font-size: 13px;\n  line-height: 20px;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n.ui-pm__next-hint { color: var(--ui-pm-muted); }\n.ui-pm__next svg {\n  flex: none;\n  width: 14px;\n  height: 14px;\n  color: var(--ui-pm-icon);\n  transition: transform 200ms var(--ui-pm-ease), color 160ms ease;\n}\n.ui-pm__next:hover svg { transform: translateX(2px); color: var(--ui-pm-ink); }\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-pm__item, .ui-pm__icon, .ui-pm__fill, .ui-pm__next, .ui-pm__next svg { transition: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-pm {\n  --ui-pm-card: var(--aui-overlay, #232326);\n  --ui-pm-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-pm-ink: var(--aui-ink, #ededed);\n  --ui-pm-text: var(--aui-ink, #c4c4c4);\n  --ui-pm-muted: var(--aui-muted, #8f8f8f);\n  --ui-pm-icon: var(--aui-muted, #8f8f8f);\n  --ui-pm-hover: var(--aui-subtle, #2a2a2d);\n  --ui-pm-off: #161618;\n  --ui-pm-badge: var(--aui-danger, #ff8a7a);\n  --ui-pm-badge-wash: color-mix(in srgb, var(--aui-red, #ff6b5f) 16%, transparent);\n  --ui-pm-tray: #161618;\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-pm__card {\n  border-color: var(--aui-border, rgba(255, 255, 255, 0.08));\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-pm__avatar { color: var(--aui-info, #4fb0fe); }\n:where(.dark, [data-theme=\"dark\"]) .ui-pm__item:active { background: #313135; }\n:where(.dark, [data-theme=\"dark\"]) .ui-pm__track { background: rgba(255, 255, 255, 0.1); }\n:where(.dark, [data-theme=\"dark\"]) .ui-pm__next:hover,\n:where(.dark, [data-theme=\"dark\"]) .ui-pm__next:focus-visible { background: var(--aui-subtle, rgba(255, 255, 255, 0.06)); }\n`;\n"}]}