mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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>
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|