{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rail",
  "title": "Rail",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Trilho horizontal com snap: anda por arrasto, Shift + roda, setas e teclado. As setas somem nas pontas.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/rail.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Rail — trilho horizontal com scroll-snap nativo.\n *\n * Colhido de um concept de e-commerce. Carrossel de produto é padrão de loja\n * grande, e aqui é CSS nativo: sem biblioteca, sem sequestro de rolagem,\n * arrasta no telefone de graça.\n *\n * O detalhe que faz a diferença comercial: **o item parcialmente visível na\n * borda é o que conta ao olho que a gama continua.** Por isso a largura de\n * coluna nunca fecha exata no viewport.\n *\n * As setas desabilitam nas pontas — um controle que não faz nada mente sobre\n * o estado da lista.\n *\n * O trilho é **uma** parada de Tab: dentro dele, ←/→ e Home/End andam pelos\n * itens. Sem isso, três trilhos numa página viram vinte e uma paradas de\n * teclado antes do primeiro campo útil. Ver a nota no `ancorar()`.\n *\n * ── O que faltava, e por que era a mesma falta três vezes ──────────────────\n *\n * O trilho sabia rolar e não sabia mais nada. Três buracos, todos de anatomia:\n *\n * 1. **Não sabia sangrar.** Trilho de vitrine encosta na borda — é o corte na\n *    borda que promete continuação. Quem quisesse isso tinha que escrever margem\n *    negativa à mão do lado de fora e, junto, perder o alinhamento do primeiro\n *    item com a coluna de texto. Que a falta é real está provado no vizinho: o\n *    `@visant/marquee` já carregava `width: 108%; marginLeft: -4%` cravado no\n *    código pra resolver a mesma coisa. Agora é `bleed`, e ele sangra a caixa\n *    SEM desalinhar o conteúdo, porque a sangria vira `padding` e\n *    `scroll-padding` de volta pra dentro.\n * 2. **O estado das pontas era calculado e escondido.** `edge` já sabia se havia\n *    mais lista de cada lado, e o único consumidor era o `disabled` das setas —\n *    que só existem a partir de `lg`. Abaixo disso a informação era computada a\n *    cada rolagem e jogada fora, e num telefone nada dizia que a lista continua.\n *    `fade` liga a máscara nas pontas, e ela lê o MESMO `edge`: some quando\n *    acaba de verdade, em vez de fingir corte permanente.\n * 3. **Não tinha cabeça.** Um trilho é uma lista, e lista sem nome nem contagem\n *    é uma fileira de retângulos. `heading` e `hint` dão o rótulo e o qualificador\n *    na mesma fileira das setas.\n *\n * As três entram como prop nova, e as três nascem desligadas: quem já usava o\n * trilho recebe o mesmo pixel de antes.\n *\n * ── E a ponta esmaecida ainda não dizia nada ───────────────────────────────\n *\n * `fade` conserta a mentira (\"existe mais lista deste lado\") mas para na\n * metáfora: o esmaecido promete continuação sem dizer QUANTA. Num trilho de 6\n * itens isso é gesto; num de 40, é a diferença entre arrastar e desistir — e\n * quem arrasta no telefone não tem seta pra contar por ela.\n *\n * `remainder` põe o número na própria ponta: `+7`, contando os itens que ainda\n * não couberam INTEIROS na vista, do mesmo `getBoundingClientRect` que já roda\n * a cada rolagem. Clicar nele anda um item, então a informação e o controle são\n * o mesmo alvo — e ele desaparece quando chega ao fim, como as setas.\n *\n * `progress` desenha a régua embaixo: largura do polegar = fatia visível da\n * lista, posição = onde se está. É a barra de rolagem que o navegador esconde\n * no telefone, redesenhada com o token da marca, e é o que responde \"isso é\n * uma lista de seis ou de sessenta?\" antes do primeiro gesto.\n *\n * ── E o trilho de DOIS itens ───────────────────────────────────────────────\n *\n * Tudo acima cuida da lista que transborda. A que não transborda ficava sem\n * dono, e o defeito foi medido no app que consome isto: faixa útil de 1116px,\n * dois cards de ~245px encostados à esquerda, **610px de vazio à direita** —\n * mais da metade da fileira. Não é bug de código: sem transbordo não há o que\n * rolar, então as setas somem e a máscara colapsa, exatamente como manda o\n * estado. O que sobra é uma fileira curta ancorada num canto de uma faixa\n * larga, e isso lê como conteúdo faltando, não como catálogo enxuto.\n *\n * `gridUpTo` é o limiar: com até N itens o trilho deixa de ser trilho e vira\n * grade de colunas iguais que dividem a faixa. É a mesma lista, com a forma que\n * a contagem pede — fileira quando há o que rolar, grade quando não há.\n *\n * Duas decisões que valem mais que o efeito:\n *\n * 1. **Quem decide é `Children.count`, não a medida.** O componente JÁ sabe se\n *    transborda (`sync`), mas só DEPOIS de montar no cliente — trocar de layout\n *    ali é um salto visível e, em SSR, HTML de servidor que não bate com o da\n *    hidratação. A contagem existe no primeiro render dos dois lados, então o\n *    servidor e o cliente pintam a mesma coisa e nada pisca.\n * 2. **A coluna tem TETO.** Grade que só divide a largura devolve, com dois\n *    itens numa faixa larga, dois pôsteres de ~550px; num card de proporção\n *    alta isso é meia dobra de altura pra dois itens, e o `sizes` do next/image\n *    que o consumidor declarou pra um card de 300 passa a servir imagem macia.\n *    O teto é 1,5× o `itemWidth`: enche a faixa quando dá, e quando não dá,\n *    para antes de virar outro problema.\n */\n\nexport interface RailProps extends React.HTMLAttributes<HTMLDivElement> {\n  children: React.ReactNode;\n  /** Largura mínima de cada item. */\n  itemWidth?: number;\n  gap?: number;\n  /** Setas de navegação (só aparecem onde há ponteiro). */\n  arrows?: boolean;\n  label?: string;\n  /**\n   * Quantos px o trilho sangra pra fora do container, de cada lado.\n   *\n   * Passe o mesmo respiro lateral do pai (`px-6` → `bleed={24}`) e a fileira\n   * encosta na borda da tela enquanto o primeiro item continua alinhado com o\n   * texto acima. `false` (padrão) é o trilho contido de sempre.\n   */\n  bleed?: number | false;\n  /**\n   * Esmaece as pontas onde a lista continua. Segue o estado real das bordas.\n   *\n   * É máscara no scroller, não faixa pintada por cima: o item cortado some\n   * inteiro no fade, com título, legenda e o que mais houver embaixo da arte.\n   */\n  fade?: boolean;\n  /**\n   * O nome da lista, na fileira das setas.\n   *\n   * `heading` e não `title` de propósito: `title` já é atributo de HTML e vira\n   * o tooltip nativo do navegador. Aceitar o mesmo nome pros dois faria quem\n   * instala escrever um e receber o outro — e o tooltip sumiria sem explicação.\n   */\n  heading?: React.ReactNode;\n  /** O qualificador ao lado do nome: \"12 peças\", \"atualizado hoje\". */\n  hint?: React.ReactNode;\n  /** `+N` na ponta que continua. Clicar anda um item. */\n  remainder?: boolean;\n  /** Régua embaixo: quanto da lista cabe na vista e onde ela está. */\n  progress?: boolean;\n  /**\n   * Com até N itens, o trilho vira GRADE: colunas iguais dividindo a faixa, sem\n   * rolagem, sem setas, sem máscara e sem tabindex rotativo (com três itens,\n   * três paradas de Tab é a ordem natural — o defeito das 21 paradas era outro).\n   *\n   * Nasce desligado como toda prop nova daqui: quem já usava o trilho recebe o\n   * mesmo pixel de antes. O app da casa liga em 3, que é onde a conta fecha lá:\n   * quatro cards de 300 + gaps passam de 1116px e transbordam de verdade; três\n   * cabem sobrando 184px mortos.\n   */\n  gridUpTo?: number | false;\n}\n\nexport function Rail({\n  children,\n  itemWidth = 240,\n  gap = 20,\n  arrows = true,\n  label = \"Scrollable list\",\n  bleed = false,\n  fade = false,\n  heading,\n  hint,\n  remainder = false,\n  progress = false,\n  gridUpTo = false,\n  className,\n  ...props\n}: RailProps) {\n  const total = React.Children.count(children);\n  const grade =\n    typeof gridUpTo === \"number\" && total > 0 && total <= gridUpTo;\n\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [edge, setEdge] = React.useState({ start: true, end: false });\n  /**\n   * O que a ponta e a régua mostram, medido no MESMO passo das bordas.\n   *\n   * Um estado só, e não três: `restam` e a régua mudam sempre junto com\n   * `edge`, e três `setState` no mesmo `onScroll` seriam três renders por\n   * quadro de rolagem pra desenhar uma informação que é uma só.\n   */\n  const [vista, setVista] = React.useState({ restam: 0, fatia: 1, pos: 0 });\n\n  const sync = React.useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    setEdge({\n      start: el.scrollLeft < 8,\n      end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 8,\n    });\n\n    const caixa = el.getBoundingClientRect();\n    // \"Falta\" o item que ainda não coube INTEIRO. O card cortado na borda é a\n    // promessa da peça; contá-lo como visto seria a conta mentindo pra manter\n    // o número bonito. 1px de tolerância para o sub-pixel de layout.\n    const restam = Array.from(el.children as HTMLCollectionOf<HTMLElement>).filter(\n      (c) => c.getBoundingClientRect().right > caixa.right + 1,\n    ).length;\n    const rolavel = el.scrollWidth - el.clientWidth;\n    setVista({\n      restam,\n      fatia: el.scrollWidth ? el.clientWidth / el.scrollWidth : 1,\n      pos: rolavel > 0 ? el.scrollLeft / rolavel : 0,\n    });\n  }, []);\n\n  /*\n   * Quem avisa que as pontas mudaram é o SCROLLER, não a janela.\n   *\n   * Era `window.addEventListener(\"resize\", sync)`, e o furo aparece assim:\n   * estreite o container sem mexer na janela — painel que abre, sidebar que\n   * colapsa, split que a pessoa arrasta, container query — e a lista passa a\n   * transbordar sem que ninguém remeça. Medido: com o trilho já transbordando\n   * (`scrollWidth > clientWidth`), a seta \"próximo\" continuava `disabled` e a\n   * máscara continuava opaca dos dois lados. Ou seja, o controle mentia sobre o\n   * estado da lista, que é exatamente o que a nota lá em cima promete não fazer.\n   *\n   * O `ResizeObserver` no scroller é o que passa a mandar. O listener de janela\n   * FICA, e não por medo: `sync` é uma leitura de três números e um `setState`\n   * que quase sempre devolve o mesmo estado, então o custo de mantê-lo é zero, e\n   * ele é a rede pra ambiente onde o RO não entrega — aba em segundo plano\n   * entrega as observações só quando volta a renderizar, e navegador antigo não\n   * tem RO nenhum. Dois avisos redundantes de uma medida idempotente é barato;\n   * um controle mentindo sobre a lista não é.\n   */\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    sync();\n    window.addEventListener(\"resize\", sync);\n    const ro =\n      typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(sync);\n    ro?.observe(el);\n    return () => {\n      window.removeEventListener(\"resize\", sync);\n      ro?.disconnect();\n    };\n  }, [sync]);\n\n  /**\n   * Avança até o próximo ITEM, não por uma distância.\n   *\n   * Era `scrollBy(clientWidth * 0.8)`, e o defeito só aparece com encaixe\n   * ligado: o navegador resolve rolagem programática por delta contra os\n   * pontos de encaixe, e o resultado pode ser desfeito no mesmo quadro — no\n   * app onde isto foi colhido, o botão devolvia o `scrollLeft` a 0 e parecia\n   * morto. Com destino explícito o encaixe deixa de ser adversário e vira o\n   * mecanismo: o alvo JÁ é um ponto de encaixe, então não há o que corrigir.\n   *\n   * A medida é por `getBoundingClientRect`, não por `offsetLeft`: este último\n   * encadeia por `offsetParent`, que quase nunca é o scroller, e devolve\n   * posição de layout cega à rolagem atual.\n   *\n   * De quebra o gesto melhora — a seta anda um item inteiro e nunca deixa meio\n   * card na borda, que é o que uma fração de viewport fazia.\n   */\n  const nudge = (dir: -1 | 1) => {\n    const el = ref.current;\n    if (!el) return;\n    const itens = Array.from(el.children) as HTMLElement[];\n    if (!itens.length) return;\n\n    const pad = parseFloat(getComputedStyle(el).paddingLeft) || 0;\n    const base = el.getBoundingClientRect().left + pad;\n    // 4px de tolerância: sub-pixel de layout não é \"outro item\".\n    const desvio = (c: HTMLElement) => c.getBoundingClientRect().left - base;\n    const alvo =\n      dir === 1\n        ? itens.find((c) => desvio(c) > 4)\n        : [...itens].reverse().find((c) => desvio(c) < -4);\n\n    el.scrollTo({\n      left: alvo ? el.scrollLeft + desvio(alvo) : dir === 1 ? el.scrollWidth : 0,\n      behavior: \"smooth\",\n    });\n  };\n\n  /**\n   * ── Tabindex rotativo: o trilho é UMA parada de Tab ────────────────────────\n   *\n   * Medido antes de existir, numa tela com três trilhos: eram **21 paradas de\n   * Tab** até o campo de busca, 17 delas cards. Quem navega por teclado tinha\n   * que atravessar três trilhos inteiros pra chegar no que queria — a forma\n   * clássica de carrossel quebrar acessibilidade sem quebrar nada visível.\n   *\n   * O padrão certo pra lista horizontal é o de widget composto: Tab entra e sai\n   * do trilho, ←/→ andam por dentro, Home/End vão pras pontas. Aqui o alcance é\n   * feito por **DOM e não por prop injetada**, de propósito: assim vale pra\n   * QUALQUER foco dentro do item — inclusive um botão secundário no card, que\n   * uma prop no link não alcançaria.\n   */\n  const itens = React.useCallback(\n    () => Array.from((ref.current?.children ?? []) as HTMLCollectionOf<HTMLElement>),\n    [],\n  );\n  const focaveisDe = (item: HTMLElement) =>\n    Array.from(item.querySelectorAll<HTMLElement>(\"a[href], button:not([disabled]), [tabindex]\"));\n\n  /** Só o item ativo é alcançável por Tab; o resto responde a seta. */\n  const ancorar = React.useCallback(\n    (ativo: number) => {\n      itens().forEach((item, i) => {\n        focaveisDe(item).forEach((f) => {\n          // Em grade não há âncora: os itens estão todos na vista, então a\n          // ordem de Tab natural é a certa — e é ela que devolve o tabindex a\n          // quem já tinha sido marcado quando a mesma lista era fileira.\n          if (grade || i === ativo) f.removeAttribute(\"tabindex\");\n          else f.setAttribute(\"tabindex\", \"-1\");\n        });\n      });\n    },\n    [itens, grade],\n  );\n\n  React.useEffect(() => {\n    ancorar(0);\n    // Remedir as pontas quando a lista muda: sem isto um trilho que recebe\n    // itens depois (filtro, fetch) fica com a seta \"próximo\" desabilitada\n    // apontando pro estado da lista anterior — e agora que `fade` lê o mesmo\n    // estado, o erro apareceria como uma ponta esmaecida sem lista embaixo.\n    sync();\n  }, [ancorar, children, sync]);\n\n  const irPara = (i: number) => {\n    const lista = itens();\n    const alvo = lista[Math.max(0, Math.min(i, lista.length - 1))];\n    if (!alvo) return;\n    ancorar(lista.indexOf(alvo));\n    focaveisDe(alvo)[0]?.focus();\n  };\n\n  const teclado = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const lista = itens();\n    const atual = lista.findIndex((it) => it.contains(document.activeElement));\n    if (atual === -1) return;\n    const passo =\n      e.key === \"ArrowRight\" ? atual + 1\n      : e.key === \"ArrowLeft\" ? atual - 1\n      : e.key === \"Home\" ? 0\n      : e.key === \"End\" ? lista.length - 1\n      : null;\n    if (passo === null) return;\n    e.preventDefault();\n    irPara(passo);\n  };\n\n  /**\n   * Focar por dentro de um item que não era o ativo passa a âncora pra ele. Sem\n   * isto, sair do trilho e voltar com Tab jogaria o foco no primeiro item de\n   * novo, perdendo o lugar de quem estava lendo.\n   */\n  const aoFocar = (e: React.FocusEvent<HTMLDivElement>) => {\n    const lista = itens();\n    const i = lista.findIndex((it) => it.contains(e.target as Node));\n    if (i > 0) ancorar(i);\n  };\n\n  /**\n   * Arrastar com o mouse. No toque o navegador já arrasta sozinho; no mouse,\n   * sem isto, o único jeito de andar era achar a barra ou segurar Shift. O\n   * snap sai durante o gesto (com ele ligado o trilho briga com a mão e pula)\n   * e volta no fim, que é quando ele assenta no card mais perto.\n   *\n   * A distância divide pela escala da caixa: dentro de um palco com\n   * `transform: scale`, 100px de mouse não são 100px de rolagem.\n   *\n   * ── Entrada e saída do gesto ───────────────────────────────────────────\n   * Escrever `scrollLeft` direto no `pointermove` deixava o trilho duro nas duas\n   * pontas: no começo ele saltava os 5px da zona morta, e ao soltar parava seco.\n   * Aqui um laço só faz os dois: a posição persegue o alvo (a mão, com um atraso\n   * mínimo) e, ao soltar, o alvo continua andando com a velocidade da mão, que\n   * decai sozinha. O snap volta só no fim da inércia, e volta ao valor que tinha\n   * antes do gesto: quem desliga o snap por fora (o `reels-rail`) não o reencontra\n   * ligado depois do primeiro arrasto. Sob movimento reduzido, é 1:1 e para ao soltar.\n   */\n  const arrasto = React.useRef<{ x: number; escala: number; andou: boolean; t: number; v: number } | null>(null);\n  const fisica = React.useRef<{\n    pos: number;\n    alvo: number;\n    v: number;\n    escrito: number;\n    t: number;\n    raf: number;\n    solto: boolean;\n    snap: string;\n  } | null>(null);\n\n  const assentar = React.useCallback(() => {\n    const f = fisica.current;\n    const el = ref.current;\n    if (!f || !el) return;\n    cancelAnimationFrame(f.raf);\n    fisica.current = null;\n    // Devolver o snap com rolagem suave: sem isto o trilho pula seco pro card.\n    const antes = el.scrollLeft;\n    el.style.scrollSnapType = f.snap;\n    const depois = el.scrollLeft;\n    if (antes !== depois) {\n      el.scrollLeft = antes;\n      el.scrollTo({ left: depois, behavior: \"smooth\" });\n    }\n  }, []);\n  React.useEffect(() => () => cancelAnimationFrame(fisica.current?.raf ?? 0), []);\n\n  const quadroFisica = (agora: number) => {\n    const f = fisica.current;\n    const el = ref.current;\n    if (!f || !el) return;\n    const dt = Math.min(48, agora - f.t);\n    f.t = agora;\n    // Outro dono mexeu na rolagem (a emenda do laço do `reels-rail`): anda junto.\n    const fora = el.scrollLeft - f.escrito;\n    if (Math.abs(fora) > 2) {\n      f.pos += fora;\n      f.alvo += fora;\n    }\n    if (f.solto) {\n      f.alvo += f.v * dt;\n      f.v *= Math.pow(0.9965, dt);\n    }\n    f.pos += (f.alvo - f.pos) * (1 - Math.pow(0.68, dt / 16.67));\n    el.scrollLeft = f.pos;\n    f.escrito = el.scrollLeft;\n    const fim = el.scrollWidth - el.clientWidth;\n    const naPonta = (el.scrollLeft <= 0 && f.v < 0) || (el.scrollLeft >= fim - 1 && f.v > 0);\n    if (f.solto && naPonta) f.v = 0;\n    if (f.solto && Math.abs(f.v) < 0.015 && (naPonta || Math.abs(f.alvo - f.pos) < 0.5)) {\n      assentar();\n      return;\n    }\n    f.raf = requestAnimationFrame(quadroFisica);\n  };\n\n  const aoApertar = (e: React.PointerEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || e.pointerType !== \"mouse\" || e.button !== 0) return;\n    if (el.scrollWidth <= el.clientWidth) return;\n    // Pegou a fileira ainda deslizando: ela para na mão.\n    assentar();\n    const escala = el.offsetWidth ? el.getBoundingClientRect().width / el.offsetWidth : 1;\n    arrasto.current = { x: e.clientX, escala: escala || 1, andou: false, t: e.timeStamp, v: 0 };\n  };\n  const aoMover = (e: React.PointerEvent<HTMLDivElement>) => {\n    const a = arrasto.current;\n    const el = ref.current;\n    if (!a || !el) return;\n    const dx = (e.clientX - a.x) / a.escala;\n    if (!a.andou) {\n      if (Math.abs(dx) < 5) return;\n      a.andou = true;\n      el.setPointerCapture(e.pointerId);\n      // Marcador e não só `style.cursor`: o card por baixo é link e teria\n      // `cursor: pointer` próprio, que ganharia do trilho no meio do arrasto.\n      el.dataset.arrastando = \"\";\n      el.style.userSelect = \"none\";\n      // Conta a partir daqui, senão a zona morta vira um salto de 5px.\n      a.x = e.clientX;\n      a.t = e.timeStamp;\n      const snap = el.style.scrollSnapType;\n      el.style.scrollSnapType = \"none\";\n      if (!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n        const p = el.scrollLeft;\n        fisica.current = { pos: p, alvo: p, v: 0, escrito: p, t: performance.now(), raf: 0, solto: false, snap };\n        fisica.current.raf = requestAnimationFrame(quadroFisica);\n      } else {\n        fisica.current = { pos: 0, alvo: 0, v: 0, escrito: 0, t: 0, raf: 0, solto: true, snap };\n      }\n      return;\n    }\n    // Passo a passo, nunca `inicio - dx`: a emenda do laço subtrai uma volta do\n    // `scrollLeft` no meio do gesto, e uma conta absoluta a desfaria no quadro seguinte.\n    a.x = e.clientX;\n    const dtm = Math.max(1, e.timeStamp - a.t);\n    a.t = e.timeStamp;\n    a.v = a.v * 0.6 + (-dx / dtm) * 0.4;\n    const f = fisica.current;\n    if (f && f.raf) f.alvo -= dx;\n    else el.scrollLeft -= dx;\n  };\n  const aoSoltar = (e: React.PointerEvent<HTMLDivElement>) => {\n    const a = arrasto.current;\n    const el = ref.current;\n    arrasto.current = null;\n    if (!a?.andou || !el) return;\n    if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);\n    delete el.dataset.arrastando;\n    el.style.userSelect = \"\";\n    const f = fisica.current;\n    if (f && f.raf) {\n      // Parou a mão antes de soltar: nada de inércia herdada de um gesto que já acabou.\n      f.v = e.timeStamp - a.t > 90 ? 0 : Math.max(-3, Math.min(3, a.v));\n      f.solto = true;\n    } else {\n      assentar();\n    }\n    // O clique que vem junto do soltar não pode abrir o card que estava embaixo.\n    const engolir = (ev: MouseEvent) => {\n      ev.preventDefault();\n      ev.stopPropagation();\n    };\n    el.addEventListener(\"click\", engolir, { capture: true, once: true });\n    window.setTimeout(() => el.removeEventListener(\"click\", engolir, { capture: true }), 0);\n  };\n\n  const temCabeca = Boolean(heading || hint);\n  // Seta que não rola nada é controle mentindo sobre a lista — em grade não há\n  // rolagem nenhuma, então ela não existe (não é a versão desabilitada dela).\n  const mostrarSetas = arrows && !grade;\n  const sangra = !grade && typeof bleed === \"number\" && bleed > 0 ? bleed : 0;\n\n  /*\n   * A máscara nasce do `edge`, não de \"tem overflow\".\n   *\n   * Uma faixa esmaecida fixa nas duas pontas mente duas vezes: promete lista no\n   * começo quando já se está no começo, e continua prometendo depois do último\n   * item. Aqui cada ponta só existe enquanto há lista daquele lado — o mesmo\n   * estado que desabilita as setas, agora com um segundo consumidor.\n   *\n   * ── Por que a geometria virou variável, e não duas strings ──────────────\n   *\n   * Era um `useMemo` que trocava a string INTEIRA do gradiente a cada virada de\n   * ponta. Duas consequências medidas no app que consome isto (trilho de 1440,\n   * fade de 84px): a máscara reaparecia de estalo no primeiro pixel de rolagem,\n   * porque `mask-image` não é propriedade animável — trocar a string é corte\n   * seco; e quem quisesse suavizar não tinha onde pegar, o valor nascia e morria\n   * dentro do componente.\n   *\n   * Agora a máscara fica SEMPRE ligada e o que muda é a largura de cada ponta,\n   * publicada em `--rail-fade-ini` / `--rail-fade-fim`. Com `0px` o par de\n   * paradas colapsa no mesmo ponto e não há fade nenhum — o primeiro item nasce\n   * inteiro, que é o defeito óbvio de mascarar os dois lados sempre. E como as\n   * duas larguras são custom properties, o app que as REGISTRA com\n   * `@property { syntax: '<length>' }` ganha a transição de graça, sem que este\n   * arquivo precise carregar CSS junto (o item do registry é só este .tsx).\n   * Sem registro, o comportamento é o de hoje: troca imediata, nada quebra.\n   */\n  // Em grade não há ponta que continue: mascarar aqui apagaria metade de um\n  // card que está inteiro na vista.\n  const mascara = fade && !grade\n    ? \"linear-gradient(to right, transparent 0, black var(--rail-fade-ini), black calc(100% - var(--rail-fade-fim)), transparent 100%)\"\n    : undefined;\n\n  return (\n    <div className={cn(\"relative\", className)} {...props}>\n      {(mostrarSetas || temCabeca) && (\n        <div\n          className={cn(\n            \"mb-3 items-end gap-3\",\n            /* Sem cabeça o class string é EXATAMENTE o de antes — `hidden` até\n               `lg`. Isso não é apego: abaixo de `lg` a fileira precisa continuar\n               sem ocupar altura nem a margem de 12px, senão todo trilho já\n               publicado ganha um degrau no celular e o `@visant/skeleton-rail`,\n               que copia estas medidas pra não pular na troca, passa a errar. */\n            temCabeca ? \"flex justify-between\" : \"hidden justify-end lg:flex\",\n          )}\n        >\n          {temCabeca && (\n            <div className=\"flex min-w-0 items-baseline gap-2\">\n              {heading && (\n                <span className=\"truncate text-[length:var(--text-destaque,0.9375rem)] font-semibold tracking-tight text-foreground\">\n                  {heading}\n                </span>\n              )}\n              {hint && (\n                <span className=\"shrink-0 font-mono text-[length:var(--text-micro,0.625rem)] uppercase tracking-[0.14em] text-muted-foreground\">\n                  {hint}\n                </span>\n              )}\n            </div>\n          )}\n          {mostrarSetas && (\n            <div className={cn(\"gap-2\", temCabeca ? \"hidden lg:flex\" : \"flex\")}>\n              <RailButton dir=\"prev\" disabled={edge.start} onClick={() => nudge(-1)} />\n              <RailButton dir=\"next\" disabled={edge.end} onClick={() => nudge(1)} />\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* A caixa de posicionamento é ESTA, não o container inteiro: com a\n          cabeça ligada, `top-1/2` do container cairia no meio do conjunto\n          \"cabeçalho + trilho\" e o `+N` flutuaria acima dos cards. */}\n      <div className=\"relative\">\n        <div\n          ref={ref}\n          role=\"group\"\n          aria-label={label}\n          onScroll={grade ? undefined : sync}\n          onKeyDown={grade ? undefined : teclado}\n          onFocus={grade ? undefined : aoFocar}\n          onPointerDown={grade ? undefined : aoApertar}\n          onPointerMove={grade ? undefined : aoMover}\n          onPointerUp={grade ? undefined : aoSoltar}\n          onPointerCancel={grade ? undefined : aoSoltar}\n          // Link e imagem arrastam como arquivo por padrão, e o arrasto nativo\n          // engole o gesto antes de o trilho ver o ponteiro andar.\n          onDragStart={grade ? undefined : (e) => e.preventDefault()}\n          className={cn(\n            \"grid pb-3 [&>*]:min-w-0\",\n            grade\n              ? null\n              : [\n                  \"grid-flow-col overflow-x-auto overscroll-x-contain\",\n                  // Mão aberta só quando há o que arrastar; fechada durante o\n                  // arrasto, por cima até do `pointer` dos cards.\n                  !(edge.start && edge.end) && \"cursor-grab\",\n                  \"data-[arrastando]:cursor-grabbing [&[data-arrastando]_*]:!cursor-grabbing\",\n                  \"[scroll-snap-type:x_proximity]\",\n                  \"[&>*]:[scroll-snap-align:start]\",\n                  // Com a régua ligada a barra nativa é a mesma informação duas\n                  // vezes, e a cinza ainda some no Mac e aparece no Windows.\n                  progress\n                    ? \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n                    : \"[scrollbar-width:thin]\",\n                ],\n          )}\n          style={{\n            gap: `${gap}px`,\n            ...(grade\n              ? {\n                  /* `auto-fit` e não `repeat(total, 1fr)`: onde a faixa não\n                     comporta as colunas — telefone, container estreito — as\n                     sobrando descem de linha sozinhas, e as trilhas vazias\n                     colapsam pra que as que ficaram dividam a largura inteira.\n                     Grade responsiva sem uma media query escrita à mão.\n\n                     O `maxWidth` é o teto de 1,5× (ver a nota do cabeçalho):\n                     é ele que impede a coluna de virar pôster gigante quando\n                     há dois itens numa faixa larga. */\n                  gridTemplateColumns: `repeat(auto-fit, minmax(${itemWidth}px, 1fr))`,\n                  maxWidth: `${total * itemWidth * 1.5 + (total - 1) * gap}px`,\n                }\n              : {\n                  gridAutoColumns: `minmax(${itemWidth}px, 1fr)`,\n                  scrollbarColor: \"var(--border) transparent\",\n                }),\n            /*\n             * Sangria em três partes, e as três são obrigatórias.\n             *\n             * A margem negativa tira a caixa pra fora do container; o padding\n             * devolve o CONTEÚDO pro lugar, então o primeiro item continua na\n             * mesma vertical do texto de cima; e o `scroll-padding` faz o encaixe\n             * parar nessa mesma vertical. Sem a terceira, o snap ancora na borda\n             * crua do scroller e o primeiro card encosta na tela toda vez que a\n             * pessoa volta pro começo — o defeito clássico de trilho sangrado.\n             */\n            ...(sangra\n              ? {\n                  marginInline: `-${sangra}px`,\n                  paddingInline: `${sangra}px`,\n                  scrollPaddingInline: `${sangra}px`,\n                }\n              : null),\n            /* A máscara vale pro SCROLLER inteiro — a altura toda do item, não\n               só a faixa da arte. Uma faixa pintada por cima só cobre o que ela\n               alcança: no app que consome isto dá pra ler o título e a descrição\n               do card cortado LOGO ABAIXO do gradiente. Máscara recorta o\n               elemento, então não sobra nada legível além do fade.\n               `black`/`transparent` aqui são canal de opacidade, não cor de\n               marca: não há token pra apontar. */\n            ...(mascara\n              ? {\n                  [\"--rail-fade-ini\" as string]: edge.start ? \"0px\" : \"40px\",\n                  [\"--rail-fade-fim\" as string]: edge.end ? \"0px\" : \"40px\",\n                  maskImage: mascara,\n                  WebkitMaskImage: mascara,\n                }\n              : null),\n          }}\n        >\n          {children}\n        </div>\n\n        {remainder && !grade && vista.restam > 0 && (\n          /* Fica DENTRO da área rolável e por cima do último card cortado —\n             é a legenda do corte. `tabIndex={-1}` pelo mesmo motivo das setas:\n             o teclado já anda com ←/→ e moveria o foco junto, que é melhor. */\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-hidden=\"true\"\n            onClick={() => nudge(1)}\n            className={cn(\n              \"absolute right-0 top-1/2 z-10 -translate-y-1/2\",\n              \"inline-flex cursor-pointer items-center rounded-full border border-[var(--border)] bg-[var(--background)]\",\n              \"px-2.5 py-1 font-mono text-[11px] tabular-nums text-[var(--muted-foreground)]\",\n              \"transition-[color,border-color,transform] [transition-duration:var(--dur-press,120ms)]\",\n              \"hover:border-[var(--foreground)] hover:text-[var(--foreground)]\",\n              \"active:scale-[0.94] motion-reduce:active:scale-100\",\n            )}\n          >\n            +{vista.restam}\n          </button>\n        )}\n      </div>\n\n      {progress && !grade && (\n        // `aria-hidden`: é o retrato da mesma rolagem que o leitor de tela já\n        // percorre item a item dentro do `role=\"group\"`. Anunciar de novo, em\n        // porcentagem, seria ruído sobre um dado que ele não pode usar.\n        <div aria-hidden=\"true\" className=\"mt-1 h-[3px] w-full overflow-hidden rounded-full bg-[var(--muted)]\">\n          <div\n            className=\"h-full rounded-full bg-[var(--brand,var(--foreground))] transition-transform [transition-duration:var(--dur-press,120ms)] ease-[var(--ease-out)] motion-reduce:transition-none\"\n            style={{\n              width: `${Math.min(1, vista.fatia) * 100}%`,\n              // A translação é em % da PRÓPRIA largura, então o polegar anda\n              // exatamente o resto da pista: em `fatia` 0,4 ele percorre 150%\n              // de si mesmo e encosta no fim sem passar.\n              transform: `translateX(${(vista.pos * (1 - Math.min(1, vista.fatia)) * 100) / Math.max(0.0001, Math.min(1, vista.fatia))}%)`,\n            }}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction RailButton({\n  dir,\n  disabled,\n  onClick,\n}: {\n  dir: \"prev\" | \"next\";\n  disabled: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      /* `tabIndex={-1}` de propósito. As setas são afordância de PONTEIRO: pra\n         teclado, ←/→ e Home/End já fazem o mesmo e fazem melhor, porque movem o\n         foco junto em vez de rolar deixando o cursor fora da tela. Mantê-las na\n         ordem de Tab adicionava duas paradas por trilho cuja única função era\n         duplicar uma tecla que já funciona. Nenhum conteúdo fica inalcançável:\n         todos os itens seguem acessíveis por dentro do trilho. */\n      tabIndex={-1}\n      aria-label={dir === \"prev\" ? \"Scroll left\" : \"Scroll right\"}\n      className={cn(\n        \"inline-flex size-10 cursor-pointer items-center justify-center border border-[var(--border)]\",\n        \"transition-[background-color,border-color,color,transform]\",\n        \"[transition-duration:var(--dur-press,120ms)]\",\n        \"hover:border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]\",\n        \"active:scale-[0.94]\",\n        \"disabled:pointer-events-none disabled:opacity-30\",\n      )}\n    >\n      <svg viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden=\"true\">\n        <path d={dir === \"prev\" ? \"M15 5l-7 7 7 7\" : \"M9 5l7 7-7 7\"} />\n      </svg>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Trilho horizontal de cards ou produtos com snap, arrasto e teclado, em loja ou catálogo",
      "evite": "Vídeos verticais: reels-rail; capas em perspectiva, cover-flow; fotos em mosaico, masonry-gallery"
    }
  },
  "categories": [
    "controles",
    "carrossel",
    "snap"
  ],
  "type": "registry:ui"
}