{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sim-slider",
  "title": "Sim Slider",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "A dobra que deixa o visitante fazer a conta: controle acessível, projeção derivada do próprio valor, curva sem transição (pra não discordar do número) e premissa obrigatória por escrito.",
  "registryDependencies": [
    "utils",
    "slider",
    "@visant/sfx-bus"
  ],
  "files": [
    {
      "path": "registry/visant/ui/sim-slider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Slider } from \"@/components/ui/slider\";\nimport { playSfx } from \"@/lib/sfx-bus\";\nimport { cn } from \"@/lib/utils\";\n\n/** Dentes do anel de foco no curso inteiro: um `focus` a cada dente cruzado. */\nconst DENTES = 40;\n\n/**\n * Sim Slider — a dobra que deixa o visitante fazer a conta em vez de ler a conta.\n *\n * Uma dobra de prova afirma um número; um simulador entrega o número que a\n * pessoa foi buscar. É a peça que mais move intenção numa landing de serviço,\n * e é também a mais fácil de fazer errado — porque o erro dela não é visual, é\n * de honestidade.\n *\n * ── As três regras que o componente FORÇA ──────────────────────────────────\n *\n * 1. **`assumption` é obrigatório.** Não existe simulador sem premissa escrita.\n *    \"Você fatura R$450k\" sem dizer de onde saiu o multiplicador é chute com\n *    tipografia boa. A letra miúda não é rodapé legal: é o que separa uma\n *    ferramenta de uma promessa. Mesma disciplina do `@visant/measure-bar`, que\n *    se recusa a desenhar régua sem denominador honesto.\n * 2. **O resultado é derivado, nunca digitado.** `outputs[].value` recebe o\n *    valor do controle e devolve texto. Não há como a projeção discordar do\n *    controle, porque não existe segunda fonte.\n * 3. **A curva é opcional e some quando não há série honesta.** Sparkline\n *    decorativa — pontos inventados pra \"dar movimento\" — é a versão gráfica da\n *    barra de progresso falsa que para em 90%.\n * 4. **A curva tem teto desenhado.** Sem ele a peça quebra a própria regra: uma\n *    linha sozinha num retângulo é número sem régua. Pego na medição — no valor\n *    padrão a série ocupava 30% da caixa e lia como gráfico quebrado, quando na\n *    verdade dizia \"você está a 30% do teto da faixa\". Com o tracejado no topo,\n *    a mesma curva passa a afirmar isso.\n *\n * ── A escala vertical é FIXA, e é isso que a torna honesta ─────────────────\n *\n * `curve` devolve pontos de 0 a 1 e o desenho não renormaliza pelo máximo da\n * série atual. Se renormalizasse, arrastar o controle mudaria a forma da curva\n * sem mudar a projeção — a linha ficaria sempre igual de cheia, e crescimento\n * viraria enfeite. A escala é a faixa inteira do controle: no mínimo a curva\n * rasteja, no máximo ela encosta no teto. É o gesto que mostra o ganho.\n *\n * ── Motion: aqui o certo é quase não animar ────────────────────────────────\n *\n * Arrastar é gesto de alta frequência, e a regra da casa é clara: o que\n * acontece dezenas de vezes por minuto **não anima**. Então:\n *\n * · O número NÃO faz count-up a cada quadro. `@visant/count-up` é pra chegada\n *   (uma vez, na rolagem); usá-lo aqui deixaria o valor sempre correndo atrás\n *   do polegar, e o texto ilegível durante o arrasto inteiro.\n * · A curva também NÃO tem transição. Foi a primeira tentativa, e estava\n *   errada: com a curva assentando em 200ms e o número trocando na hora, os\n *   dois passam o arrasto inteiro discordando — o gráfico mostra um cenário e o\n *   texto mostra outro. Num simulador isso não é atraso, é informação falsa.\n *   Curva e número são a MESMA leitura, e leitura acompanha o dedo.\n * · O que anima é o que o gesto não controla: a curva ENTRA uma vez, na\n *   montagem, e nunca reinicia — trocar o atributo `points` não rearma uma\n *   animação CSS. É o raro `@keyframes` legítimo: ninguém interrompe uma\n *   entrada que acontece uma vez só.\n *\n *   Essa entrada era um traçado (`stroke-dasharray: 1` + `pathLength={1}` +\n *   `stroke-dashoffset` animado). **Não use.** Com `vector-effect:\n *   non-scaling-stroke` o dash passa a ser medido no espaço RENDERIZADO, não no\n *   do `viewBox`, então o comprimento normalizado deixa de valer e a linha sai\n *   picotada em marcha — e some junto a única coisa que a curva tinha que ser:\n *   contínua. Ou você tem traço honesto na diagonal (non-scaling-stroke), ou\n *   tem traçado animado. A escolha aqui é o traço, e a entrada virou fade.\n * · `tabular-nums` + largura travada pela maior leitura possível: sem isso o\n *   \"R$1.000\" → \"R$18.500\" empurra o rótulo vizinho a cada pixel de arrasto, e\n *   a dobra inteira treme.\n *\n * ── Por que o controle é o Slider do shadcn ────────────────────────────────\n *\n * Slider acessível é problema resolvido, e resolvê-lo de novo custa teclado,\n * `aria-valuetext`, RTL e arrasto com o ponteiro fora do trilho. Entra por\n * `registryDependencies`. O que este componente acrescenta é a ANATOMIA em\n * volta — leitura, projeção, curva e premissa — que é o que nenhuma lib traz.\n *\n * Roda do mouse ficou de fora de propósito: numa página que rola, roda sobre um\n * controle rouba a rolagem, e a saída (listener nativo com `passive: false`)\n * conserta o roubo criando outro problema — a pessoa que só queria descer a\n * página muda um número sem querer.\n */\n\nexport interface SimOutput {\n  label: string;\n  /** Recebe o valor do controle e devolve o texto já formatado. */\n  value: (v: number) => string;\n  /** Destaca esta leitura como a principal. Uma só, ou nenhuma. */\n  primary?: boolean;\n}\n\nexport interface SimSliderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  label: string;\n  min: number;\n  max: number;\n  step?: number;\n  defaultValue?: number;\n  /** Formata a leitura do próprio controle (o que a pessoa está movendo). */\n  format?: (v: number) => string;\n  outputs: SimOutput[];\n  /** A premissa, por escrito. Sem ela o simulador não é ferramenta, é promessa. */\n  assumption: string;\n  /**\n   * A série da curva para um dado valor, entre 0 e 1 cada ponto. Ausente, a\n   * curva não desenha — e isso é o certo quando não há projeção temporal real.\n   */\n  curve?: (v: number) => number[];\n  /**\n   * O que o topo da caixa significa. É a régua da curva — sem ela a linha é um\n   * traço num retângulo. Ausente, o tracejado ainda desenha, mudo.\n   */\n  ceilingLabel?: string;\n  onValueChange?: (v: number) => void;\n}\n\nexport function SimSlider({\n  label,\n  min,\n  max,\n  step = 1,\n  defaultValue,\n  format = (v) => String(v),\n  outputs,\n  assumption,\n  curve,\n  ceilingLabel,\n  onValueChange,\n  className,\n  ...props\n}: SimSliderProps) {\n  const [value, setValue] = React.useState(defaultValue ?? Math.round((min + max) / 2));\n\n  const dente = React.useRef<number | null>(null);\n  const handle = React.useCallback(\n    (v: number[]) => {\n      // Som de anel de foco de lente girando, por dente de curso. O portão de\n      // 45 ms do bus segura o arrasto rápido.\n      const d = Math.round(((v[0] - min) / (max - min || 1)) * DENTES);\n      if (dente.current !== d) playSfx(\"focus\");\n      dente.current = d;\n      setValue(v[0]);\n      onValueChange?.(v[0]);\n    },\n    [onValueChange, min, max],\n  );\n\n  // A largura de cada leitura é travada pela maior string possível — medida nos\n  // dois extremos da faixa, não no valor atual. `ch` de fonte tabular é uma\n  // régua confiável justamente porque todo dígito tem a mesma caixa.\n  const widths = React.useMemo(\n    () =>\n      outputs.map((o) => Math.max(o.value(min).length, o.value(max).length)),\n    [outputs, min, max],\n  );\n  const inputWidth = Math.max(format(min).length, format(max).length);\n\n  const points = React.useMemo(() => {\n    if (!curve) return null;\n    const serie = curve(value);\n    if (serie.length < 2) return null;\n    const passo = 100 / (serie.length - 1);\n    return serie\n      .map((n, i) => `${(i * passo).toFixed(2)},${(100 - Math.min(Math.max(n, 0), 1) * 100).toFixed(2)}`)\n      .join(\" \");\n  }, [curve, value]);\n\n  return (\n    <div\n      className={cn(\n        \"sim-slider flex flex-col gap-6 rounded-[var(--r-surface)] border border-[var(--border)]\",\n        \"bg-[var(--card)] p-[var(--pad-card,1.5rem)]\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-start justify-between gap-4\">\n        <div className=\"flex flex-col gap-1\">\n          <span className=\"text-[13px] text-[var(--muted-foreground)]\">{label}</span>\n          <span\n            className=\"[font-family:var(--font-display,inherit)] text-[clamp(1.75rem,1.4rem+1.4vw,2.5rem)] leading-none tabular-nums text-[var(--foreground)]\"\n            style={{ minWidth: `${inputWidth}ch` }}\n          >\n            {format(value)}\n          </span>\n        </div>\n\n        <dl className=\"flex flex-wrap items-end gap-x-8 gap-y-4\">\n          {outputs.map((o, i) => (\n            <div key={o.label} className=\"flex flex-col gap-1\">\n              <dt className=\"text-[13px] text-[var(--muted-foreground)]\">{o.label}</dt>\n              <dd\n                className={cn(\n                  \"tabular-nums\",\n                  /*\n                   * O número principal ganha TAMANHO e a fonte de display, não\n                   * a cor da marca. Marca clara pintando número grande é o\n                   * defeito preferido do simulador: o ciano da casa dá 1,29:1\n                   * sobre papel branco, e o valor que a peça inteira existe\n                   * para mostrar é o primeiro a sumir. A hierarquia aqui já\n                   * vem do corpo — 2,5rem contra 1,25rem é o dobro.\n                   */\n                  o.primary\n                    ? \"[font-family:var(--font-display,inherit)] text-[clamp(1.75rem,1.4rem+1.4vw,2.5rem)] text-[var(--foreground)]\"\n                    : \"text-[1.25rem] text-[var(--muted-foreground)]\",\n                  /* Depois do tamanho: o `cn` trata `text-[...]` como quem define a\n                     entrelinha e apaga um `leading-*` que venha antes. Sem esta\n                     ordem o valor da direita descia 7px em relação ao da esquerda. */\n                  \"leading-none\",\n                )}\n                style={{ minWidth: `${widths[i]}ch` }}\n              >\n                {o.value(value)}\n              </dd>\n            </div>\n          ))}\n        </dl>\n      </div>\n\n      {points ? (\n        <div className=\"relative\">\n          {ceilingLabel ? (\n            <span className=\"pointer-events-none absolute right-0 top-0 -translate-y-1/2 bg-[var(--card)] pl-2 text-[11px] text-[var(--muted-foreground)]\">\n              {ceilingLabel}\n            </span>\n          ) : null}\n          <svg\n            aria-hidden=\"true\"\n            viewBox=\"0 0 100 100\"\n            preserveAspectRatio=\"none\"\n            className=\"sim-slider-curve h-24 w-full\"\n          >\n            {/* O teto: a régua da curva. Fica a 1 unidade do topo porque em\n                `y=0` a metade de cima do traço sai do viewBox e some. */}\n            <line\n              className=\"sim-slider-ceiling\"\n              x1=\"0\"\n              y1=\"1\"\n              x2=\"100\"\n              y2=\"1\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            {/* A área sob a curva usa a cor da marca com opacidade — nunca um hex\n                próprio, senão o simulador é a única peça da página fora do tema. */}\n            <polygon className=\"sim-slider-area\" points={`0,100 ${points} 100,100`} />\n            {/* `vector-effect` porque o viewBox é esticado por `preserveAspectRatio:\n                none`: sem isso a linha engorda na horizontal e afina na vertical.\n                É também o que proíbe traçado animado aqui — ver o cabeçalho. */}\n            <polyline className=\"sim-slider-line\" points={points} vectorEffect=\"non-scaling-stroke\" />\n          </svg>\n        </div>\n      ) : null}\n\n      <Slider\n        value={[value]}\n        min={min}\n        max={max}\n        step={step}\n        onValueChange={handle}\n        aria-label={label}\n        aria-valuetext={format(value)}\n        // O Slider do shadcn vem com `touch-none`: a dobra inteira virava armadilha pra rolagem.\n        className=\"[touch-action:pan-y]! pointer-coarse:py-3.5\"\n      />\n\n      <p className=\"text-[12px] leading-[1.5] text-[var(--muted-foreground)]\">{assumption}</p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@layer components": {
      ".sim-slider-line": {
        "fill": "none",
        "stroke": "var(--brand)",
        "stroke-width": "2",
        "stroke-linejoin": "round",
        "stroke-linecap": "round",
        "animation": "sim-slider-fade var(--dur-slow, 300ms) var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)) both"
      },
      ".sim-slider-ceiling": {
        "stroke": "var(--border)",
        "stroke-width": "1",
        "stroke-dasharray": "3 4"
      },
      ".sim-slider-area": {
        "fill": "color-mix(in oklab, var(--brand) 14%, transparent)",
        "animation": "sim-slider-fade var(--dur-slow, 300ms) var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)) 400ms both"
      }
    },
    "@keyframes sim-slider-fade": {
      "from": {
        "opacity": "0"
      },
      "to": {
        "opacity": "1"
      }
    },
    "@media (prefers-reduced-motion: reduce)": {
      ".sim-slider-line, .sim-slider-area": {
        "animation": "none"
      }
    }
  },
  "meta": {
    "uso": {
      "quando": "Simulador de ROI ou economia numa landing: o visitante move o valor e vê a projeção com premissa",
      "evite": "Escolher valor sem projeção: elastic-slider; recomendar produto por medida, measure-picker"
    }
  },
  "categories": [
    "controles",
    "simulador",
    "calculadora",
    "slider",
    "projecao",
    "roi",
    "curva",
    "premissa"
  ],
  "type": "registry:ui"
}