{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "auth-button",
  "title": "Auth Button",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "O canto da conta nos três estados. Enquanto a sessão não responde ele não escreve “Entrar”, e não muda de tamanho quando ela chega.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "avatar"
  ],
  "files": [
    {
      "path": "registry/visant/ui/auth-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Check, ChevronDown, LogOut } from \"lucide-react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * AuthButton — o canto da conta: entrar, ou o rosto de quem já entrou.\n *\n * ── O defeito que esta peça existe pra não cometer ──────────────────────────\n *\n * O botão de conta é o primeiro lugar onde todo produto pisca. A causa é sempre\n * a mesma linha:\n *\n *     {user ? <Menu /> : <button>Entrar</button>}\n *\n * `user` é `null` ANTES da sessão responder e `null` DEPOIS de a sessão dizer\n * \"não tem ninguém\". Um booleano não distingue \"ainda não sei\" de \"não\". Então\n * quem já está logado vê \"Entrar\" por 200–600ms — o produto acusando a própria\n * pessoa de ser estranha — e o cabeçalho ainda pula quando o pill de 88px vira\n * um avatar de 36px.\n *\n * As duas decisões que consertam isso, e são o contrato da peça:\n *\n *  1. **`status` é um dos três**, `\"loading\" | \"signed-out\" | \"signed-in\"`, e\n *     não `user | null`. Enquanto for `loading` a peça NÃO ESCREVE PALAVRA\n *     nenhuma sobre o estado da conta. Um esqueleto não pode estar errado; a\n *     palavra \"Entrar\" pode. Quem consome fica obrigado a mapear o estado real\n *     do seu SDK nos três — que é justamente o trabalho que o `??` escondia.\n *  2. **Os três estados ocupam a mesma caixa.** Altura em `--ab-h` e uma\n *     largura mínima reservada em `--ab-w`, valendo para esqueleto, pill e\n *     avatar. Sem isso, não piscar o texto ainda deixaria o layout pular, que é\n *     a metade do defeito que ninguém nomeia.\n *\n * E o `hint`: quem tem um cookie/flag de \"havia sessão aqui\" passa\n * `hint=\"signed-in\"` e o esqueleto já nasce com a forma redonda do avatar em\n * vez da forma do pill. O palpite muda só a FORMA, nunca o texto — se o palpite\n * errar, nada de falso terá sido dito, só um retângulo de largura diferente.\n *\n * ── Fora disso ──────────────────────────────────────────────────────────────\n *\n * Nenhum SDK entra aqui. Sessão chega por prop e as ações saem por callback, e é\n * por isso que a mesma peça serve Clerk, Supabase, Auth.js ou o OAuth da casa.\n * O menu é próprio (nada de dependência nova): um ponto de tabulação, setas\n * andando pelos itens, `Esc` fecha e devolve o foco ao gatilho, clique fora\n * fecha.\n *\n * O painel vai num portal pro `body`, com posição fixa medida do gatilho. Dentro\n * do gatilho ele herdava o `overflow: hidden` de qualquer card, header ou palco\n * em volta e saía cortado ao meio. Se não couber do lado pedido, abre do outro.\n */\n\nexport type AuthStatus = \"loading\" | \"signed-out\" | \"signed-in\";\n\nexport interface AuthAccount {\n  name: string;\n  email?: string;\n  avatarUrl?: string | null;\n}\n\nexport interface AuthProviderOption {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  onSelect: () => void;\n}\n\nexport interface AuthMenuItem {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  onSelect: () => void;\n}\n\nexport interface AuthButtonProps {\n  /** Os três estados de verdade. `loading` nunca desenha texto de conta. */\n  status: AuthStatus;\n  /** Só faz sentido em `signed-in`. */\n  account?: AuthAccount | null;\n  /** Com provedores, o clique abre a lista. Sem eles, chama `onSignIn`. */\n  providers?: AuthProviderOption[];\n  onSignIn?: () => void;\n  onSignOut: () => void;\n  /** Itens extras do menu da conta (perfil, cobrança, equipe…). */\n  items?: AuthMenuItem[];\n  /** Palpite de forma para o esqueleto. Não vira texto em hipótese nenhuma. */\n  hint?: \"signed-in\" | \"signed-out\";\n  align?: \"start\" | \"end\";\n  /**\n   * De que lado do gatilho o painel abre. `\"bottom\"` (padrão) é o canto do\n   * header, que foi onde a peça nasceu.\n   *\n   * `\"top\"` existe porque o botão de conta nem sempre mora no topo: no Visant\n   * Club ele é o rodapé de uma coluna de 240px, e ali o painel de ~14 linhas\n   * abria PRA BAIXO, pra fora do viewport — o menu virava uma lista cortada na\n   * borda da janela, ou pior, empurrava scroll na sidebar. Um `align` não\n   * resolve: aquilo escolhe a borda horizontal.\n   */\n  side?: \"top\" | \"bottom\";\n  labels?: Partial<{\n    signIn: string;\n    signOut: string;\n    loading: string;\n    providers: string;\n  }>;\n  className?: string;\n}\n\nconst PADRAO = {\n  signIn: \"Entrar\",\n  signOut: \"Sair\",\n  loading: \"Verificando a sessão\",\n  providers: \"Entrar com\",\n};\n\nfunction iniciais(nome: string): string {\n  return (\n    nome\n      .trim()\n      .split(/\\s+/)\n      .slice(0, 2)\n      .map((p) => p[0]?.toUpperCase() ?? \"\")\n      .join(\"\") || \"?\"\n  );\n}\n\n/* Painel do gatilho — serve tanto à lista de provedores quanto ao menu da conta.\n   É um só porque as duas caixas têm exatamente o mesmo contrato de teclado, e\n   duplicar isso seria duplicar o lugar onde o foco se perde. */\nfunction Painel({\n  open,\n  onClose,\n  align,\n  side,\n  gatilho,\n  className,\n  children,\n}: {\n  open: boolean;\n  onClose: () => void;\n  align: \"start\" | \"end\";\n  side: \"top\" | \"bottom\";\n  gatilho: React.RefObject<HTMLButtonElement>;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const caixa = React.useRef<HTMLDivElement>(null);\n  const [pos, setPos] = React.useState<React.CSSProperties | null>(null);\n  const [lado, setLado] = React.useState(side);\n\n  // Posição fixa a partir do retângulo de tela do gatilho. Rola ou redimensiona,\n  // mede de novo. Sem espaço do lado pedido e com mais do outro, vira.\n  React.useLayoutEffect(() => {\n    if (!open) {\n      setPos(null);\n      return;\n    }\n    const medir = () => {\n      const g = gatilho.current;\n      if (!g) return;\n      const r = g.getBoundingClientRect();\n      const alto = caixa.current?.offsetHeight ?? 0;\n      // A largura entra na conta: gatilho longe da borda no celular abria o painel para fora da tela.\n      const largura = caixa.current?.offsetWidth ?? 264;\n      const vao = 6;\n      const cabeEmbaixo = window.innerHeight - r.bottom - vao >= alto;\n      const cabeEmCima = r.top - vao >= alto;\n      const l = side === \"bottom\" ? (cabeEmbaixo || !cabeEmCima ? \"bottom\" : \"top\") : cabeEmCima || !cabeEmbaixo ? \"top\" : \"bottom\";\n      setLado(l);\n      setPos({\n        position: \"fixed\",\n        ...(l === \"bottom\" ? { top: r.bottom + vao } : { bottom: window.innerHeight - r.top + vao }),\n        left: Math.max(12, Math.min(align === \"end\" ? r.right - largura : r.left, window.innerWidth - largura - 12)),\n      });\n    };\n    medir();\n    // Segunda medida: na primeira o painel ainda não tinha altura pra decidir o lado.\n    const raf = requestAnimationFrame(medir);\n    window.addEventListener(\"resize\", medir);\n    window.addEventListener(\"scroll\", medir, true);\n    return () => {\n      cancelAnimationFrame(raf);\n      window.removeEventListener(\"resize\", medir);\n      window.removeEventListener(\"scroll\", medir, true);\n    };\n  }, [open, side, align, gatilho]);\n\n  const itens = React.useCallback(\n    () => Array.from(caixa.current?.querySelectorAll<HTMLElement>(\"[data-item]\") ?? []),\n    [],\n  );\n\n  // Foco no primeiro item ao abrir: menu que abre sem foco dentro obriga a\n  // pessoa a caçar o Tab de volta, e no leitor de tela ele simplesmente não\n  // existe.\n  React.useEffect(() => {\n    if (open) itens()[0]?.focus();\n  }, [open, itens]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    const foraDaCaixa = (alvo: Node) =>\n      !caixa.current?.contains(alvo) && !gatilho.current?.contains(alvo);\n    const onDown = (e: PointerEvent) => {\n      if (e.target instanceof Node && foraDaCaixa(e.target)) onClose();\n    };\n    // `pointerdown` e não `click`: no celular o `click` chega tarde demais e a\n    // caixa fica aberta durante o scroll que a pessoa já começou.\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [open, onClose, gatilho]);\n\n  if (!open) return null;\n\n  const andar = (passo: number) => {\n    const lista = itens();\n    if (!lista.length) return;\n    const atual = lista.findIndex((el) => el === document.activeElement);\n    const proximo = (atual + passo + lista.length) % lista.length;\n    lista[proximo]?.focus();\n  };\n\n  if (typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <div\n      ref={caixa}\n      role=\"menu\"\n      onKeyDown={(e) => {\n        switch (e.key) {\n          case \"Escape\":\n            e.preventDefault();\n            onClose();\n            gatilho.current?.focus();\n            break;\n          case \"ArrowDown\":\n            e.preventDefault();\n            andar(1);\n            break;\n          case \"ArrowUp\":\n            e.preventDefault();\n            andar(-1);\n            break;\n          case \"Home\":\n            e.preventDefault();\n            itens()[0]?.focus();\n            break;\n          case \"End\":\n            e.preventDefault();\n            itens().slice(-1)[0]?.focus();\n            break;\n          case \"Tab\":\n            // Sair da caixa com Tab fecha em vez de deixar o foco vagando por\n            // trás de um menu ainda pintado na tela.\n            onClose();\n            break;\n        }\n      }}\n      className={cn(\n        \"z-[1000] w-[16.5rem] max-w-[calc(100vw-1.5rem)] overflow-hidden\",\n        \"rounded-[var(--r-surface,0.875rem)] border border-border bg-popover text-popover-foreground\",\n        \"[box-shadow:var(--e-raised,0_8px_24px_-12px_rgb(0_0_0/0.35))]\",\n        \"animate-in fade-in-0 zoom-in-95 [animation-duration:var(--dur-fast,160ms)]\",\n        // A origem da escala acompanha o lado: painel que abre pra cima e cresce\n        // a partir do topo parece que veio de outro lugar da tela.\n        lado === \"top\"\n          ? align === \"end\"\n            ? \"origin-bottom-right\"\n            : \"origin-bottom-left\"\n          : align === \"end\"\n            ? \"origin-top-right\"\n            : \"origin-top-left\",\n        className,\n      )}\n      // Antes da primeira medida o painel fica invisível no canto, só pra ter altura.\n      style={pos ?? { position: \"fixed\", top: 0, left: 0, visibility: \"hidden\" }}\n    >\n      {children}\n    </div>,\n    document.body,\n  );\n}\n\nconst ITEM =\n  \"flex w-full min-w-0 items-center gap-2.5 px-3 py-2 pointer-coarse:py-3 text-left text-[length:var(--text-corpo,0.8125rem)] text-foreground \" +\n  \"transition-colors [transition-duration:var(--dur-press,120ms)] hover:bg-muted focus-visible:bg-muted focus-visible:outline-none\";\n\nexport function AuthButton({\n  status,\n  account,\n  providers,\n  onSignIn,\n  onSignOut,\n  items,\n  hint = \"signed-out\",\n  align = \"end\",\n  side = \"bottom\",\n  labels,\n  className,\n}: AuthButtonProps) {\n  const t = { ...PADRAO, ...labels };\n  const [aberto, setAberto] = React.useState(false);\n  const gatilho = React.useRef<HTMLButtonElement>(null);\n\n  // Trocar de estado fecha o que estava aberto: sair pelo menu não pode deixar\n  // um menu de conta pairando sobre um botão que já virou \"Entrar\".\n  React.useEffect(() => setAberto(false), [status]);\n\n  const fechar = React.useCallback(() => setAberto(false), []);\n\n  return (\n    <div\n      className={cn(\n        // A caixa comum dos três estados. Reservar largura AQUI é o que impede o\n        // cabeçalho de pular quando a sessão finalmente responde.\n        \"relative inline-block [--ab-h:2.25rem] [--ab-w:6.5rem]\",\n        className,\n      )}\n    >\n      {status === \"loading\" && (\n        /*\n         * Nem \"Entrar\", nem \"Carregando…\", nem o nome de ninguém. Só a forma.\n         * `aria-live=\"polite\"` porque quem usa leitor de tela também merece\n         * saber que há algo em curso — mas em texto invisível, que não ocupa\n         * espaço nem precisa ser desmentido depois.\n         */\n        <div\n          role=\"status\"\n          aria-live=\"polite\"\n          className={cn(\n            \"flex h-[var(--ab-h)] items-center gap-2\",\n            hint === \"signed-in\" ? \"\" : \"w-[var(--ab-w)]\",\n          )}\n        >\n          <span className=\"sr-only\">{t.loading}</span>\n          {hint === \"signed-in\" ? (\n            <>\n              <span className=\"h-[var(--ab-h)] w-[var(--ab-h)] shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n              <span className=\"hidden h-3 w-16 animate-pulse rounded-[var(--r-pill,9999px)] bg-muted motion-reduce:animate-none sm:block\" />\n            </>\n          ) : (\n            <span className=\"h-[var(--ab-h)] w-full animate-pulse rounded-[var(--r-pill,9999px)] bg-muted motion-reduce:animate-none\" />\n          )}\n        </div>\n      )}\n\n      {status === \"signed-out\" && (\n        <button\n          ref={gatilho}\n          type=\"button\"\n          onClick={() => (providers?.length ? setAberto((o) => !o) : onSignIn?.())}\n          aria-haspopup={providers?.length ? \"menu\" : undefined}\n          aria-expanded={providers?.length ? aberto : undefined}\n          className={cn(\n            \"relative flex h-[var(--ab-h)] min-w-[var(--ab-w)] items-center justify-center gap-1.5 px-4 pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5\",\n            \"rounded-[var(--r-pill,9999px)] border border-border bg-card\",\n            \"text-[length:var(--text-corpo,0.8125rem)] font-medium text-foreground\",\n            // A marca entra em BORDA e sombra, nunca no texto: o ciano da casa\n            // dá 1,29:1 sobre branco e some no site do cliente.\n            \"transition-[border-color,box-shadow,transform] [transition-duration:var(--dur-fast,160ms)] ease-[var(--ease-out)]\",\n            \"hover:border-[var(--brand,var(--foreground))] hover:[box-shadow:var(--e-raised)] active:scale-[0.985]\",\n            \"focus-visible:outline-none focus-visible:[box-shadow:var(--focus,0_0_0_2px_var(--ring))]\",\n          )}\n        >\n          {t.signIn}\n          {!!providers?.length && (\n            <ChevronDown\n              className={cn(\n                \"h-3.5 w-3.5 text-muted-foreground transition-transform [transition-duration:var(--dur-fast,160ms)]\",\n                aberto && \"rotate-180\",\n              )}\n            />\n          )}\n        </button>\n      )}\n\n      {status === \"signed-in\" && account && (\n        <button\n          ref={gatilho}\n          type=\"button\"\n          onClick={() => setAberto((o) => !o)}\n          aria-haspopup=\"menu\"\n          aria-expanded={aberto}\n          aria-label={`Conta de ${account.name}`}\n          className={cn(\n            \"relative flex h-[var(--ab-h)] min-w-[var(--ab-h)] max-w-[13rem] items-center gap-2 rounded-[var(--r-pill,9999px)] pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5\",\n            \"border border-transparent bg-transparent pl-0 pr-0 sm:border-border sm:bg-card sm:pl-1 sm:pr-2.5\",\n            \"transition-[border-color,box-shadow] [transition-duration:var(--dur-fast,160ms)] ease-[var(--ease-out)]\",\n            \"hover:border-[var(--brand,var(--border))] hover:[box-shadow:var(--e-raised)]\",\n            \"focus-visible:outline-none focus-visible:[box-shadow:var(--focus,0_0_0_2px_var(--ring))]\",\n          )}\n        >\n          <Avatar className=\"h-[calc(var(--ab-h)-0.5rem)] w-[calc(var(--ab-h)-0.5rem)] shrink-0 ring-1 ring-border\">\n            {account.avatarUrl && <AvatarImage src={account.avatarUrl} alt=\"\" />}\n            <AvatarFallback className=\"bg-muted text-[length:var(--text-rotulo,0.6875rem)] font-semibold text-foreground\">\n              {iniciais(account.name)}\n            </AvatarFallback>\n          </Avatar>\n          {/* O nome some no celular por largura, não por capricho — o avatar\n              continua sendo o alvo de 36px que o dedo precisa. */}\n          <span className=\"hidden min-w-0 truncate text-[length:var(--text-corpo,0.8125rem)] font-medium text-foreground sm:block\">\n            {account.name}\n          </span>\n          <ChevronDown\n            className={cn(\n              \"hidden h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [transition-duration:var(--dur-fast,160ms)] sm:block\",\n              aberto && \"rotate-180\",\n            )}\n          />\n        </button>\n      )}\n\n      {status === \"signed-out\" && !!providers?.length && (\n        <Painel open={aberto} onClose={fechar} align={align} side={side} gatilho={gatilho}>\n          <p className=\"px-3 pb-1 pt-2.5 text-[length:var(--text-rotulo,0.6875rem)] uppercase tracking-[0.08em] text-muted-foreground\">\n            {t.providers}\n          </p>\n          {providers.map((p) => (\n            <button\n              key={p.id}\n              data-item\n              role=\"menuitem\"\n              tabIndex={-1}\n              type=\"button\"\n              className={ITEM}\n              onClick={() => {\n                fechar();\n                p.onSelect();\n              }}\n            >\n              <span className=\"grid h-5 w-5 shrink-0 place-items-center text-muted-foreground\">\n                {p.icon}\n              </span>\n              <span className=\"min-w-0 truncate\">{p.label}</span>\n            </button>\n          ))}\n          <div className=\"h-1\" />\n        </Painel>\n      )}\n\n      {status === \"signed-in\" && account && (\n        <Painel open={aberto} onClose={fechar} align={align} side={side} gatilho={gatilho}>\n          {/* Quem está logado, escrito por extenso. O avatar sozinho é ambíguo\n              em qualquer produto com duas contas na mesma máquina. */}\n          <div className=\"flex min-w-0 items-center gap-2.5 border-b border-border px-3 py-2.5\">\n            <Avatar className=\"h-8 w-8 shrink-0 ring-1 ring-border\">\n              {account.avatarUrl && <AvatarImage src={account.avatarUrl} alt=\"\" />}\n              <AvatarFallback className=\"bg-muted text-[length:var(--text-rotulo,0.6875rem)] font-semibold text-foreground\">\n                {iniciais(account.name)}\n              </AvatarFallback>\n            </Avatar>\n            <span className=\"flex min-w-0 flex-col leading-tight\">\n              <span className=\"truncate text-[length:var(--text-corpo,0.8125rem)] font-medium text-foreground\">\n                {account.name}\n              </span>\n              {account.email && (\n                <span className=\"truncate text-[length:var(--text-apoio,0.75rem)] text-muted-foreground\">\n                  {account.email}\n                </span>\n              )}\n            </span>\n          </div>\n\n          {items?.map((it) => (\n            <button\n              key={it.id}\n              data-item\n              role=\"menuitem\"\n              tabIndex={-1}\n              type=\"button\"\n              className={ITEM}\n              onClick={() => {\n                fechar();\n                it.onSelect();\n              }}\n            >\n              <span className=\"grid h-4 w-4 shrink-0 place-items-center text-muted-foreground\">\n                {it.icon}\n              </span>\n              <span className=\"min-w-0 truncate\">{it.label}</span>\n            </button>\n          ))}\n\n          <div className=\"my-1 h-px bg-border\" />\n          <button\n            data-item\n            role=\"menuitem\"\n            tabIndex={-1}\n            type=\"button\"\n            className={cn(ITEM, \"mb-1\")}\n            onClick={() => {\n              fechar();\n              onSignOut();\n            }}\n          >\n            <LogOut className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n            <span className=\"min-w-0 truncate\">{t.signOut}</span>\n          </button>\n        </Painel>\n      )}\n    </div>\n  );\n}\n\n/**\n * Selo de \"entrou\" — opcional, para quem quer confirmar a entrada sem toast.\n * Fica aqui e não numa peça própria porque só existe em função deste botão.\n */\nexport function AuthBadge({ children, className }: { children: React.ReactNode; className?: string }) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-1 rounded-[var(--r-pill,9999px)] border border-border px-2 py-0.5\",\n        \"text-[length:var(--text-rotulo,0.6875rem)] text-muted-foreground\",\n        className,\n      )}\n    >\n      <Check className=\"h-3 w-3 text-emerald-600 dark:text-emerald-400\" />\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Canto da conta no cabeçalho do app: entrar, carregando e avatar com menu, sem pular de tamanho",
      "evite": "Tela de cadastro ou edição dos dados da conta: profile-form"
    }
  },
  "categories": [
    "controles",
    "conta",
    "sessao",
    "menu"
  ],
  "type": "registry:ui"
}