{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "assistant-welcome",
  "title": "Assistant Welcome",
  "description": "A first-run assistant hero — greeting, capability cards and starter chips that prefill the host's composer instead of sending, over a token-only animated accent.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/assistant-welcome.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight, Check } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface AssistantCapability {\n  /**\n   * Unique inside `capabilities` only — the pick echo is keyed by\n   * `capability:<id>`, so a starter chip may reuse the same id without the two\n   * lighting up together.\n   */\n  id: string\n  title: React.ReactNode\n  /** The one-line example printed under the title. */\n  example: string\n  /**\n   * What actually goes into the composer, when the readable example is shorter\n   * than the prompt you want to seed. Defaults to `example`.\n   */\n  prompt?: string\n  /** Any node; the chip normalises a raw lucide glyph to 1rem so callers don't have to size it. */\n  icon?: React.ReactNode\n}\n\nexport interface AssistantStarter {\n  /** Unique inside `starters` only — echoes are keyed by `starter:<id>`. */\n  id: string\n  text: string\n  /** Composer text when it differs from the visible chip label. Defaults to `text`. */\n  prompt?: string\n}\n\nexport interface AssistantWelcomePick {\n  /** Which list the pick came from — lets one handler tell a card from a chip. */\n  source: \"capability\" | \"starter\"\n  id: string\n  /** Exactly the string to drop into the composer. Already resolved from `prompt ?? example | text`. */\n  text: string\n}\n\ntype Density = \"comfortable\" | \"compact\"\n\nconst DENSITY = {\n  comfortable: {\n    frame: \"gap-9 px-6 py-14\",\n    greeting: \"text-3xl @2xl/welcome:text-4xl\",\n    subtitle: \"text-sm @2xl/welcome:text-base\",\n    group: \"gap-3\",\n    grid: \"gap-3 grid-cols-[repeat(auto-fit,minmax(min(15rem,100%),1fr))]\",\n    card: \"gap-2.5 p-4\",\n    chip: \"gap-1.5 px-3 py-1.5 text-sm\",\n  },\n  compact: {\n    frame: \"gap-6 px-4 py-8\",\n    greeting: \"text-2xl @2xl/welcome:text-3xl\",\n    subtitle: \"text-sm\",\n    group: \"gap-2\",\n    grid: \"gap-2 grid-cols-[repeat(auto-fit,minmax(min(13rem,100%),1fr))]\",\n    card: \"gap-2 p-3\",\n    chip: \"gap-1.5 px-2.5 py-1 text-xs\",\n  },\n} as const satisfies Record<Density, Record<string, string>>\n\n/**\n * Decoration only. Every colour is a chart token, so re-theming the host\n * re-tints the accent for free and dark mode needs no second rule. Blur and the\n * fixed opacity live on the wrapper; the animation lives on the inner node —\n * `animate-pulse` drives opacity 1 → .5, and stacking that under a fixed\n * `opacity-*` wrapper is what keeps the breath subtle instead of a strobe.\n */\nconst ACCENT_BLOBS = [\n  { token: \"--chart-1\", box: \"-top-20 left-[6%] size-64 opacity-20 @2xl/welcome:size-80\", duration: \"7s\", delay: \"0s\" },\n  { token: \"--chart-4\", box: \"-top-28 right-[4%] size-56 opacity-20 @2xl/welcome:size-72\", duration: \"11s\", delay: \"1.4s\" },\n  {\n    token: \"--chart-2\",\n    box: \"top-24 left-1/2 size-52 -translate-x-1/2 opacity-10 @2xl/welcome:size-64\",\n    duration: \"9s\",\n    delay: \"2.8s\",\n  },\n] as const\n\nfunction AccentField() {\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n      {ACCENT_BLOBS.map(blob => (\n        <div className={cn(\"absolute blur-3xl\", blob.box)} key={blob.token}>\n          {/* Duration/delay are inline so they beat the `animate-pulse` shorthand\n              in the cascade; `motion-reduce:animate-none` drops the keyframes\n              altogether, leaving the same gradient perfectly still. */}\n          <div\n            className=\"size-full animate-pulse rounded-full motion-reduce:animate-none\"\n            style={{\n              animationDelay: blob.delay,\n              animationDuration: blob.duration,\n              background: `var(${blob.token})`,\n            }}\n          />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nexport interface AssistantWelcomeProps extends React.ComponentPropsWithoutRef<\"section\"> {\n  /**\n   * The headline. Time of day, the user's first name, the workspace — resolve\n   * all of it outside; this block never guesses who it is talking to.\n   */\n  greeting: React.ReactNode\n  /** One supporting line under the greeting. */\n  subtitle?: React.ReactNode\n  /** Static pill above the greeting (model name, workspace, \"beta\"). Never clickable. */\n  eyebrow?: React.ReactNode\n  /** Cards. An empty array renders nothing at all — no label, no gap. */\n  capabilities?: AssistantCapability[]\n  /** Chips under the cards. An empty array renders nothing at all. */\n  starters?: AssistantStarter[]\n  /**\n   * **Prefill**, not send: the host drops `pick.text` into its composer and the\n   * reader can still edit or discard it. Omit the callback and both lists\n   * degrade to static copy — no hover, no pointer cursor, no button role —\n   * because a card that looks clickable and does nothing is the worse bug.\n   */\n  onPick?: (pick: AssistantWelcomePick) => void\n  /** Bottom slot: the consumer's composer. This block owns none of its state. */\n  children?: React.ReactNode\n  /** Small print under the composer (model disclaimer, shortcut hint). */\n  footnote?: React.ReactNode\n  /** Eyebrow above the card grid; `\"\"` hides it and the list falls back to an aria-label. */\n  capabilitiesLabel?: React.ReactNode\n  /** Eyebrow above the chip row; `\"\"` hides it and the list falls back to an aria-label. */\n  startersLabel?: React.ReactNode\n  /** `compact` shrinks paddings, type scale and grid track width — same anatomy. */\n  density?: Density\n  /** Animated token gradient behind the headline. Decorative; safe to switch off. */\n  accent?: boolean\n  /**\n   * The session is not ready (connecting, restoring, out of quota). Picks are\n   * refused, both lists read as disabled, and the composer slot still renders —\n   * whatever is inside it is the host's business.\n   */\n  disabled?: boolean\n  /** `1` when the block owns the page, `2` when it sits inside an existing document outline. */\n  headingLevel?: 1 | 2\n  /** How long the \"added to the composer\" mark stays on an item, ms. Clamped 600–10000. */\n  echoDuration?: number\n}\n\ntype Echo = { key: string; text: string }\n\nexport const AssistantWelcome = React.forwardRef<HTMLElement, AssistantWelcomeProps>(\n  (\n    {\n      greeting,\n      subtitle,\n      eyebrow,\n      capabilities = [],\n      starters = [],\n      onPick,\n      children,\n      footnote,\n      capabilitiesLabel = \"What I can help with\",\n      startersLabel = \"Start with\",\n      density = \"comfortable\",\n      accent = true,\n      disabled = false,\n      headingLevel = 1,\n      echoDuration = 2400,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const uid = React.useId()\n    const headingId = `${uid}-greeting`\n    const capsId = `${uid}-caps`\n    const startersId = `${uid}-starters`\n\n    /**\n     * Which item was just handed to the composer. A *transient* mark, never a\n     * latch: prefilling is reversible (the reader can wipe the draft), so the\n     * same card has to stay pickable. Compare a click-to-send strip, where the\n     * first pick is irreversible and the whole group must lock for good.\n     */\n    const [echo, setEcho] = React.useState<Echo | null>(null)\n\n    const echoMs = Math.min(10000, Math.max(600, Number.isFinite(echoDuration) ? echoDuration : 2400))\n\n    // A fresh object per pick, on purpose: picking the *same* card twice still\n    // changes state identity, so this effect tears the old timer down and starts\n    // a new one instead of letting the first one cut the second mark short.\n    React.useEffect(() => {\n      if (echo === null) return\n      const timer = window.setTimeout(() => setEcho(null), echoMs)\n      return () => window.clearTimeout(timer)\n    }, [echo, echoMs])\n\n    // Derived, not a second effect: going disabled hides the mark on the next\n    // paint, and the pending timer above still clears the state on its own.\n    const activeEcho = disabled ? null : echo\n\n    const interactive = Boolean(onPick)\n    const d = DENSITY[density]\n    const Heading = headingLevel === 2 ? \"h2\" : \"h1\"\n\n    const pick = (source: AssistantWelcomePick[\"source\"], id: string, text: string) => {\n      // The guard is the real gate — the buttons carry `aria-disabled` rather\n      // than the native attribute, so the click still arrives here and is\n      // refused, and the item the reader just tabbed to keeps its focus.\n      if (!onPick || disabled) return\n      setEcho({ key: `${source}:${id}`, text })\n      onPick({ id, source, text })\n    }\n\n    const showCaps = capabilities.length > 0\n    const showStarters = starters.length > 0\n    const showComposer = Boolean(children) || Boolean(footnote)\n\n    return (\n      <section\n        aria-labelledby={headingId}\n        className={cn(\"@container/welcome relative isolate w-full\", className)}\n        ref={ref}\n        {...props}\n      >\n        {accent && <AccentField />}\n\n        {/* `relative`, not a z-index: positioned siblings paint in DOM order, so\n            the content sits above the accent without opening a negative layer\n            that would slide under the host's own background. */}\n        <div className={cn(\"relative mx-auto flex w-full max-w-3xl flex-col\", d.frame)}>\n          <header className=\"flex flex-col items-center gap-3 text-center\">\n            {eyebrow && (\n              <span className=\"inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-full border bg-card/70 px-3 py-1 text-xs font-medium wrap-anywhere text-muted-foreground backdrop-blur\">\n                {eyebrow}\n              </span>\n            )}\n\n            {/* w-full + wrap-anywhere: in a centred column a long unbreakable\n                token (a pasted URL sitting in the user's display name) would\n                otherwise size the heading to its min-content and push the whole\n                block wider than the viewport. */}\n            <Heading\n              className={cn(\"w-full font-semibold tracking-tight text-balance wrap-anywhere\", d.greeting)}\n              id={headingId}\n            >\n              {greeting}\n            </Heading>\n\n            {subtitle && <p className={cn(\"max-w-xl text-pretty text-muted-foreground\", d.subtitle)}>{subtitle}</p>}\n          </header>\n\n          {showCaps && (\n            <div className={cn(\"flex flex-col\", d.group)}>\n              {capabilitiesLabel ? (\n                <p className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\" id={capsId}>\n                  {capabilitiesLabel}\n                </p>\n              ) : null}\n\n              {/* auto-fit tracks, not a fixed column count: three capabilities\n                  fill the row and seven wrap, without the block hardcoding how\n                  many the host ships. */}\n              <ul\n                aria-label={capabilitiesLabel ? undefined : \"Things the assistant can help with\"}\n                aria-labelledby={capabilitiesLabel ? capsId : undefined}\n                className={cn(\"grid\", d.grid)}\n              >\n                {capabilities.map(item => {\n                  const marked = activeEcho?.key === `capability:${item.id}`\n                  const body = (\n                    <>\n                      <span className=\"flex w-full items-center gap-2\">\n                        {item.icon && (\n                          <span\n                            aria-hidden=\"true\"\n                            className={cn(\n                              \"flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\",\n                              \"[&_svg]:size-4 [&_svg]:shrink-0\",\n                              marked && \"bg-primary/10 text-primary\",\n                            )}\n                          >\n                            {item.icon}\n                          </span>\n                        )}\n                        <span className=\"min-w-0 flex-1 text-sm font-medium wrap-anywhere\">{item.title}</span>\n                        {interactive &&\n                          (marked ? (\n                            <Check aria-hidden=\"true\" className=\"size-4 shrink-0 text-primary\" />\n                          ) : (\n                            // Always mounted at opacity 0, so revealing it on\n                            // hover never reflows the title row.\n                            <ArrowUpRight\n                              aria-hidden=\"true\"\n                              className=\"size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cap:opacity-100 group-focus-visible/cap:opacity-100 motion-reduce:transition-none\"\n                            />\n                          ))}\n                      </span>\n                      <span className=\"text-sm text-muted-foreground wrap-anywhere\">{item.example}</span>\n                      {marked && <span className=\"sr-only\">Added to the composer.</span>}\n                    </>\n                  )\n\n                  return (\n                    <li className=\"min-w-0\" key={item.id}>\n                      {interactive ? (\n                        <button\n                          aria-disabled={disabled || undefined}\n                          className={cn(\n                            \"group/cap flex h-full w-full cursor-pointer flex-col items-start rounded-xl border bg-card text-left\",\n                            \"transition-colors hover:border-primary/50 hover:bg-accent motion-reduce:transition-none\",\n                            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                            \"aria-disabled:cursor-not-allowed aria-disabled:opacity-55 aria-disabled:hover:border-border aria-disabled:hover:bg-card\",\n                            marked && \"border-primary bg-primary/5\",\n                            d.card,\n                          )}\n                          onClick={() => pick(\"capability\", item.id, item.prompt ?? item.example)}\n                          type=\"button\"\n                        >\n                          {body}\n                        </button>\n                      ) : (\n                        <div\n                          className={cn(\n                            \"flex h-full w-full flex-col items-start rounded-xl border bg-card text-left\",\n                            d.card,\n                          )}\n                        >\n                          {body}\n                        </div>\n                      )}\n                    </li>\n                  )\n                })}\n              </ul>\n            </div>\n          )}\n\n          {showStarters && (\n            <div className={cn(\"flex flex-col\", d.group)}>\n              {startersLabel ? (\n                <p className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\" id={startersId}>\n                  {startersLabel}\n                </p>\n              ) : null}\n\n              {/* items-start: a two-word chip must not inherit the height of a\n                  three-line neighbour as dead space. */}\n              <ul\n                aria-label={startersLabel ? undefined : \"Starter prompts\"}\n                aria-labelledby={startersLabel ? startersId : undefined}\n                className=\"flex flex-wrap items-start gap-2\"\n              >\n                {starters.map(item => {\n                  const marked = activeEcho?.key === `starter:${item.id}`\n\n                  return (\n                    <li className=\"max-w-full min-w-0\" key={item.id}>\n                      {interactive ? (\n                        <button\n                          aria-disabled={disabled || undefined}\n                          className={cn(\n                            \"inline-flex max-w-full cursor-pointer items-start rounded-full border bg-card text-left\",\n                            \"transition-colors hover:border-primary/50 hover:bg-accent motion-reduce:transition-none\",\n                            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                            \"aria-disabled:cursor-not-allowed aria-disabled:opacity-55 aria-disabled:hover:border-border aria-disabled:hover:bg-card\",\n                            marked && \"border-primary bg-primary/5\",\n                            d.chip,\n                          )}\n                          onClick={() => pick(\"starter\", item.id, item.prompt ?? item.text)}\n                          type=\"button\"\n                        >\n                          {marked && <Check aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0 text-primary\" />}\n                          <span className=\"wrap-anywhere\">{item.text}</span>\n                          {marked && <span className=\"sr-only\">Added to the composer.</span>}\n                        </button>\n                      ) : (\n                        <span\n                          className={cn(\n                            \"inline-flex max-w-full items-start rounded-full border bg-card text-left text-muted-foreground\",\n                            d.chip,\n                          )}\n                        >\n                          <span className=\"wrap-anywhere\">{item.text}</span>\n                        </span>\n                      )}\n                    </li>\n                  )\n                })}\n              </ul>\n            </div>\n          )}\n\n          {showComposer && (\n            <div className=\"flex w-full flex-col gap-2\">\n              {children}\n              {footnote && <p className=\"text-center text-xs text-balance text-muted-foreground\">{footnote}</p>}\n            </div>\n          )}\n        </div>\n\n        {/* Polite, always mounted, and never wrapped around the cards: the mark\n            is a visual courtesy, this is the same courtesy for a screen reader,\n            and neither may steal focus from the composer the host just filled. */}\n        <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n          {activeEcho ? `Added to the composer: ${activeEcho.text}` : \"\"}\n        </span>\n      </section>\n    )\n  },\n)\n\nAssistantWelcome.displayName = \"AssistantWelcome\"\n\nexport default AssistantWelcome\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}