{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"smart-recommendation","type":"registry:ui","title":"Smart Recommendation","description":"Suggestion card with a confidence signal, alternatives to switch to and accept with undo","dependencies":[],"files":[{"path":"registry/smart-recommendation/smart-recommendation.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\n\n/*\n * Smart Recommendation – a suggestion card with a confidence signal, alternatives and one-click accept, in a single\n * self-contained file. Fold it away, pick an alternative instead, accept and undo.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type Recommendation = {\n  id: string;\n  /** Short name, used in the accept button, e.g. \"Incremental sync\" */\n  label: string;\n  /** One sentence why – shown in the card */\n  reason: ReactNode;\n  /** 0 = no signal, 1 = weak, 2 = good, 3 = strong */\n  signal: 0 | 1 | 2 | 3;\n};\n\nexport type SmartRecommendationProps = {\n  title?: string;\n  /** First entry is the recommendation, the rest are offered as alternatives */\n  options: Recommendation[];\n  onAccept?: (option: Recommendation) => void;\n  onUndo?: (option: Recommendation) => void;\n  defaultOpen?: boolean;\n  className?: string;\n};\n\nconst signalText = [\"No signal\", \"Weak signal\", \"Good signal\", \"Strong signal\"];\n/** Any signal outside 0–3 (NaN, \"3\", 7, -1) is read as the nearest valid level, NaN as no signal */\nconst level = (s: unknown): 0 | 1 | 2 | 3 => {\n  const n = Math.round(Number(s));\n  return (Number.isFinite(n) ? Math.min(3, Math.max(0, n)) : 0) as 0 | 1 | 2 | 3;\n};\n/** \"Incremental sync\" → \"incremental sync\", but \"AWS backup\" and \"S3 sync\" keep their capitals */\nconst lowerFirst = (s: string) => (/^\\p{Lu}\\p{Ll}/u.test(s) ? s.charAt(0).toLowerCase() + s.slice(1) : s);\n\nexport function SmartRecommendation({\n  title = \"Smart recommendation\",\n  options,\n  onAccept,\n  onUndo,\n  defaultOpen = true,\n  className,\n}: SmartRecommendationProps) {\n  // By position, so two options with the same id stay apart\n  const [chosen, setChosen] = useState(0);\n  const [accepted, setAccepted] = useState<number | null>(null);\n  const [open, setOpen] = useState(defaultOpen);\n  const [menu, setMenu] = useState(false);\n  const menuRef = useRef<HTMLDivElement>(null);\n  // When the button sits at the start of its row (long labels push the actions onto their own row), the menu opens\n  // to the right instead of past the card's left edge\n  const [flip, setFlip] = useState(false);\n  useLayoutEffect(() => {\n    const alt = menuRef.current;\n    const pop = alt?.querySelector<HTMLElement>(\".ui-sr__menu\");\n    const card = alt?.closest<HTMLElement>(\".ui-sr\");\n    if (!menu || !alt || !pop || !card || getComputedStyle(alt).position === \"static\") return setFlip(false);\n    // Some slack: a menu that only grazes the edge (as in the demo) keeps opening to the left\n    setFlip(alt.getBoundingClientRect().right - pop.offsetWidth < card.getBoundingClientRect().left - 24);\n  }, [menu]);\n\n  useEffect(() => {\n    if (!menu) return;\n    const away = (e: PointerEvent) => !menuRef.current?.contains(e.target as Node) && setMenu(false);\n    const esc = (e: KeyboardEvent) => e.key === \"Escape\" && setMenu(false);\n    window.addEventListener(\"pointerdown\", away);\n    window.addEventListener(\"keydown\", esc);\n    return () => {\n      window.removeEventListener(\"pointerdown\", away);\n      window.removeEventListener(\"keydown\", esc);\n    };\n  }, [menu]);\n\n  const at = chosen < options.length ? chosen : 0;\n  const current = options[at];\n  // Nothing to recommend – render nothing rather than crash\n  if (!current) return null;\n  const isRecommended = at === 0;\n  const done = accepted === at;\n  const signal = level(current.signal);\n  const acceptText = `Accept ${lowerFirst(String(current.label))}`;\n\n  return (\n    <>\n      <style href=\"ui-smart-recommendation\" precedence=\"default\">\n        {css}\n      </style>\n      <section className={[\"ui-sr\", className].filter(Boolean).join(\" \")} aria-label={title} data-accepted={done || undefined}>\n        <div className=\"ui-sr__card\">\n          <button type=\"button\" className=\"ui-sr__head\" aria-expanded={open} onClick={() => setOpen((o) => !o)}>\n            <SparkIcon />\n            <span className=\"ui-sr__title\" dir=\"auto\" title={title}>\n            {title}\n          </span>\n            {!isRecommended && <span className=\"ui-sr__pill\">Alternative</span>}\n            <span className=\"ui-sr__chevron\">\n              <Icon d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n            </span>\n          </button>\n          <div className=\"ui-sr__body\" data-open={open || undefined} inert={!open}>\n            <p className=\"ui-sr__reason\" key={at} dir=\"auto\">\n              {current.reason}\n            </p>\n          </div>\n        </div>\n\n        <footer className=\"ui-sr__foot\">\n          <span className=\"ui-sr__signal\" data-level={signal} title={signalText[signal]}>\n            <span className=\"ui-sr__bars\" aria-hidden=\"true\">\n              {[1, 2, 3].map((n) => (\n                <span key={n} data-on={signal >= n || undefined} />\n              ))}\n            </span>\n            {signalText[signal]}\n          </span>\n\n          <div className=\"ui-sr__actions\">\n            {options.length > 1 && !done && (\n              <div className=\"ui-sr__alt\" ref={menuRef}>\n                <button\n                  type=\"button\"\n                  className=\"ui-sr__btn\"\n                  aria-haspopup=\"menu\"\n                  aria-expanded={menu}\n                  onClick={() => setMenu((m) => !m)}\n                >\n                  Alternatives\n                </button>\n                {menu && (\n                  <div className=\"ui-sr__menu\" role=\"menu\" data-flip={flip || undefined}>\n                    {options.map((o, i) => (\n                      <button\n                        key={`${o.id}-${i}`}\n                        type=\"button\"\n                        role=\"menuitemradio\"\n                        aria-checked={i === at}\n                        className=\"ui-sr__option\"\n                        onClick={() => {\n                          setChosen(i);\n                          setMenu(false);\n                          setOpen(true);\n                        }}\n                      >\n                        <span className=\"ui-sr__option-label\">\n                          <span className=\"ui-sr__option-name\" dir=\"auto\" title={o.label}>\n                            {o.label}\n                          </span>\n                          {i === 0 && <span className=\"ui-sr__tag\">Recommended</span>}\n                        </span>\n                        <span className=\"ui-sr__mini\" data-level={level(o.signal)} aria-label={signalText[level(o.signal)]}>\n                          {[1, 2, 3].map((n) => (\n                            <span key={n} data-on={level(o.signal) >= n || undefined} />\n                          ))}\n                        </span>\n                      </button>\n                    ))}\n                  </div>\n                )}\n              </div>\n            )}\n            {done ? (\n              <>\n                <span className=\"ui-sr__done\" role=\"status\">\n                  <Icon d=\"m3.5 8.5 3 3 6-7\" size={14} />\n                  <span className=\"ui-sr__done-text\" title={`${current.label} accepted`}>\n                    <bdi>{current.label}</bdi> accepted\n                  </span>\n                </span>\n                <button\n                  type=\"button\"\n                  className=\"ui-sr__btn\"\n                  onClick={() => {\n                    setAccepted(null);\n                    onUndo?.(current);\n                  }}\n                >\n                  Undo\n                </button>\n              </>\n            ) : (\n              <button\n                type=\"button\"\n                className=\"ui-sr__btn ui-sr__btn--primary\"\n                title={acceptText}\n                onClick={() => {\n                  setAccepted(at);\n                  setMenu(false);\n                  onAccept?.(current);\n                }}\n              >\n                <span className=\"ui-sr__btn-text\">{acceptText}</span>\n              </button>\n            )}\n          </div>\n        </footer>\n      </section>\n    </>\n  );\n}\n\nconst SparkIcon = () => (\n  <svg className=\"ui-sr__spark\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M9 3.5H4A1.5 1.5 0 0 0 2.5 5v7A1.5 1.5 0 0 0 4 13.5h7a1.5 1.5 0 0 0 1.5-1.5V8\" />\n    <path d=\"M12.5 1.5v4M10.5 3.5h4\" />\n  </svg>\n);\n\nconst Icon = ({ d, size = 16 }: { d: string; size?: number }) => (\n  <svg viewBox=\"0 0 16 16\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d={d} />\n  </svg>\n);\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n.ui-sr {\n  --ui-sr-tray: var(--aui-subtle, #f0f0f0);\n  --ui-sr-card: var(--aui-surface, #ffffff);\n  --ui-sr-ink: var(--aui-ink, #171717);\n  --ui-sr-text: var(--aui-ink, #404040);\n  --ui-sr-muted: var(--aui-muted, #8f8f8f);\n  --ui-sr-accent: var(--aui-blue, #4fb0fe);\n  --ui-sr-ok: var(--aui-success, #1e8a4f);\n  --ui-sr-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-sr-accent-text: var(--aui-blue, #4fb0fe);\n  --ui-sr-border: var(--aui-border, #e5e5e5);\n  --ui-sr-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n  --ui-sr-head-hover: var(--aui-subtle, #f8f8f8);\n  --ui-sr-pill: #f4f0ff;\n  --ui-sr-pill-text: var(--aui-purple, #a651f0);\n  --ui-sr-bar: #d4d4d4;\n  --ui-sr-btn: var(--aui-surface, #fff);\n  --ui-sr-btn-hover: #f7f7f7;\n  --ui-sr-menu: var(--aui-overlay, #fff);\n  --ui-sr-option-hover: var(--aui-subtle, #f5f5f5);\n  --ui-sr-option-on: #f5f8ff;\n  --ui-sr-tag: #eef7ff;\n  --ui-sr-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  box-sizing: border-box;\n  width: 100%;\n  max-width: 440px;\n  container-type: inline-size;\n  /* Like the Chat Composer: the white card on a gray tray, the actions live in the tray below */\n  background: var(--ui-sr-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-sr-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-sr *, .ui-sr *::before, .ui-sr *::after { box-sizing: border-box; }\n.ui-sr button { margin: 0; font: inherit; color: inherit; cursor: pointer; }\n.ui-sr button:focus-visible { outline: 2px solid var(--ui-sr-focus); outline-offset: 2px; }\n\n.ui-sr__card {\n  padding: 6px 8px 8px;\n  background: var(--ui-sr-card);\n  border: 1px solid var(--ui-sr-border);\n  border-radius: var(--aui-radius-lg, 16px);\n  box-shadow: var(--ui-sr-shadow);\n}\n.ui-sr__head {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  width: 100%;\n  height: 40px;\n  padding: 0 10px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-lg, 16px) - 4px);\n  background: transparent;\n  text-align: left;\n  transition: background-color 140ms ease;\n}\n.ui-sr__head:hover { background: var(--ui-sr-head-hover); }\n.ui-sr button.ui-sr__head:focus-visible { outline-offset: -2px; }\n.ui-sr__spark { flex: none; color: var(--ui-sr-accent-text); }\n.ui-sr__title { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 15px; font-weight: 500; }\n.ui-sr__pill {\n  flex: none;\n  padding: 1px 8px;\n  border-radius: 999px;\n  background: var(--ui-sr-pill);\n  font-size: 11.5px;\n  font-weight: 500;\n  color: var(--ui-sr-pill-text);\n}\n.ui-sr__chevron { display: grid; margin-left: auto; color: var(--ui-sr-muted); transition: transform 280ms var(--ui-sr-ease); }\n.ui-sr__head[aria-expanded=\"false\"] .ui-sr__chevron { transform: rotate(-90deg); }\n.ui-sr__body {\n  display: grid;\n  grid-template-rows: 0fr;\n  transition: grid-template-rows 340ms var(--ui-sr-ease);\n}\n.ui-sr__body[data-open] { grid-template-rows: 1fr; }\n.ui-sr__reason {\n  min-height: 0;\n  overflow: hidden;\n  margin: 0;\n  padding: 0 10px;\n  font-size: 14px;\n  line-height: 21px;\n  color: var(--ui-sr-text);\n  text-align: start;\n  white-space: pre-line;\n  overflow-wrap: anywhere;\n  animation: ui-sr-swap 360ms var(--ui-sr-ease);\n}\n.ui-sr__body[data-open] .ui-sr__reason { padding-bottom: 8px; }\n.ui-sr__reason strong { font-weight: 500; color: var(--ui-sr-ink); }\n\n.ui-sr__foot {\n  display: flex;\n  align-items: center;\n  /* Narrow containers: the actions move below the signal instead of running out of the tray */\n  flex-wrap: wrap;\n  justify-content: space-between;\n  gap: 8px 10px;\n  min-height: 50px;\n  padding: 8px 8px 8px 16px;\n}\n.ui-sr__signal {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 13px;\n  white-space: nowrap;\n  color: var(--ui-sr-muted);\n}\n.ui-sr__bars, .ui-sr__mini { display: inline-flex; align-items: flex-end; gap: 2px; }\n.ui-sr__bars span, .ui-sr__mini span {\n  width: 3px;\n  border-radius: 1px;\n  background: var(--ui-sr-bar);\n  transition: background-color 260ms ease;\n}\n.ui-sr__bars span:nth-child(1), .ui-sr__mini span:nth-child(1) { height: 6px; }\n.ui-sr__bars span:nth-child(2), .ui-sr__mini span:nth-child(2) { height: 9px; }\n.ui-sr__bars span:nth-child(3), .ui-sr__mini span:nth-child(3) { height: 12px; }\n/* Scoped to the card, so other components with data-level on the page keep their colors */\n.ui-sr [data-level=\"1\"] > span[data-on] { background: var(--aui-amber, #e8b000); }\n.ui-sr [data-level=\"2\"] > span[data-on] { background: var(--ui-sr-accent); }\n.ui-sr [data-level=\"3\"] > span[data-on] { background: var(--ui-sr-ok); }\n.ui-sr__signal[data-level=\"0\"] .ui-sr__bars span:first-child { background: #8f8f8f; }\n\n/* Never wider than the row – a long label shortens in its button instead of running out of the tray */\n.ui-sr__actions { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; margin-left: auto; }\n.ui-sr__alt, .ui-sr__actions > .ui-sr__btn:not(.ui-sr__btn--primary) { flex: none; }\n.ui-sr__btn {\n  height: 32px;\n  padding: 0 12px;\n  border: 0;\n  border-radius: var(--aui-radius, 10px);\n  background: var(--ui-sr-btn);\n  box-shadow: 0 0 0 1px var(--ui-sr-border), 0 1px 2px rgba(0, 0, 0, 0.04);\n  font-size: 13px !important;\n  font-weight: 500;\n  min-width: 0;\n  max-width: 100%;\n  white-space: nowrap;\n  transition: background-color 160ms ease, transform 160ms ease;\n}\n.ui-sr__btn--primary { display: inline-flex; align-items: center; }\n.ui-sr__btn-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n.ui-sr__btn:hover { background: var(--ui-sr-btn-hover); }\n.ui-sr__btn:active { transform: scale(0.98); }\n.ui-sr__btn--primary {\n  background: linear-gradient(#323137, #201e25);\n  box-shadow: 0 0 0 1px #0d0d0d, 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.12);\n  color: #fff !important;\n}\n.ui-sr__btn--primary:hover { background: linear-gradient(#3b3a41, #26242b); }\n.ui-sr__done {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  min-width: 0;\n  padding-right: 4px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--ui-sr-ok);\n  animation: ui-sr-swap 300ms var(--ui-sr-ease);\n}\n\n.ui-sr__alt { position: relative; }\n.ui-sr__menu {\n  position: absolute;\n  bottom: calc(100% + 8px);\n  right: 0;\n  z-index: 5;\n  display: grid;\n  gap: 2px;\n  /* The column never grows with a long name; a long list scrolls */\n  grid-template-columns: minmax(0, 1fr);\n  width: 260px;\n  max-height: min(320px, 60vh);\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  padding: 4px;\n  border-radius: calc(var(--aui-radius-lg, 16px) - 4px);\n  background: var(--ui-sr-menu);\n  box-shadow: var(--aui-shadow-popover, 0 0 0 1px var(--ui-sr-border), 0 12px 30px -8px rgba(0, 0, 0, 0.2));\n  animation: ui-sr-drop 180ms var(--ui-sr-ease);\n}\n.ui-sr__menu[data-flip] { right: auto; left: 0; }\n/* Narrow: the actions sit on their own row – the menu lines up with its right edge so it stays inside */\n@container (max-width: 420px) {\n  .ui-sr__actions { position: relative; }\n  .ui-sr__alt { position: static; }\n  .ui-sr__menu { width: min(260px, 100cqw - 24px); }\n}\n.ui-sr__option {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 10px;\n  min-height: 34px;\n  padding: 0 10px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 1px);\n  background: transparent;\n  font-size: 13px !important;\n  text-align: left;\n}\n.ui-sr__option:hover { background: var(--ui-sr-option-hover); }\n.ui-sr__option[aria-checked=\"true\"] { background: var(--ui-sr-option-on); }\n.ui-sr button.ui-sr__option:focus-visible { outline-offset: -2px; }\n.ui-sr__option-label { display: inline-flex; flex: 0 1 auto; align-items: center; gap: 6px; min-width: 0; }\n.ui-sr__option-name, .ui-sr__done-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n.ui-sr__tag, .ui-sr__mini { flex: none; }\n.ui-sr__tag { padding: 0 6px; border-radius: 999px; background: var(--ui-sr-tag); font-size: 10.5px; font-weight: 500; line-height: 16px; color: var(--ui-sr-accent-text); }\n\n@keyframes ui-sr-swap { from { opacity: 0; transform: translateY(2px); } }\n@keyframes ui-sr-drop { from { opacity: 0; transform: translateY(4px) scale(0.98); } }\n@media (prefers-reduced-motion: reduce) {\n  .ui-sr__reason, .ui-sr__done, .ui-sr__menu { animation: none; }\n  .ui-sr__body, .ui-sr__chevron { 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-sr {\n  --ui-sr-tray: var(--aui-subtle, #161618);\n  --ui-sr-card: var(--aui-surface, #232326);\n  --ui-sr-ink: var(--aui-ink, #ededed);\n  --ui-sr-text: var(--aui-ink, #c4c4c4);\n  --ui-sr-muted: var(--aui-muted, #8f8f8f);\n  --ui-sr-ok: #3fb971;\n  --ui-sr-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  --ui-sr-accent-text: var(--aui-info, #4fb0fe);\n  --ui-sr-border: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-sr-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);\n  --ui-sr-head-hover: var(--aui-subtle, rgba(255, 255, 255, 0.06));\n  --ui-sr-pill: color-mix(in srgb, var(--aui-purple, #b98af0) 18%, transparent);\n  --ui-sr-pill-text: var(--aui-purple, #b98af0);\n  --ui-sr-bar: rgba(255, 255, 255, 0.14);\n  --ui-sr-btn: var(--aui-surface, #2a2a2d);\n  --ui-sr-btn-hover: #313135;\n  --ui-sr-menu: var(--aui-overlay, #2a2a2d);\n  --ui-sr-option-hover: var(--aui-subtle, #313135);\n  --ui-sr-option-on: color-mix(in srgb, var(--aui-blue, #4fb0fe) 16%, transparent);\n  --ui-sr-tag: color-mix(in srgb, var(--aui-blue, #4fb0fe) 16%, transparent);\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-sr__menu {\n  box-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(255, 255, 255, 0.1), 0 12px 30px -8px rgba(0, 0, 0, 0.5));\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-sr__btn--primary {\n  background: linear-gradient(#f4f4f5, #e4e4e7);\n  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 #ffffff;\n  color: #121213 !important;\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-sr__btn--primary:hover { background: linear-gradient(#ffffff, #ececef); }\n`;\n"}]}