{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "skeleton-rail",
  "title": "Skeleton Rail",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Esqueletos com as medidas do que substituem (trilho, card, lista, texto, mídia), em quatro movimentos: pulse, shimmer, wave ou parado.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/skeleton-rail.tsx",
      "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Skeleton Rail — os esqueletos da casa, com as MESMAS medidas do que substituem.\n *\n * ── Por que o item guarda cinco formatos e continua se chamando `skeleton-rail`\n *\n * O trilho era o único formato publicado, e um esqueleto só cobre o dia em que\n * a tela que carrega é um trilho. Toda tela real espera outras quatro coisas:\n * um bloco de texto, uma ficha de mídia, uma lista de linhas e um card solto.\n * Quem instalou o item e precisou de uma dessas escreveu `h-4 w-full bg-muted`\n * à mão — ou seja, exatamente a caixa duplicada que o item existe pra evitar,\n * e agora sem a única disciplina que importa aqui: bater a medida do original.\n *\n * O nome **não** muda. `skeleton-rail` já está instalado em projeto de cliente,\n * e renomear o item é apagar o arquivo de quem fez `add` mês passado. O nome\n * envelheceu; quebrar quem confiou nele é pior.\n *\n * Os cinco: `SkeletonRail` (o trilho), `SkeletonCard`, `SkeletonList`,\n * `SkeletonText` e `SkeletonMedia`. Todos falam a mesma língua de prop e todos\n * são montados sobre o mesmo bloco, então o pulso, a defasagem e o\n * comportamento sob `prefers-reduced-motion` são um só comportamento.\n *\n * ── A única coisa que um skeleton precisa acertar ──────────────────────────\n *\n * Ele existe pra evitar o salto de layout. Se as medidas dele divergirem do\n * componente real, ele deixa de evitar o salto e passa a **causar** um — que é\n * pior que não ter skeleton nenhum, porque agora o salto acontece depois de a\n * pessoa já ter começado a ler.\n *\n * ── E era exatamente isso que este arquivo fazia ───────────────────────────\n *\n * Medido no navegador, com os mesmos props que a sala \"Mesa de trabalho\" passa\n * pros dois lados (`itemWidth={170} gap={14}`, cinco cards):\n *\n * | container | card do trilho | card do esqueleto | Δ largura | Δ altura |\n * |---|---|---|---|---|\n * | 600px  | 170px   | 170px | 0     | **51px** |\n * | 900px  | 170px   | 170px | 0     | **51px** |\n * | 1200px | 228,8px | 170px | 58,8  | **80px** |\n * | 1600px | 308,8px | 170px | 138,8 | **140px** |\n *\n * Duas causas, as duas de estrutura e nenhuma de estilo:\n *\n * 1. **`flex` + `shrink-0` + largura fixa contra `grid` + `minmax(w, 1fr)`.** O\n *    trilho ESTICA os itens quando sobra largura no container; o esqueleto\n *    mantinha os 170px crus. Até caber justo os dois coincidem por acidente, e a\n *    partir daí divergem — e a divergência CRESCE com a tela, que é o pior tipo\n *    de bug: some no notebook de quem desenvolve e explode no monitor do cliente.\n * 2. **A barra de título não representava nada.** O trilho não tem título: ele\n *    tem uma fileira de controle com dois botões de 40px, `mb-3` embaixo, e\n *    `pb-3` no scroller. O esqueleto desenhava uma barra de 16px e nenhum `pb`.\n *    Por isso a altura errava 51px **mesmo quando a largura acertava**.\n *\n * A regra que fica: esqueleto não se desenha \"parecido\", se desenha com o mesmo\n * sistema de layout do original. Aqui é o mesmo `grid grid-flow-col` com o mesmo\n * `gridAutoColumns`, copiado de propósito e não por coincidência.\n *\n * ── As decisões pequenas que continuam valendo ─────────────────────────────\n *\n * · **Skeleton não rola de lado** — oferecer navegação por cima de nada é uma\n *   porta falsa. Isso era feito com `overflow-hidden`, e custava os últimos 15px\n *   de erro: quando a fileira transborda, o trilho real reserva a calha da barra\n *   de rolagem (`overflow-x-auto` + `scrollbar-width: thin`) e o esqueleto não\n *   reservava nada. A porta continua fechada, agora por `pointer-events-none`\n *   num scroller sem nada focável dentro — ninguém rola, e a calha existe nas\n *   mesmas larguras em que o trilho tem uma. Fechar a porta pelo comportamento,\n *   e não pela caixa, é o que deixa a medida bater.\n * · O bloco é `--muted`, e só. Nada de gradiente varrendo: shimmer é movimento\n *   contínuo numa tela onde a pessoa já está esperando.\n * · O `animate-pulse` **não** sobrevive a `prefers-reduced-motion` — o guard da\n *   casa zera `animation-duration` pra tudo. O comentário antigo aqui afirmava o\n *   contrário. Está certo assim: quem pediu menos movimento recebe o esqueleto\n *   parado, que continua dizendo o formato do que vem. O que não pode é a peça\n *   AFIRMAR uma coisa que o CSS do projeto desmente.\n *\n * O número de cards default é 5 porque é quanto cabe numa faixa larga: menos\n * deixa a linha visivelmente curta e denuncia que aquilo não é o conteúdo.\n */\n\n// Ciclo de espera em laço, não transição: mais curto que isto vira pisca-pisca.\nconst CICLO_ONDA_MS = 1400;\nconst CICLO_BRILHO_MS = 1500;\n\n/* ── o movimento da espera ─────────────────────────────────────────────── */\n\n/**\n * Quatro jeitos de o bloco dizer \"ainda não chegou\".\n *\n * | `motion` | o que faz | quando |\n * |---|---|---|\n * | `pulse` | opacidade respira, quase em uníssono | o default: o mais quieto que ainda se mexe |\n * | `shimmer` | uma faixa de luz atravessa cada bloco | tela de produto, onde a espera é curta |\n * | `wave` | a respiração desce em cascata, item a item | lista e trilho: dá direção à chegada |\n * | `none` | parado | tela densa, ou quem vai animar por fora |\n *\n * O movimento entra por contexto: `motion` na moldura vale pra todos os blocos\n * de dentro, e um `SkeletonCard` dentro do `SkeletonRail` herda o do trilho sem\n * precisar repetir a prop. `shimmer` e `wave` são Web Animations API, sem bloco\n * `css` à parte, e as duas param sob `prefers-reduced-motion`. O `pulse` é o\n * `animate-pulse` do Tailwind, que o guard da casa já para.\n */\nexport type SkeletonMotion = \"pulse\" | \"shimmer\" | \"wave\" | \"none\";\n\nconst MovimentoCtx = React.createContext<SkeletonMotion>(\"pulse\");\n\nfunction movimentoReduzido() {\n  return typeof window !== \"undefined\" && !!window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n/* ── as duas peças que os cinco formatos compartilham ─────────────────────── */\n\n/**\n * O bloco. Tudo aqui é feito dele.\n *\n * `delay` em vez de `animationDelay` solto: a defasagem é a única coisa que dá\n * direção à espera (ver a nota no fim), e concentrar a regra num lugar impede\n * que um formato novo esqueça dela e pulse fora do compasso dos outros.\n */\nfunction Bloco({\n  delay = 0,\n  className,\n  style,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { delay?: number }) {\n  const movimento = React.useContext(MovimentoCtx);\n  const corpo = React.useRef<HTMLDivElement>(null);\n  const faixa = React.useRef<HTMLSpanElement>(null);\n\n  React.useEffect(() => {\n    if (movimentoReduzido()) return;\n    if (movimento === \"wave\") {\n      const el = corpo.current;\n      if (!el?.animate) return;\n      // Defasagem maior que a do pulse: é ela que faz a respiração descer.\n      const a = el.animate([{ opacity: 1 }, { opacity: 0.35, offset: 0.5 }, { opacity: 1 }], {\n        duration: CICLO_ONDA_MS,\n        delay: delay * 2,\n        iterations: Infinity,\n        easing: \"ease-in-out\",\n      });\n      return () => a.cancel();\n    }\n    if (movimento === \"shimmer\") {\n      const el = faixa.current;\n      if (!el?.animate) return;\n      const a = el.animate([{ transform: \"translateX(-100%)\" }, { transform: \"translateX(100%)\" }], {\n        duration: CICLO_BRILHO_MS,\n        delay,\n        iterations: Infinity,\n        easing: \"ease-in-out\",\n      });\n      return () => a.cancel();\n    }\n  }, [movimento, delay]);\n\n  return (\n    <div\n      ref={corpo}\n      className={cn(\n        \"rounded bg-muted\",\n        movimento === \"pulse\" && \"animate-pulse\",\n        movimento === \"shimmer\" && \"relative overflow-hidden\",\n        className,\n      )}\n      style={{ animationDelay: movimento === \"pulse\" ? `${delay}ms` : undefined, ...style }}\n      {...props}\n    >\n      {movimento === \"shimmer\" ? (\n        <span\n          ref={faixa}\n          aria-hidden=\"true\"\n          className=\"absolute inset-0\"\n          style={{\n            transform: \"translateX(-100%)\",\n            background:\n              \"linear-gradient(90deg, transparent, color-mix(in oklab, var(--foreground) 12%, transparent), transparent)\",\n          }}\n        />\n      ) : null}\n    </div>\n  );\n}\n\n/**\n * A moldura que anuncia a espera — uma vez, e não uma por bloco.\n *\n * `label={false}` existe pro caso de a tela ter VÁRIOS esqueletos juntos: ali o\n * anúncio pertence ao container que embrulha todos, e cinco `role=\"status\"`\n * empilhados fariam o leitor de tela recitar cinco vezes que algo está\n * carregando. Um esqueleto solto, que é o caso comum, anuncia sozinho.\n *\n * `motion` ausente herda o da moldura de fora; presente, vale daqui pra dentro.\n */\nfunction Moldura({\n  label,\n  motion,\n  className,\n  children,\n  ...props\n}: React.HTMLAttributes<HTMLElement> & { label: string | false; motion?: SkeletonMotion }) {\n  const corpo = (\n    <section\n      {...(label ? { role: \"status\", \"aria-busy\": true } : { \"aria-hidden\": true })}\n      className={cn(\"min-w-0\", className)}\n      {...props}\n    >\n      {label ? <span className=\"sr-only\">{label}</span> : null}\n      {children}\n    </section>\n  );\n  return motion ? <MovimentoCtx.Provider value={motion}>{corpo}</MovimentoCtx.Provider> : corpo;\n}\n\nexport interface SkeletonRailProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Como o bloco espera: `pulse` (default), `shimmer`, `wave` ou `none`. Ver a tabela no topo. */\n  motion?: SkeletonMotion;\n  /** Quantos cards fantasma. Default 5. */\n  cards?: number;\n  /** Os mesmos valores do `@visant/rail` que este esqueleto representa. */\n  itemWidth?: number;\n  gap?: number;\n  /** Proporção da arte, ex. `\"4 / 3\"`. */\n  ratio?: string;\n  /**\n   * Reserva a fileira de controle que o `@visant/rail` desenha acima do trilho\n   * (as duas setas, a partir de `lg`). Antes isto desenhava uma barra de título\n   * — que o trilho não tem — e era metade do salto de altura.\n   */\n  heading?: boolean;\n  /**\n   * O que o leitor de tela ouve enquanto a lista não chegou. `false` cala a\n   * peça — pra quando quem anuncia é o container que embrulha vários.\n   *\n   * `role=\"status\"` no lugar de um `aria-hidden` seco: escondido de todo mundo,\n   * o esqueleto deixava quem usa leitor de tela sem NENHUM sinal entre o clique\n   * e a lista chegar — só silêncio, que se lê como travou. Os blocos visuais\n   * seguem escondidos, porque eles são forma, não conteúdo.\n   */\n  label?: string | false;\n}\n\nexport function SkeletonRail({\n  cards = 5,\n  itemWidth = 240,\n  gap = 20,\n  ratio = \"4 / 3\",\n  heading = true,\n  label = \"Carregando a lista\",\n  motion,\n  className,\n  ...props\n}: SkeletonRailProps) {\n  return (\n    <Moldura\n      motion={motion}\n      label={label}\n      className={className}\n      {...(props as React.HTMLAttributes<HTMLElement>)}\n    >\n      {heading && (\n        // Espelha `rail.tsx`: `mb-3 hidden justify-end gap-2 lg:flex` com dois\n        // alvos de 40px. Mesmas classes, de propósito — é o que garante que a\n        // troca não mexa um pixel quando o trilho entrar no lugar.\n        <div aria-hidden=\"true\" className=\"mb-3 hidden justify-end gap-2 lg:flex\">\n          <Bloco className=\"size-10\" />\n          <Bloco className=\"size-10\" />\n        </div>\n      )}\n\n      <div\n        aria-hidden=\"true\"\n        // Cópia literal do scroller do trilho — `grid grid-flow-col`,\n        // `gridAutoColumns`, `pb-3`, `overflow-x-auto`, `scrollbar-width: thin`\n        // — porque é a cópia literal que faz a medida bater em toda largura. O\n        // `pointer-events-none` é o que mantém a porta fechada. Ver a nota.\n        className=\"pointer-events-none grid grid-flow-col overflow-x-auto pb-3 [scrollbar-width:thin]\"\n        style={{\n          gridAutoColumns: `minmax(${itemWidth}px, 1fr)`,\n          gap: `${gap}px`,\n          scrollbarColor: \"var(--border) transparent\",\n        }}\n      >\n        {Array.from({ length: cards }).map((_, i) => (\n          /*\n           * 90ms de defasagem por card: a fileira inteira pulsando no mesmo\n           * quadro lê como um bloco só piscando, e não como uma lista chegando.\n           * O atraso é o que dá direção à espera — e ele some junto com a\n           * animação sob `prefers-reduced-motion`, sem deixar card nenhum\n           * invisível, porque a cor de repouso do `animate-pulse` é a opacidade\n           * cheia.\n           */\n          <SkeletonCard key={i} ratio={ratio} lines={2} delay={i * PASSO} label={false} />\n        ))}\n      </div>\n    </Moldura>\n  );\n}\n\n/** A defasagem entre um item e o vizinho. Uma só, pros cinco formatos. */\nconst PASSO = 90;\n\nexport interface SkeletonCardProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Como o bloco espera: `pulse` (default), `shimmer`, `wave` ou `none`. Ver a tabela no topo. */\n  motion?: SkeletonMotion;\n  /** Proporção da mídia, ex. `\"4 / 3\"`. `false` desenha um card sem mídia. */\n  ratio?: string | false;\n  /** Quantas linhas de texto abaixo da mídia. */\n  lines?: number;\n  delay?: number;\n  label?: string | false;\n}\n\n/**\n * SkeletonCard — mídia em cima, título e apoio embaixo.\n *\n * As duas linhas são 14px e 12px, e não é gosto: são as alturas de linha de\n * `text-corpo` e `text-apoio` truncados numa linha, que é o que um card de\n * catálogo desenha. Um bloco de 16px aqui adiantaria 4px por linha, e o salto\n * apareceria multiplicado pelo número de cards da fileira.\n */\nexport function SkeletonCard({\n  ratio = \"4 / 3\",\n  lines = 2,\n  delay = 0,\n  label = \"Carregando\",\n  motion,\n  className,\n  ...props\n}: SkeletonCardProps) {\n  return (\n    <Moldura\n      motion={motion}\n      label={label}\n      className={className}\n      {...(props as React.HTMLAttributes<HTMLElement>)}\n    >\n      {ratio ? <Bloco delay={delay} style={{ aspectRatio: ratio }} /> : null}\n      {Array.from({ length: lines }).map((_, i) => (\n        <Bloco\n          key={i}\n          delay={delay}\n          className={cn(\n            i === 0 ? \"mt-3 h-3.5 w-3/4\" : \"mt-1.5 h-3\",\n            // A última linha sempre mais curta: parágrafo real quase nunca fecha\n            // a linha final, e um bloco cheio até a borda lê como barra, não\n            // como texto.\n            i > 0 && (i === lines - 1 ? \"w-1/2\" : \"w-full\"),\n          )}\n        />\n      ))}\n    </Moldura>\n  );\n}\n\nexport interface SkeletonTextProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Como o bloco espera: `pulse` (default), `shimmer`, `wave` ou `none`. Ver a tabela no topo. */\n  motion?: SkeletonMotion;\n  lines?: number;\n  /** Altura da linha em px. Use a MESMA `line-height` do texto que vai entrar. */\n  lineHeight?: number;\n  /** Respiro entre linhas em px. O resto da entrelinha, na prática. */\n  gap?: number;\n  delay?: number;\n  label?: string | false;\n}\n\n/**\n * SkeletonText — o parágrafo que ainda não chegou.\n *\n * `lineHeight` + `gap` em vez de uma classe de altura: a soma dos dois é a\n * entrelinha do texto de verdade, e é a soma que precisa bater. Com `h-4 gap-2`\n * cravado, um parágrafo de seis linhas erra 24px — meia linha de diferença por\n * linha, que é exatamente o tamanho do salto que ninguém consegue explicar.\n */\nexport function SkeletonText({\n  lines = 3,\n  lineHeight = 14,\n  gap = 8,\n  delay = 0,\n  label = \"Carregando o texto\",\n  motion,\n  className,\n  ...props\n}: SkeletonTextProps) {\n  return (\n    <Moldura\n      motion={motion}\n      label={label}\n      className={className}\n      {...(props as React.HTMLAttributes<HTMLElement>)}\n    >\n      <div className=\"flex flex-col\" style={{ gap: `${gap}px` }}>\n        {Array.from({ length: lines }).map((_, i) => (\n          <Bloco\n            key={i}\n            delay={delay + i * PASSO}\n            className={i === lines - 1 && lines > 1 ? \"w-[62%]\" : \"w-full\"}\n            style={{ height: `${lineHeight}px` }}\n          />\n        ))}\n      </div>\n    </Moldura>\n  );\n}\n\nexport interface SkeletonListProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Como o bloco espera: `pulse` (default), `shimmer`, `wave` ou `none`. Ver a tabela no topo. */\n  motion?: SkeletonMotion;\n  rows?: number;\n  /** O quadrado à esquerda: avatar, capa, ícone. `false` tira. */\n  thumb?: boolean;\n  /** A coluna miúda da direita: valor, data, contagem. */\n  meta?: boolean;\n  /** Altura de cada linha em px. A mesma da linha real, sempre. */\n  rowHeight?: number;\n  delay?: number;\n  label?: string | false;\n}\n\n/**\n * SkeletonList — a fileira vertical de registros.\n *\n * A altura da linha é prop e não conta interna: lista de e-mail, de pedido e de\n * membro do time têm alturas diferentes, e o esqueleto que assume 56px pra\n * todas volta a causar o salto que ele existe pra evitar. O `justify-between`\n * segura a coluna de meta na direita, como na linha real.\n */\nexport function SkeletonList({\n  rows = 4,\n  thumb = true,\n  meta = true,\n  rowHeight = 56,\n  delay = 0,\n  label = \"Carregando a lista\",\n  motion,\n  className,\n  ...props\n}: SkeletonListProps) {\n  return (\n    <Moldura\n      motion={motion}\n      label={label}\n      className={className}\n      {...(props as React.HTMLAttributes<HTMLElement>)}\n    >\n      <div className=\"flex flex-col\">\n        {Array.from({ length: rows }).map((_, i) => (\n          <div\n            key={i}\n            // A régua entre as linhas fica no esqueleto também: ela é 1px de\n            // layout, e 1px vezes vinte linhas é uma linha inteira de erro.\n            className={cn(\n              \"flex min-w-0 items-center gap-3 border-b border-border\",\n              i === rows - 1 && \"border-b-0\",\n            )}\n            style={{ height: `${rowHeight}px` }}\n          >\n            {thumb ? <Bloco delay={delay + i * PASSO} className=\"size-9 shrink-0 rounded-full\" /> : null}\n            <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n              <Bloco delay={delay + i * PASSO} className=\"h-3.5 w-1/2\" />\n              <Bloco delay={delay + i * PASSO} className=\"h-3 w-1/3\" />\n            </div>\n            {meta ? <Bloco delay={delay + i * PASSO} className=\"h-3 w-12 shrink-0\" /> : null}\n          </div>\n        ))}\n      </div>\n    </Moldura>\n  );\n}\n\nexport interface SkeletonMediaProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Como o bloco espera: `pulse` (default), `shimmer`, `wave` ou `none`. Ver a tabela no topo. */\n  motion?: SkeletonMotion;\n  ratio?: string;\n  /** Cantos da mídia real. Passe o mesmo raio, senão a troca \"estala\". */\n  radius?: string;\n  delay?: number;\n  label?: string | false;\n}\n\n/**\n * SkeletonMedia — o retângulo da foto, do vídeo, do mapa, do gráfico.\n *\n * É o formato mais simples e o mais fácil de errar: quase todo mundo escreve\n * `h-48` e a mídia real chega com proporção, não com altura. Aqui é\n * `aspect-ratio` por padrão, que é como a caixa da mídia se reserva de verdade\n * — a altura sai da largura que a coluna deu, em qualquer tela.\n */\nexport function SkeletonMedia({\n  ratio = \"16 / 9\",\n  radius = \"var(--r-surface, 0.875rem)\",\n  delay = 0,\n  label = \"Carregando a mídia\",\n  motion,\n  className,\n  ...props\n}: SkeletonMediaProps) {\n  return (\n    <Moldura\n      motion={motion}\n      label={label}\n      className={className}\n      {...(props as React.HTMLAttributes<HTMLElement>)}\n    >\n      <Bloco delay={delay} className=\"w-full rounded-none\" style={{ aspectRatio: ratio, borderRadius: radius }} />\n    </Moldura>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Placeholder com a medida exata de cards, listas ou mídia enquanto o conteúdo carrega",
      "evite": "Lista que voltou vazia: empty-state; operação longa em segundo plano, status-ticker"
    }
  },
  "categories": [
    "feedback",
    "skeleton",
    "carregando",
    "trilho",
    "espera"
  ],
  "type": "registry:ui"
}