Files
DeskcommCRM/components/kanban/ScoreSlot.tsx
T
Rafael César Rocha MonteiroandClaude Opus 5 72d2ed4c35 build(tailwind): migra para o Tailwind 4 com config em CSS
O `tailwind.config.ts` deixa de existir. O que era `theme.extend` virou um
bloco `@theme inline` em `app/globals.css` — o modo `inline` é o que preserva o
tema, porque o utilitário recebe o `var(--color-…)` literal em vez de uma cópia
do valor claro. Os três blocos de token (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam exatamente onde estavam: `extrairRegua`
(lib/branding/contraste.ts) acha o bloco claro por igualdade EXATA de seletor, e
`branding-tema-claro-escopavel` compara prop a prop os blocos claro e escuro.

O que a migração destapou, e é o item de maior impacto visual:

  No v3, modificador de opacidade sobre cor declarada como `var(--…)` sem
  `<alpha-value>` fazia o Tailwind NÃO EMITIR a regra — em silêncio. Todo token
  deste produto é `var(--color-*)`, então 62 classes distintas em 252 usos eram
  letra morta: `bg-destructive/10` não pintava fundo nenhum, `border-destructive/30`
  caía na cor neutra da regra global de borda, `text-muted-foreground/60` herdava
  a cor do pai. O v4 resolve opacidade por `color-mix()` e as 252 passaram a
  pintar. Medido contra um `tailwindcss@3.4.19` de descarte: alimentado com 7
  classes, ele emitiu 4 — nenhuma das 3 com barra.

Renomes mecânicos (58 `rounded` puro → `rounded-md`, 22 `outline-none` →
`outline-hidden`, 4 `flex-shrink-0` → `shrink-0`). O `rounded` puro não podia
ficar: o v3 remapeava o DEFAULT para `--radius-md` (8px) e o v4 tem um `rounded`
embutido de 0.25rem que nem `@utility` sobrescreve — o raio encolheria pela
metade em 33 arquivos, sem erro nenhum.

Guardas novas em `tests/unit/tailwind-tokens.test.ts`, porque a migração troca um
arquivo que o TypeScript conferia por um que ninguém confere: reprova config em
JS de volta, `:root` embrulhado em `@layer` (que ligaria a auto-referência que o
`@theme inline` emite), token referenciado que não existe, pasta de UI fora do
`@source`, e a volta dos três utilitários renomeados.

`branding-rampa` fatiava o CSS com `indexOf('[data-theme="dark"]')` e quebrou:
o `@custom-variant` pôs essa string no topo do arquivo. Passa a recortar o
`:root` por casamento de chaves.

Provado na tela, em instalação fresca (baseline.sql + bootstrap-owner + Supabase
local), medindo por `getComputedStyle` e nunca a olho — onboarding completo em
claro e escuro, 0 erro de console. Sonda em `tests/sonda-tailwind-4.ts`,
capturas e medições em `evidence/tailwind-4/`.

NÃO verificado: o build da imagem Docker. Os binários musl de `@tailwindcss/oxide`
e `lightningcss` estão no lockfile para linux x64 e arm64, mas docker.io está
inalcançável nesta máquina — quem prova é o `imagens-ok` no PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:48:00 -03:00

123 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, type MouseEvent } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { bandLabel, type ScoreBand } from "@/lib/kanban/score-band";
interface Fator {
pontos: number;
frase: string;
ancora?: { kind: string; id: string };
}
interface ScoreSlotProps {
probability: number;
/** A faixa PERSISTIDA — este componente NÃO a deriva do número. */
band: ScoreBand;
reason: string;
/** Até três, as que existem. */
factors: Fator[];
}
/**
* A faixa ③ quando há score: medidor, número, e o porquê a um gesto de distância.
*
* UM COMPORTAMENTO, DOIS GATILHOS: clique/toque abre, e onde há ponteiro o
* hover também abre. Hover-only sumiria com as evidências no tablet, e decidir
* isso depois viraria retrabalho de layout dentro de um card de altura fixa —
* que é contrato do §5.
*
* A faixa vem PRONTA do banco. Derivá-la do número aqui ignoraria a histerese e
* traria de volta o card piscando na fronteira, no único lugar onde o CHECK de
* coerência não alcança.
*/
export function ScoreSlot({ probability, band, reason, factors }: ScoreSlotProps) {
const [aberto, setAberto] = useState(false);
const pare = (e: MouseEvent) => {
// O card inteiro seleciona ao clique; abrir o porquê não é selecionar.
e.stopPropagation();
};
return (
<Popover open={aberto} onOpenChange={setAberto}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
pare(e);
setAberto((v) => !v);
}}
onMouseEnter={() => setAberto(true)}
onMouseLeave={() => setAberto(false)}
// O rótulo acessível carrega o número E a faixa: "72%" lido sozinho
// não diz se é bom ou ruim para quem não vê a barra.
aria-label={`Probabilidade ${probability}%, ${bandLabel(band)}. Ver o porquê.`}
className="flex min-w-0 items-center gap-2 rounded-md text-left"
>
<span
className="h-[3px] w-16 shrink-0 overflow-hidden rounded-full bg-border"
aria-hidden
>
<span
className={cn(
"block h-full rounded-full",
band === "quente" && "bg-accent",
band === "morno" && "bg-warning",
band === "frio" && "bg-text-muted",
)}
style={{ width: `${probability}%` }}
/>
</span>
<span className="tabular-nums text-text-muted">{probability}%</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-xs"
onClick={pare}
data-testid="score-evidencias"
>
<p className="font-medium text-text">{bandLabel(band)} · {probability}%</p>
{reason !== "" && <p className="mt-1 text-text-muted">{reason}</p>}
{factors.length > 0 ? (
<ul className="mt-2 space-y-1">
{factors.map((f, i) => (
<li key={`${f.frase}-${i}`} className="flex items-baseline gap-2">
<span
className={cn(
"shrink-0 tabular-nums",
f.pontos > 0 ? "text-accent" : "text-warning-fg",
)}
>
{f.pontos > 0 ? "+" : "−"}
{Math.abs(f.pontos)}
</span>
<span className="min-w-0 flex-1 text-text">
{f.frase}
{/* O rótulo diz O QUE a evidência é. Chamar um checkpoint de
"momento da conversa" seria prometer um destino que ele não
tem — e fabricar âncora de mensagem para cumprir a frase é
pior que âncora nenhuma. */}
{f.ancora && (
<span className="ml-1 text-text-muted">
({f.ancora.kind === "message" ? "ver a mensagem" : "registro que sustenta"})
</span>
)}
</span>
</li>
))}
</ul>
) : (
// Vazio com DESENHO PRÓPRIO: não há evidência a mostrar, e dizer isso
// é melhor que deixar um espaço que parece defeito de carregamento.
<p className="mt-2 text-text-muted">Sem evidências registradas.</p>
)}
</PopoverContent>
</Popover>
);
}