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>
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { useMarcaDaInstalacao } from "@/lib/branding/contexto";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface RecoveryCodesPanelProps {
|
|
codes: string[];
|
|
onAcknowledge: () => void;
|
|
}
|
|
|
|
/**
|
|
* Prefixo do arquivo baixado, derivado da marca da instalação.
|
|
*
|
|
* Este arquivo fica anos na pasta de downloads do usuário — é o artefato de marca
|
|
* mais duradouro que o produto entrega. Com o nome fixo, o cliente do revendedor
|
|
* baixa um arquivo com a NOSSA marca no nome, e não há atualização que conserte
|
|
* isso depois: o arquivo já está no disco dele.
|
|
*
|
|
* O acento é removido antes de filtrar (senão "Ótima Gestão" perderia o "O"
|
|
* inteiro em vez de virar "otima-gestao"), e uma marca sem nenhum caractere ASCII
|
|
* — nome em outro alfabeto, ou só emoji — cai em "crm", porque `download=""`
|
|
* faz o browser inventar um nome como "download.txt" e o usuário perde o arquivo
|
|
* de vista.
|
|
*/
|
|
export function prefixoDoArquivo(nome: string): string {
|
|
const slug = nome
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return slug || "crm";
|
|
}
|
|
|
|
/**
|
|
* One-time display of recovery codes. User must check the acknowledgement
|
|
* box before completing setup. Codes are shown in a 2x5 mono grid with copy
|
|
* + download options.
|
|
*/
|
|
export function RecoveryCodesPanel({ codes, onAcknowledge }: RecoveryCodesPanelProps) {
|
|
const [acked, setAcked] = useState(false);
|
|
// A marca vem por PROP do servidor (`lib/branding/contexto.tsx`), não de
|
|
// `branding()`: aquela função lê fontes diferentes no servidor e no navegador,
|
|
// e a divergência é hydration mismatch. Aqui ela só alimenta o nome do arquivo
|
|
// baixado, mas ler a fonte certa não é opcional por o efeito ser pequeno — é o
|
|
// mesmo hook que o resto da casca usa, e uma exceção aqui seria a próxima
|
|
// ocorrência a reabrir o defeito.
|
|
const marca = useMarcaDaInstalacao();
|
|
|
|
const handleCopy = async () => {
|
|
const ok = await copyToClipboard(codes.join("\n"));
|
|
if (ok) toast.success("Códigos copiados para a área de transferência.");
|
|
else toast.error("Não foi possível copiar. Selecione e copie manualmente.");
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${prefixoDoArquivo(marca.name)}-recovery-codes.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
toast.success("Arquivo baixado.");
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border border-border bg-muted/40 p-4">
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
Salve esses 10 códigos em um local seguro. Cada um pode ser usado{" "}
|
|
<strong>uma única vez</strong> para entrar caso você perca acesso ao autenticador.
|
|
Eles <strong>não serão mostrados novamente</strong>.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{codes.map((c, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-md border border-border bg-background px-3 py-2 text-center font-mono text-sm tracking-widest"
|
|
>
|
|
{c}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
|
|
Copiar todos
|
|
</Button>
|
|
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
|
|
Baixar .txt
|
|
</Button>
|
|
</div>
|
|
|
|
<label className="flex cursor-pointer items-start gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={acked}
|
|
onChange={(e) => setAcked(e.target.checked)}
|
|
className="mt-0.5 h-4 w-4 rounded-md border-border"
|
|
/>
|
|
<span>Salvei meus códigos em local seguro.</span>
|
|
</label>
|
|
|
|
<Button
|
|
type="button"
|
|
className={cn("w-full")}
|
|
disabled={!acked}
|
|
onClick={onAcknowledge}
|
|
>
|
|
Concluir
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|