{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "title": "Timeline",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Linha do tempo com progresso amarrado ao scroll.",
  "dependencies": [
    "framer-motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/timeline.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useTransform,\n} from \"framer-motion\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Timeline — a linha do tempo que avança junto com a página.\n *\n * ── O que estava errado aqui, e não era \"estilo\" ───────────────────────────\n *\n * Esta peça entrou no registry colada de fora e ninguém passou a régua:\n *\n * · **Copy de outra marca dentro do item.** O componente escrevia, fixo no\n *   corpo, \"Changelog from my journey\" e \"I've been working on Aceternity for\n *   the past 2 years\". Quem instalasse recebia o texto de outra pessoa e teria\n *   que editar o arquivo do registry pra tirar. Agora a cabeça é slot: sem\n *   `title` a linha começa direto no primeiro nó, que é o piso honesto.\n * · **Cor crua de paleta.** `bg-white`, `dark:bg-neutral-950`, `text-black`,\n *   `from-purple-500 via-blue-500`. Roxo e azul não são de marca nenhuma —\n *   eram de todas. Agora o trilho é `--border` e o preenchido é `--brand`, então\n *   a mesma linha sai na cor de quem instalou.\n * · **Animar `height`.** O preenchimento crescia por altura, que é layout a cada\n *   quadro. Virou `scaleY` com origem no topo: mesma leitura, no compositor.\n * · **Nó sem estado.** Os doze pontos eram idênticos do começo ao fim, então a\n *   linha não dizia onde a pessoa está. Agora cada nó tem três estados — feito,\n *   agora, a seguir — e eles saem da MESMA fração que preenche o trilho, medida\n *   uma vez por layout. Sem isso o ponto e o traço discordariam na tela.\n * · **`py-20 md:px-10 max-w-7xl` embutido.** A peça impunha a largura e o\n *   respiro da página inteira, então ela só cabia numa página inteira. Dentro de\n *   um painel, de uma coluna ou de um card, saía cortada. Agora o item ocupa o\n *   que o pai der.\n *\n * ── A anatomia ────────────────────────────────────────────────────────────\n *\n * `rótulo (meta) → título → corpo`, com o nó do trilho ao lado servindo de\n * evidência. Sem `meta`, o rótulo vira \"Etapa 01\" — numerar é melhor que deixar\n * o degrau sem nome, porque é o número que deixa a pessoa dizer \"travei na 3\".\n *\n * Quem pediu menos movimento recebe a linha inteira preenchida e todos os nós\n * no estado final: o conteúdo é o mesmo, sem percurso.\n *\n * ── Quem rola ─────────────────────────────────────────────────────────────\n *\n * Por padrão, a página. Com `scrollContainer`, um contêiner próprio: a linha\n * mora num painel, num modal ou num card com rolagem, e o progresso passa a ser o\n * percurso DELE. Sem isso a peça ficava muda em qualquer lugar onde a página não\n * rola, presa no estado em que nasceu.\n *\n * ── Por que o estado deixou de ser uma palavra ────────────────────────────\n *\n * Antes, o degrau atual se anunciava com a etiqueta \"agora\" ao lado da data:\n * caixa alta, mono, tracking largo — a terceira etiqueta em caixa alta da mesma\n * linha, contando com a data e com o que o consumidor põe no corpo. É esse\n * empilhamento de etiqueta que dá a cara de gerado por IA: tudo vira rótulo e\n * nada vira desenho. E era etiqueta redundante, porque o nó ao lado já estava\n * dizendo a mesma coisa.\n *\n * O estado agora é ANATOMIA, não texto:\n *\n * · **feito** — nó cheio, conteúdo em peso normal. Já passou, não pede atenção.\n * · **agora** — o marco ganha superfície: cartão com fundo, borda e um fio da\n *   marca colado na esquerda, encostando no trilho. É o único degrau com caixa\n *   na coluna inteira, então o olho acha em um quadro, sem ler nada.\n * · **a seguir** — nó vazado e título em cinza de apoio. Está lá, ainda não é\n *   agora. O recuo NÃO é por opacidade: opacidade em cima de `--muted-foreground`\n *   derruba o contraste do texto abaixo do piso de legibilidade, e o degrau que\n *   ainda não chegou é justamente o que a pessoa está tentando ler.\n *\n * A palavra continua existindo pra quem não vê a tela, em `sr-only`: leitor de\n * tela não enxerga fio nem fundo, e \"onde eu estou\" é a pergunta que a peça\n * existe pra responder. Texto invisível não é etiqueta — é a legenda do desenho.\n */\n\nexport interface TimelineEntry {\n  title: React.ReactNode;\n  content: React.ReactNode;\n  /** O rótulo acima do título: data, fase, prazo. Sem isto vira \"Etapa 01\". */\n  meta?: React.ReactNode;\n}\n\nexport interface TimelineProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  data: TimelineEntry[];\n  /** Cabeça opcional. Sem ela a linha começa direto no primeiro nó. */\n  title?: React.ReactNode;\n  /** Uma frase abaixo do título. Só aparece se `title` existir. */\n  description?: React.ReactNode;\n  /** O que a linha diz quando `data` chega vazio. */\n  emptyLabel?: React.ReactNode;\n  /**\n   * Contêiner que rola, quando não é a página. O progresso vira o percurso dele,\n   * de ponta a ponta. O elemento precisa estar posicionado (`relative`).\n   */\n  scrollContainer?: React.RefObject<HTMLElement | null>;\n}\n\nconst ESTADO = {\n  feito: \"feito\",\n  agora: \"agora\",\n  aseguir: \"a seguir\",\n} as const;\n\nexport function Timeline({\n  data,\n  title,\n  description,\n  emptyLabel = \"Nenhuma etapa por aqui ainda.\",\n  scrollContainer,\n  className,\n  ...props\n}: TimelineProps) {\n  const listaRef = React.useRef<HTMLOListElement>(null);\n  const nosRef = React.useRef<(HTMLLIElement | null)[]>([]);\n  const semMovimento = useReducedMotion();\n\n  /**\n   * A fração de cada etapa dentro da lista — medida, não estimada.\n   *\n   * Dividir 1 pelo número de etapas seria mais curto e estaria errado sempre que\n   * uma etapa tem três linhas e a seguinte tem dez: o ponto acenderia antes ou\n   * depois de o traço chegar nele, e a linha passaria a discordar de si mesma.\n   * Aqui a fração sai do offset real do nó, então traço e ponto são a mesma\n   * medida lida duas vezes.\n   */\n  const [fracoes, setFracoes] = React.useState<number[]>([]);\n\n  React.useEffect(() => {\n    const lista = listaRef.current;\n    if (!lista) return;\n\n    const medir = () => {\n      // As duas medidas na mesma régua: com `offsetHeight` dividindo um delta de\n      // `getBoundingClientRect`, a fração encolhia junto com o `scale` de quem\n      // hospeda a peça, e o ponto acendia antes de o traço chegar nele.\n      const caixa = lista.getBoundingClientRect();\n      const alturaTotal = caixa.height || 1;\n      setFracoes(\n        nosRef.current.map((no) =>\n          no ? (no.getBoundingClientRect().top - caixa.top) / alturaTotal : 0,\n        ),\n      );\n    };\n\n    medir();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(medir);\n    ro.observe(lista);\n    return () => ro.disconnect();\n  }, [data]);\n\n  /*\n   * `end 60%`, e não `end 50%`: o traço precisa terminar de encher ANTES de a\n   * última etapa sair pela borda de cima, senão a linha nunca é vista completa\n   * — a pessoa rola, o traço ainda está a 80% e o conteúdo já acabou.\n   */\n  const { scrollYProgress } = useScroll(\n    scrollContainer\n      ? { container: scrollContainer as React.RefObject<HTMLElement> }\n      : { target: listaRef, offset: [\"start 85%\", \"end 60%\"] },\n  );\n\n  // `scaleY` e não `height`: altura é layout a cada quadro, escala é compositor.\n  const escala = useTransform(scrollYProgress, [0, 1], [0, 1]);\n  const opacidade = useTransform(scrollYProgress, [0, 0.06], [0, 1]);\n\n  const [alcancados, setAlcancados] = React.useState(0);\n  useMotionValueEvent(scrollYProgress, \"change\", (p) => {\n    setAlcancados(fracoes.filter((f) => f <= p).length);\n  });\n  /*\n   * O evento só avisa quando o progresso MUDA. Se a primeira leitura chega antes\n   * de as frações existirem (o normal: as duas nascem no mesmo commit), o nó fica\n   * no piso até a pessoa rolar, enquanto o traço já está na posição certa. Era por\n   * isso que a mesma peça nascia no último passo numa tela e no primeiro em outra.\n   */\n  React.useEffect(() => {\n    setAlcancados(fracoes.filter((f) => f <= scrollYProgress.get()).length);\n  }, [fracoes, scrollYProgress]);\n\n  /*\n   * Piso de um: linha do tempo sem degrau atual não é linha do tempo.\n   *\n   * Com `alcancados` em zero — antes de a peça entrar na faixa de rolagem que a\n   * preenche — TODOS os nós caíam em \"a seguir\": doze pontos vazios e doze\n   * títulos apagados, uma lista morta. Pior, `i === total - 1` virava `i === -1`\n   * e nem o \"agora\" existia, então a peça não dizia nada em nenhum dos seus\n   * estados. O primeiro marco é o presente por definição; o traço continua em\n   * zero e cresce com a rolagem, que é o que ele tem a dizer.\n   */\n  const total = semMovimento ? data.length : Math.max(1, alcancados);\n\n  if (!data.length) {\n    return (\n      <section\n        className={cn(\n          \"rounded-[var(--r-surface,0.875rem)] border border-dashed border-border px-5 py-8 text-center\",\n          className,\n        )}\n        {...props}\n      >\n        <p className=\"text-[length:var(--text-apoio,0.75rem)] text-muted-foreground\">\n          {emptyLabel}\n        </p>\n      </section>\n    );\n  }\n\n  return (\n    <section className={cn(\"min-w-0\", className)} {...props}>\n      {title && (\n        <header className=\"mb-6 min-w-0\">\n          <h2 className=\"text-[length:var(--text-titulo,1.1875rem)] font-semibold tracking-tight text-foreground\">\n            {title}\n          </h2>\n          {description && (\n            <p className=\"mt-1.5 max-w-prose text-[length:var(--text-apoio,0.75rem)] leading-relaxed text-muted-foreground\">\n              {description}\n            </p>\n          )}\n        </header>\n      )}\n\n      {/*\n       * O trilho é irmão do `<ol>`, não filho.\n       *\n       * `<ol>` só aceita `<li>` (mais `<script>`/`<template>`): um `<div>` solto\n       * lá dentro é HTML inválido e entra na contagem que o leitor de tela\n       * anuncia — \"lista de 4 itens\" numa linha do tempo de 3 etapas. Como o\n       * `<ol>` é o único filho em fluxo deste `div`, o `inset-y-0` do trilho dá\n       * exatamente a altura da lista, que era o motivo de ele estar lá dentro.\n       */}\n      <div className=\"relative min-w-0\">\n        {/*\n         * O trilho vai de ponta a ponta da LISTA, por `inset-y-0` — não por uma\n         * altura medida em JS e escrita em `style`. Era assim antes, e o número\n         * ficava velho toda vez que uma etapa mudava de altura (fonte que troca,\n         * texto que reflui no celular), deixando o traço curto ou sobrando por\n         * baixo do último nó.\n         */}\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute bottom-0 left-[3px] top-0 w-px bg-border\"\n        >\n          <motion.div\n            className=\"absolute inset-x-0 top-0 h-full origin-top\"\n            style={{\n              scaleY: semMovimento ? 1 : escala,\n              opacity: semMovimento ? 1 : opacidade,\n              background: \"var(--brand, var(--foreground))\",\n            }}\n          />\n        </div>\n\n        <ol ref={listaRef} className=\"min-w-0 list-none pl-8\">\n        {data.map((item, i) => {\n          const estado =\n            i < total - 1 ? ESTADO.feito\n            : i === total - 1 ? ESTADO.agora\n            : ESTADO.aseguir;\n          const aceso = estado !== ESTADO.aseguir;\n\n          return (\n            <li\n              key={i}\n              ref={(el) => {\n                nosRef.current[i] = el;\n              }}\n              className={cn(\"relative min-w-0\", i > 0 && \"mt-5\")}\n            >\n              {/*\n               * O nó carrega o estado, e ele muda por `background` e `box-shadow`\n               * — nunca por tamanho. Ponto que cresce ao acender empurra o texto\n               * ao lado meio pixel e a coluna inteira treme na rolagem.\n               */}\n              <span\n                aria-hidden=\"true\"\n                /*\n                 * `-left-8` = -2rem = -32px, que é exatamente o `pl-8` da lista:\n                 * o nó começa na borda esquerda do `<ol>`, ocupa 0..7px, e seu\n                 * centro cai em 3,5px — o mesmo centro do trilho, que mora em\n                 * `left-[3px]` com 1px de largura. Estava em `-1.75rem` e o nó\n                 * saía 4px à direita da linha, encostado nela em vez de\n                 * enfiado nela. Se mexer no `pl-` da lista, mexa aqui junto.\n                 */\n                className={cn(\n                  \"absolute -left-8 top-[0.95rem] block size-[7px] rounded-full\",\n                  \"transition-[background-color,box-shadow]\",\n                  \"[transition-duration:var(--dur-base,200ms)]\",\n                  \"[transition-timing-function:var(--ease-out)]\",\n                )}\n                style={{\n                  background: aceso\n                    ? \"var(--brand, var(--foreground))\"\n                    : \"var(--background)\",\n                  boxShadow: aceso\n                    ? estado === ESTADO.agora\n                      ? \"0 0 0 4px color-mix(in oklch, var(--brand, var(--foreground)) 18%, transparent)\"\n                      : \"none\"\n                    : \"inset 0 0 0 1px var(--border)\",\n                }}\n              />\n\n              {/*\n               * O cartão do marco. A caixa existe em TODOS os degraus (mesmo\n               * recuo, mesma borda transparente) e só a pintura muda — se ela\n               * nascesse junto com o estado, o texto pularia 1rem pro lado toda\n               * vez que a rolagem trocasse o degrau atual.\n               */}\n              <div\n                className={cn(\n                  \"relative min-w-0 rounded-[var(--r-surface,0.875rem)] border px-3.5 py-2.5\",\n                  \"transition-[background-color,border-color]\",\n                  \"[transition-duration:var(--dur-base,200ms)]\",\n                  \"[transition-timing-function:var(--ease-out)]\",\n                  estado === ESTADO.agora ? \"border-border bg-muted/40\" : \"border-transparent\",\n                )}\n              >\n                {/* O fio da marca é o sinal do degrau atual — forma, não palavra.\n                    Encosta no trilho e é a única aresta pintada da coluna. */}\n                {estado === ESTADO.agora && (\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"absolute inset-y-2 left-0 w-[2px] rounded-full\"\n                    style={{ background: \"var(--brand, var(--foreground))\" }}\n                  />\n                )}\n\n                <div className=\"flex min-w-0 items-baseline gap-2\">\n                  {/* Data em mono tabular e caixa do consumidor: caixa alta com\n                      tracking largo aqui era a terceira etiqueta gritada da\n                      mesma linha. Mono e tabular já dizem \"isto é um dado\". */}\n                  <span className=\"font-mono text-[length:var(--text-micro,0.625rem)] tabular-nums text-muted-foreground\">\n                    {item.meta ?? `Etapa ${String(i + 1).padStart(2, \"0\")}`}\n                  </span>\n                  {/* Só pra leitor de tela: fio e fundo não são anunciados. */}\n                  {estado === ESTADO.agora && <span className=\"sr-only\">{ESTADO.agora}</span>}\n                </div>\n\n                <h3\n                  className={cn(\n                    \"mt-0.5 text-[length:var(--text-destaque,0.9375rem)] tracking-tight\",\n                    // O peso também é estado: o degrau atual é o único em\n                    // semibold, então a coluna tem um ponto de entrada só.\n                    estado === ESTADO.agora ? \"font-semibold\" : \"font-medium\",\n                    aceso ? \"text-foreground\" : \"text-muted-foreground\",\n                  )}\n                >\n                  {item.title}\n                </h3>\n\n                <div className=\"mt-1 min-w-0\">{item.content}</div>\n              </div>\n            </li>\n          );\n        })}\n        </ol>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Changelog, história da empresa ou processo longo com progresso amarrado ao scroll",
      "evite": "Poucos passos de como funciona: step-cards; arte presa avançando por passos, pinned-scene"
    }
  },
  "categories": [
    "conteudo",
    "scroll",
    "narrativa"
  ],
  "type": "registry:ui"
}