{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-ticker",
  "title": "Status Ticker",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "O texto que segura a pessoa durante uma espera longa, em quatro desenhos: linha com cronômetro, barra que varre, etapas e pulso.",
  "registryDependencies": [
    "utils",
    "https://visant-ui.vercel.app/r/glitch-chars.json",
    "@visant/glitch-chars",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/status-ticker.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { EASE_VARREDURA_CSS } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { GlitchChars } from \"@/components/ui/glitch-chars\";\n\n/**\n * Linha de status para espera longa: ruído + verbo que gira + cronômetro.\n *\n * O problema que resolve não é técnico, é de percepção. Numa geração de 20–60s\n * uma barra de progresso mente (não existe progresso real para medir) e um\n * spinner some da consciência em 5s. Uma palavra que TROCA — \"Criando\",\n * \"Refinando\", \"Lapidando\" — mantém a leitura de que há trabalho acontecendo, e\n * o cronômetro dá ao usuário o direito de decidir se vale esperar.\n *\n * Os verbos entram por prop de propósito: são a voz da marca, não do componente.\n *\n * A troca é revelada caractere a caractere (7 passos de 50ms) em vez de cortar\n * seco — o corte seco lê como bug de render, a revelação lê como escrita.\n *\n * `prefers-reduced-motion`: sem ruído, sem revelação; a palavra troca direto e o\n * cronômetro continua (informação, não animação).\n *\n * ── Quatro jeitos de esperar, a mesma máquina por baixo ────────────────────\n *\n * | `variant` | quando |\n * |---|---|\n * | `linha` | o default: ruído, verbo e cronômetro numa linha só |\n * | `barra` | a mesma linha com uma faixa varrendo embaixo, pra quem sente falta de \"barra\" sem prometer porcentagem |\n * | `etapas` | os passos todos à vista: o que já foi, o que está indo e o que falta |\n * | `pulso` | sem ruído de máquina: um ponto que respira, pra produto mais quieto |\n *\n * As quatro leem o mesmo relógio e a mesma lista de `steps`; trocar de variante\n * não reinicia o cronômetro de quem já está esperando.\n */\n\n// Uma volta do indicador em laço, não transição de UI.\nconst VARREDURA_MS = 1600;\nexport type StatusTickerVariant = \"linha\" | \"barra\" | \"etapas\" | \"pulso\";\n\nexport function StatusTicker({\n  steps = [\"Criando\", \"Desenhando\", \"Refinando\", \"Compondo\", \"Lapidando\"],\n  rotateEverySec = 3,\n  showTimer = true,\n  variant = \"linha\",\n  loop = true,\n  className,\n}: {\n  /** Como a espera aparece. Ver a tabela acima. */\n  variant?: StatusTickerVariant;\n  /**\n   * Volta ao primeiro passo depois do último. `false` segura no último, que é\n   * o honesto quando a lista descreve um processo de verdade (`etapas`).\n   */\n  loop?: boolean;\n  /** Verbos rotativos. A voz da marca entra aqui. */\n  steps?: readonly string[];\n  /** Segundos em cada verbo antes de trocar. */\n  rotateEverySec?: number;\n  /** Cronômetro à direita — o que dá ao usuário a decisão de esperar. */\n  showTimer?: boolean;\n  className?: string;\n}) {\n  const [word, setWord] = useState(steps[0] ?? \"\");\n  const [dots, setDots] = useState(\".\");\n  const [timer, setTimer] = useState(\"0:00\");\n\n  // A palavra ASSENTADA, sem os quadros embaralhados da revelação. É ela que o\n  // leitor de tela recebe — anunciar \"cr*a-do\" seria pior que não anunciar.\n  const [alvo, setAlvo] = useState(steps[0] ?? \"\");\n  // O índice em estado (além do ref) porque `etapas` desenha a lista inteira.\n  const [idx, setIdx] = useState(0);\n\n  const startedAt = useRef<number | null>(null);\n  const wordIdx = useRef(0);\n  const revealTimer = useRef<ReturnType<typeof setInterval> | null>(null);\n\n  /*\n   * `steps` não pode entrar como dependência de efeito.\n   *\n   * O default é um literal de array: identidade nova a cada render. E o efeito\n   * abaixo faz `setState` a cada 380ms (os pontinhos), o que gera render, que\n   * gera array novo, que refaz o efeito — e o efeito reinicia `startedAt`. Na\n   * prática o cronômetro nunca passava de 0:00 e a palavra nunca girava, que é\n   * exatamente o que a peça vende. Quem passa `steps={[\"a\",\"b\"]}` inline caía no\n   * mesmo buraco. A chave de texto é estável; o conteúdo vive num ref.\n   */\n  const stepsKey = steps.join(\"|\");\n  const stepsRef = useRef(steps);\n  stepsRef.current = steps;\n  const loopRef = useRef(loop);\n  loopRef.current = loop;\n\n  useEffect(() => {\n    const primeiro = stepsRef.current[0] ?? \"\";\n    setWord(primeiro);\n    setAlvo(primeiro);\n    wordIdx.current = 0;\n    setIdx(0);\n  }, [stepsKey]);\n\n  useEffect(() => {\n    // `Date.now()` no efeito, não no render: em StrictMode/SSR o render roda\n    // duas vezes e o cronômetro começaria negativo.\n    startedAt.current = Date.now();\n\n    const reduced =\n      typeof window !== \"undefined\" &&\n      window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const reveal = (target: string) => {\n      setAlvo(target);\n      if (reduced) {\n        setWord(target);\n        return;\n      }\n      const totalSteps = 7;\n      const pool = \"abcdefghijklmnopqrstuvwxyz*•-□\";\n      let step = 0;\n      if (revealTimer.current) clearInterval(revealTimer.current);\n      revealTimer.current = setInterval(() => {\n        step += 1;\n        if (step >= totalSteps) {\n          if (revealTimer.current) clearInterval(revealTimer.current);\n          setWord(target);\n          return;\n        }\n        const revealed = Math.floor((step / totalSteps) * target.length);\n        setWord(\n          Array.from({ length: target.length }, (_, i) =>\n            i < revealed\n              ? target[i]\n              : pool[Math.floor(Math.random() * pool.length)],\n          ).join(\"\"),\n        );\n      }, 50);\n    };\n\n    const dotsId = reduced\n      ? null\n      : setInterval(() => setDots((d) => (d.length >= 1 ? \"\" : \".\")), 380);\n\n    // Contador próprio em vez de `sec % rotateEverySec`: um tick atrasado pelo\n    // navegador (aba de fundo, GC) pulava o resto da divisão e a palavra ficava\n    // parada mais um ciclo inteiro.\n    let desdeATroca = 0;\n\n    const tickId = setInterval(() => {\n      const sec = Math.floor((Date.now() - (startedAt.current ?? 0)) / 1000);\n      setTimer(`${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, \"0\")}`);\n\n      desdeATroca += 1;\n      const lista = stepsRef.current;\n      const noFim = !loopRef.current && wordIdx.current >= lista.length - 1;\n      if (desdeATroca >= rotateEverySec && lista.length > 1 && !noFim) {\n        desdeATroca = 0;\n        wordIdx.current = (wordIdx.current + 1) % lista.length;\n        setIdx(wordIdx.current);\n        reveal(lista[wordIdx.current]);\n      }\n    }, 1000);\n\n    return () => {\n      if (dotsId) clearInterval(dotsId);\n      clearInterval(tickId);\n      if (revealTimer.current) clearInterval(revealTimer.current);\n    };\n  }, [stepsKey, rotateEverySec]);\n\n  const relogio = showTimer ? (\n    <span aria-hidden=\"true\" className=\"ml-auto shrink-0 pl-3 text-[0.9em] tabular-nums text-muted-foreground\">\n      {timer}\n    </span>\n  ) : null;\n\n  // O que a região viva anuncia: só a palavra assentada. Sem isto o leitor de\n  // tela lia o cronômetro a cada segundo, para sempre.\n  const anuncio = <span className=\"sr-only\">{alvo}</span>;\n\n  // `min-w` impede o bloco inteiro de pular a cada troca de palavra.\n  const palavra = (\n    <span aria-hidden=\"true\" className=\"min-w-[18ch] text-foreground\">\n      {word}\n      {dots}\n    </span>\n  );\n\n  const base = \"font-mono text-[11px] font-bold uppercase text-muted-foreground\";\n  const ruido = <GlitchChars className=\"text-[var(--accent-ink,var(--muted-foreground))]\" />;\n\n  if (variant === \"etapas\") {\n    const lista = steps.length ? steps : [\"\"];\n    return (\n      <div role=\"status\" aria-live=\"polite\" className={cn(base, \"flex flex-col gap-2\", className)}>\n        {anuncio}\n        {lista.map((passo, i) => {\n          const feito = i < idx;\n          const agora = i === idx;\n          return (\n            <div key={`${passo}-${i}`} aria-hidden=\"true\" className=\"flex items-center gap-3\">\n              {/* Marcador em caixa fixa: o ruído, o ponto cheio e o anel ocupam\n                  a mesma largura, então a coluna de texto não dança. */}\n              <span className=\"grid w-[2ch] shrink-0 place-items-center\">\n                {agora ? (\n                  ruido\n                ) : (\n                  <span\n                    className={cn(\n                      \"size-1.5 rounded-full\",\n                      feito ? \"bg-muted-foreground\" : \"border border-border\",\n                    )}\n                  />\n                )}\n              </span>\n              {agora ? (\n                palavra\n              ) : (\n                <span className={cn(\"min-w-[18ch]\", feito ? \"text-muted-foreground line-through decoration-1\" : \"text-muted-foreground\")}>\n                  {passo}\n                </span>\n              )}\n              {agora ? relogio : null}\n            </div>\n          );\n        })}\n      </div>\n    );\n  }\n\n  if (variant === \"barra\") {\n    return (\n      <div role=\"status\" aria-live=\"polite\" className={cn(base, \"flex flex-col gap-2.5\", className)}>\n        <div className=\"flex items-center gap-3\">\n          {ruido}\n          {palavra}\n          {anuncio}\n          {relogio}\n        </div>\n        <Varredura />\n      </div>\n    );\n  }\n\n  if (variant === \"pulso\") {\n    return (\n      <div role=\"status\" aria-live=\"polite\" className={cn(base, \"flex items-center gap-3\", className)}>\n        <span aria-hidden=\"true\" className=\"relative grid size-2 shrink-0 place-items-center\">\n          <span className=\"absolute inset-0 rounded-full bg-[var(--brand,var(--foreground))] opacity-40 motion-safe:animate-ping\" />\n          <span className=\"relative size-2 rounded-full bg-[var(--brand,var(--foreground))]\" />\n        </span>\n        {palavra}\n        {anuncio}\n        {relogio}\n      </div>\n    );\n  }\n\n  return (\n    <div role=\"status\" aria-live=\"polite\" className={cn(base, \"flex items-center gap-3\", className)}>\n      {ruido}\n      {palavra}\n      {anuncio}\n      {relogio}\n    </div>\n  );\n}\n\n/**\n * A faixa que varre sem prometer número.\n *\n * Indeterminada de propósito: o ticker existe justamente pra espera em que não\n * há progresso real pra medir. Web Animations API em vez de keyframe CSS, pra\n * peça não depender de um bloco `css` instalado à parte. Sob\n * `prefers-reduced-motion` o segmento fica parado no meio.\n */\nfunction Varredura() {\n  const ref = useRef<HTMLSpanElement>(null);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof el.animate !== \"function\") return;\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const a = el.animate(\n      [{ transform: \"translateX(-100%)\" }, { transform: \"translateX(300%)\" }],\n      { duration: VARREDURA_MS, iterations: Infinity, easing: EASE_VARREDURA_CSS },\n    );\n    return () => a.cancel();\n  }, []);\n  return (\n    <span aria-hidden=\"true\" className=\"relative block h-[2px] w-full overflow-hidden rounded-full bg-muted\">\n      <span\n        ref={ref}\n        className=\"absolute inset-y-0 left-0 w-1/3 rounded-full bg-[var(--brand,var(--foreground))]\"\n        style={{ transform: \"translateX(100%)\" }}\n      />\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Espera longa de IA ou processamento, segurando a pessoa com texto, etapas ou cronômetro",
      "evite": "Mover arquivo: paper-shuttle; layout carregando, skeleton-rail; espera curta ao lado do rótulo, working-glyph"
    }
  },
  "categories": [
    "feedback",
    "espera",
    "ia",
    "estado"
  ],
  "type": "registry:ui"
}