feat(marca): tela /admin/marca — ver, mudar, e ver o estado quando falha

A tabela e a server action existiam sem chamador. Esta e a superficie: o dono
da instalacao troca o nome e a cor pela tela, em vez de SSH + reinicio da stack.

Gate is_platform_admin com notFound(), nao redirect('/403') — a existencia da
tela nao e assunto de quem nao e da plataforma (precedente: settings/atualizacao).
A porta e o AdminSidebar; NAO entra no registry.ts, que so cobre app/app/**
(confirmado lendo tests/unit/navegacao-completude.test.ts).

O BLOCO QUE ENSINA. O controle primario da cor e a tira dos 11 tons, com tres
marcacoes: "Sua cor", "Botoes no modo claro", "Botoes no modo escuro". Sem ela o
usuario cola um hex e nao entende por que o botao ficou de outro tom. A marcacao
usa frente calculada por luminancia, entao o contorno e visivel em qualquer marca.

LINGUAGEM E METADE DA ENTREGA. O leitor e dono de uma VPS, nao designer. Zero
"OKLCH", "rampa", "stop 600", "WCAG". A tela diz "o texto em cima dos botoes",
"passa em AA", "no modo claro os botoes usam um tom mais escuro que a sua cor —
e o que mantem o texto em cima deles legivel". Um teste guarda isso: a uniao dos
codigos do motor e Record<CodigoDaMarca, Traducao>, entao codigo novo em
lib/branding/ REPROVA o typecheck aqui em vez de vazar cru para a tela.

BLOCO DE ESTADO (invariante 6 — configuracao tem superficie): origem de cada
campo ("padrao do sistema" / "definido nesta tela" / "veio do arquivo de
instalacao"), contraste medido com veredito, o que o sistema ajustou, e o
fallback ativo com data e motivo. Fallback que ninguem ve e indistinguivel de a
feature nunca ter sido instalada.

E a tela DIZ O QUE AINDA NAO FAZ, embaixo do campo: hoje o nome alcanca o titulo
da aba; o menu e a tela de entrada ainda leem o .env. Sem isso o operador conclui
que quebrou.

DOIS DEFEITOS DE PROSA QUE SO A SONDA PEGOU (gates nao pegam texto errado):
1. A frase de deslocamento citava o tom padrao DAQUELE MODO. Medido com #f5c518:
   o modo escuro anda +2 e a tela dizia "um tom mais escuro da sua cor" quando o
   botao pousa EXATAMENTE na cor da pessoa. Agora a distancia e ate a cor dela.
2. Com marca neutra (#808080), a derivacao emite semantica_deslocada porque o
   verde DO PRODUTO colide com o verde de sucesso DO PRODUTO — e a tela dizia
   "sua cor ficou parecida com sucesso" sobre uma cor que nao pinta nada.
Ambos com teste, ambos sabotados para confirmar que reprovam (2/2 e 1/1).

PROVA EM TELA (Playwright, build de producao, login com MFA real):
  estado          swatches   --color-accent   titulo da aba
  inicial              1        #506d48        Marca da instalacao
  digitando #7a5cd6   15        #506d48
  hex invalido        --        Salvar DESABILITADO
  salvo               15        #604aa6        <- a cor muda no produto
  recarregado         15        #604aa6        <- persistiu

E o #604aa6 e exatamente o tom que a tela prometeu como "Botoes no modo claro":
a tela nao mentiu. Banco depois do salvar: accent_hex=#7a5cd6, seeded_from_env=f.
Zero jargao tecnico na tela, zero rolagem lateral. Evidencia em evidence/.

O primeiro run REPROVOU com "Could not find the table 'public.platform_branding'
in the schema cache" — o baseline tinha sido provado num Postgres descartavel,
mas o Supabase local nunca recebera a migration. Nao era bug do codigo; era o
ambiente. Aplicada a 0155 no banco local, o ciclo fechou.

Medido de brinde, nao consertado: `border-error-fg/30` nao gera CSS nenhum no
Tailwind 3.4 (modificador de opacidade sobre cor em var()). Duas telas do repo
usam a classe e estao sem borda — contacts/[id]/_client.tsx:64 e
AnonymizeDialog.tsx:102.

typecheck 0 · lint 0 · test:unit 380 files / 4326 passed · build EXIT=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCiffR3ugjQbxsfceQE2GB
This commit is contained in:
Rafael Melgaço
2026-08-13 17:48:56 -03:00
co-authored by Claude Opus 5
parent 0872214dd6
commit 50c2017978
13 changed files with 1204 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
/**
* O bloco de ESTADO — o que satisfaz o invariante 6 do Sistema Vivo.
*
* Uma feature de marca que só tem campos é indistinguível de uma que nunca foi
* instalada no dia em que ela degrada: o operador vê a cor do produto, conclui
* que o campo não funciona e vai embora. Este bloco existe para que TODO estado
* do sistema tenha uma frase na tela:
*
* - de onde veio cada campo (o `.env` ou esta tela — `seeded_from_env`);
* - quanto contraste a cor de fato entrega, medido, com veredito;
* - o que o sistema ajustou por conta própria, e por quê;
* - se a marca PAROU de ser aplicada — `fallback_at`, o alarme que
* `registrarEstadoDaMarca` acende no caminho de render e apaga sozinho
* quando a resolução volta a passar.
*
* ── O que aqui é AO VIVO e o que é o que está GRAVADO ─────────────────────────
*
* Contraste e ajustes descrevem a cor que está NO CAMPO — é assim que a pessoa
* decide antes de salvar. Origem e alarme descrevem o que está GRAVADO no banco,
* porque essas duas só mudam depois de salvar. Os títulos dizem qual é qual: um
* bloco que mistura as duas leituras é como se ensina alguém a não confiar na
* tela.
*/
import type { Marca, TokensDoTema } from "@/lib/branding/contraste";
import { Card } from "@/components/ui/card";
import { motivosDoFallback, type Aviso, type Tom } from "./_linguagem";
const CLASSE_DO_TOM: Record<Tom, string> = {
informativo: "text-text-muted",
atencao: "text-warning-fg",
problema: "text-error-fg",
};
interface Props {
/** De qual camada veio cada campo GRAVADO (`banco`, `env` ou `padrao`). */
readonly origens: { readonly nome: string; readonly logoUrl: string; readonly cor: string };
/** `false` quando a linha do banco é só uma cópia do arquivo de instalação. */
readonly definidoNestaTela: boolean;
/** Já formatado no servidor — data formatada no cliente diverge do HTML servido. */
readonly fallbackEm: string | null;
readonly fallbackMotivo: string | null;
/** A derivação da cor que está NO CAMPO. `null` = campo vazio ou cor inválida. */
readonly derivada: Marca | null;
readonly avisos: readonly Aviso[];
/** `false` quando a serialização recusaria esta cor — ela não chegaria à tela. */
readonly seriaAplicada: boolean;
}
/**
* O par que interessa a quem lê: o texto escrito EM CIMA da cor dos botões.
*
* `--color-accent-fg` é calculado (preto ou branco, o que ler melhor), então
* este número responde à única pergunta que a pessoa tem sobre a própria cor —
* "dá para ler o que está escrito no botão?". Os outros pares medidos (bordas,
* contorno de foco) não têm frase própria: quando algum deles reprova, o motor
* emite `accent_sem_contraste_possivel` e a lista de ajustes já o diz.
*/
function contrasteDoTexto(tokens: TokensDoTema) {
return tokens.pares.find((p) => p.papel === "--color-accent-fg") ?? null;
}
function LinhaDeContraste({ rotulo, tokens }: { rotulo: string; tokens: TokensDoTema }) {
const par = contrasteDoTexto(tokens);
if (!par) return null;
return (
<li className="flex flex-wrap items-baseline gap-x-2">
<span className="text-text-muted">{rotulo}:</span>
<span className="font-mono text-text">{par.razao.toFixed(1).replace(".", ",")}:1</span>
<span className={par.passa ? "text-success-fg" : "text-error-fg"}>
{par.passa ? "passa em AA" : "abaixo do mínimo AA"}
</span>
</li>
);
}
function origemEmPortugues(origem: string, definidoNestaTela: boolean): string {
// `banco` com `seeded_from_env` ligado NÃO é escolha de ninguém: é o valor do
// arquivo de instalação copiado para a tabela na primeira leitura. Chamar isso
// de "definido nesta tela" faria a origem mentir logo na instalação nova.
if (origem === "banco") {
return definidoNestaTela ? "definido nesta tela" : "veio do arquivo de instalação do servidor";
}
if (origem === "env") return "veio do arquivo de instalação do servidor";
return "padrão do sistema";
}
function LinhaDeOrigem({ campo, valor }: { campo: string; valor: string }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-border py-2 last:border-b-0">
<span className="text-sm text-text">{campo}</span>
<span className="text-sm text-text-muted">{valor}</span>
</div>
);
}
export function EstadoDaMarca({
origens,
definidoNestaTela,
fallbackEm,
fallbackMotivo,
derivada,
avisos,
seriaAplicada,
}: Props) {
return (
<section className="space-y-4" aria-labelledby="estado-da-marca">
<div>
<h2 id="estado-da-marca" className="text-base font-semibold tracking-tight text-text">
Como está agora
</h2>
<p className="text-sm text-text-muted">
O que o sistema está usando, o que ele mediu e o que ele ajustou sozinho.
</p>
</div>
{/* A moldura usa `border-error` puro, e não `border-error-fg/30` como duas
telas vizinhas fazem: MEDIDO no CSS gerado do build, o modificador de
opacidade sobre uma cor vinda de `var()` não produz regra nenhuma no
Tailwind 3.4 — aquelas telas estão sem borda e ninguém percebeu. Um
alarme não é o lugar de repetir esse erro. */}
{fallbackEm ? (
<Card className="border-error bg-error-bg p-4">
<p className="text-sm font-semibold text-error-fg">
A sua marca não está sendo aplicada.
</p>
<p className="mt-1 text-sm text-text">
Desde {fallbackEm} o sistema voltou a usar as cores padrão dele.
</p>
<ul className="mt-2 space-y-1">
{motivosDoFallback(fallbackMotivo).map((aviso) => (
<li key={aviso.texto} className="text-sm text-text-muted">
{aviso.texto}
</li>
))}
</ul>
<p className="mt-2 text-sm text-text-muted">
Salvar uma cor válida aqui apaga este alerta.
</p>
</Card>
) : null}
<Card className="p-4">
<h3 className="text-sm font-medium text-text">De onde vem cada coisa</h3>
<div className="mt-2">
<LinhaDeOrigem
campo="Nome do sistema"
valor={origemEmPortugues(origens.nome, definidoNestaTela)}
/>
<LinhaDeOrigem
campo="Logo"
valor={origemEmPortugues(origens.logoUrl, definidoNestaTela)}
/>
<LinhaDeOrigem
campo="Cor"
valor={origemEmPortugues(origens.cor, definidoNestaTela)}
/>
</div>
<p className="mt-3 text-xs text-text-muted">
O logo ainda é trocado no arquivo de instalação do servidor. Esta tela mostra de onde
ele vem para que o valor não pareça ter sumido.
</p>
</Card>
{derivada ? (
<Card className="p-4">
<h3 className="text-sm font-medium text-text">O texto em cima dos botões</h3>
<p className="mt-1 text-xs text-text-muted">
Quanto maior o número, mais fácil de ler. AA é o mínimo recomendado
internacionalmente para texto.
</p>
<ul className="mt-2 space-y-1 text-sm">
<LinhaDeContraste rotulo="No modo claro" tokens={derivada.claro} />
<LinhaDeContraste rotulo="No modo escuro" tokens={derivada.escuro} />
</ul>
<h3 className="mt-5 text-sm font-medium text-text">O que o sistema ajustou</h3>
{/* O caso "nada mudou" NÃO diz "sua cor é usada exatamente como você
escolheu": no modo escuro o produto usa um tom mais claro da
escala por desenho, e a frase absoluta seria desmentida pela
própria tira logo acima. */}
{avisos.length === 0 ? (
<p className="mt-1 text-sm text-text-muted">
Nada foi ajustado — a escala acima mostra onde a sua cor entra.
</p>
) : (
<ul className="mt-2 space-y-1.5">
{avisos.map((aviso) => (
<li key={aviso.texto} className={`text-sm ${CLASSE_DO_TOM[aviso.tom]}`}>
{aviso.texto}
</li>
))}
</ul>
)}
{!seriaAplicada ? (
<p className="mt-3 text-sm text-error-fg">
Do jeito que está, esta cor não chegaria à tela: o sistema continuaria com as cores
padrão dele.
</p>
) : null}
</Card>
) : null}
</section>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { updateBranding } from "@/app/actions/settings/updateBranding";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cssDaMarca } from "@/lib/branding/css";
import { ehHexValido, K, normalizarHex } from "@/lib/branding/rampa";
import { REGUA_DO_PRODUTO } from "@/lib/branding/regua-do-produto";
import { resolverMarca } from "@/lib/branding/resolve";
import { envelopeDeSemente } from "@/lib/branding/schema";
import { platformBrandingSchema, type PlatformBrandingInput } from "@/lib/schemas/settings";
import { EstadoDaMarca } from "./_estado";
import { avisosDaMarca, type DistanciaAteSuaCor } from "./_linguagem";
import { TiraDeTons, type ItemDaLegenda } from "./_tira";
export interface MarcaGravada {
readonly app_name: string | null;
readonly logo_url: string | null;
readonly accent_hex: string | null;
readonly show_powered_by: boolean;
}
interface Props {
/** A linha como está no banco. Campo não editado aqui volta igual no salvamento. */
readonly gravada: MarcaGravada;
/** O nome que a instalação exibe HOJE — vira placeholder, nunca texto fixo. */
readonly nomeEmVigor: string;
readonly origens: { readonly nome: string; readonly logoUrl: string; readonly cor: string };
readonly definidoNestaTela: boolean;
readonly fallbackEm: string | null;
readonly fallbackMotivo: string | null;
}
/** Mensagem por código de recusa da server action. */
const ERRO_EM_PORTUGUES: Record<string, string> = {
validation_failed: "Algum campo não está no formato esperado.",
unauthenticated: "Sua sessão expirou. Entre de novo para salvar.",
forbidden_role: "Só quem administra a instalação pode mudar a marca.",
};
/** Cinza médio para o seletor visual quando o campo ainda não tem cor válida. */
const COR_NEUTRA_DO_SELETOR = "#808080";
export function FormularioDaMarca({
gravada,
nomeEmVigor,
origens,
definidoNestaTela,
fallbackEm,
fallbackMotivo,
}: Props) {
const router = useRouter();
const [nome, setNome] = useState(gravada.app_name ?? "");
const [hex, setHex] = useState(gravada.accent_hex ?? "");
const [erroTecnico, setErroTecnico] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const hexLimpo = hex.trim();
const hexValido = hexLimpo.length === 0 || ehHexValido(hexLimpo);
/**
* A resolução AO VIVO, pelo mesmo caminho que o servidor usa no render.
*
* `resolverMarca` e não `derivarMarca` direto: assim o envelope, a validação e
* os motivos que a pessoa vê aqui são exatamente os que o `app/layout.tsx`
* produzirá depois de salvar. Duas montagens diferentes divergiriam justamente
* no caso de borda — e o caso de borda é o que esta tela existe para mostrar.
*
* A derivação inteira (duas caminhadas de contraste sobre todos os pares) roda
* a cada tecla, e isso é barato: são milhares de conversões de cor, não
* milhões, e o `useMemo` já corta a repetição enquanto o hex não muda.
*/
const resolvida = useMemo(
() =>
resolverMarca(
[{ origem: "tela", cor: hexLimpo.length > 0 ? envelopeDeSemente(hexLimpo) : undefined }],
REGUA_DO_PRODUTO,
),
[hexLimpo],
);
// A mesma serialização que decide se a cor chega ao `<style>` da página —
// rodada aqui para a tela poder dizer "esta cor não seria aplicada" ANTES de
// salvar, em vez de o operador descobrir pelo alarme de fallback depois.
const serializacao = useMemo(() => cssDaMarca(resolvida.cor), [resolvida.cor]);
const derivada = resolvida.cor?.derivada ?? null;
/**
* Em que degrau da escada cada papel pousou.
*
* O clamp não é zelo: com deslocamento grande o índice aritmético SAI do
* array (`#f5c518` anda +3 no modo claro e leva os papéis para além do 10), e
* o motor já resolve isso prendendo nas pontas — `stop()`, em `rampa.ts`. A
* tira tem de marcar o degrau que de fato pinta, não um que não existe.
*/
const degraus = useMemo(() => {
if (!derivada) return null;
const preso = (indice: number) => Math.max(0, Math.min(10, indice));
return {
// A cor da pessoa só ocupa um degrau quando a escada foi gerada a partir
// dela. Marca neutra usa a escada do produto, e ali ela não está.
suaCor: derivada.origemDaRampa === "semente" ? K : null,
claro: preso(REGUA_DO_PRODUTO.claro.indices.accent + derivada.claro.deslocamento),
escuro: preso(REGUA_DO_PRODUTO.escuro.indices.accent + derivada.escuro.deslocamento),
};
}, [derivada]);
// A distância é medida a partir da COR DA PESSOA, que é a referência de quem
// lê — e não a partir do tom padrão de cada modo, que é a referência do motor.
// Ver o comentário de `DistanciaAteSuaCor` em `_linguagem.ts`.
const distancia = useMemo<DistanciaAteSuaCor>(() => {
if (!degraus || degraus.suaCor === null) return { claro: null, escuro: null };
const suaCor = degraus.suaCor;
return { claro: degraus.claro - suaCor, escuro: degraus.escuro - suaCor };
}, [degraus]);
const avisos = useMemo(
() => avisosDaMarca([...resolvida.motivos, ...serializacao.motivos], distancia),
[resolvida.motivos, serializacao.motivos, distancia],
);
const legenda = useMemo<ItemDaLegenda[]>(() => {
if (!derivada || !degraus) return [];
return [
{
rotulo: "Sua cor",
hex: derivada.marca,
indice: degraus.suaCor,
nota: degraus.suaCor === null ? "fora da escala — fica só no logo" : undefined,
},
{ rotulo: "Botões no modo claro", hex: derivada.claro.accent, indice: degraus.claro },
{ rotulo: "Botões no modo escuro", hex: derivada.escuro.accent, indice: degraus.escuro },
];
}, [derivada, degraus]);
function handleSubmit(evento: React.FormEvent) {
evento.preventDefault();
setErroTecnico(null);
const candidato: PlatformBrandingInput = {
app_name: nome.trim() || null,
// O logo volta como está: esta tela ainda não o edita (o menu lateral lê o
// logo do arquivo de instalação, não do banco — um campo aqui salvaria um
// valor que nenhuma tela mostraria). Mandar o valor gravado é o que
// impede o `upsert` de apagá-lo.
logo_url: gravada.logo_url,
accent_hex: hexLimpo || null,
// Idem: `show_powered_by` não tem nenhum consumidor no produto hoje —
// medido, zero ocorrências fora do schema e da tabela. Um interruptor que
// não muda nada em tela nenhuma é pior que a ausência dele.
show_powered_by: gravada.show_powered_by,
};
const lido = platformBrandingSchema.safeParse(candidato);
if (!lido.success) {
toast.error("Confira os campos: algum valor não está no formato esperado.");
return;
}
startTransition(async () => {
const resultado = await updateBranding(lido.data);
if (resultado.ok) {
toast.success("Marca salva.");
// A origem e o alarme deste bloco vêm do servidor: sem o refresh eles
// continuariam descrevendo o estado de antes de salvar.
router.refresh();
return;
}
toast.error(ERRO_EM_PORTUGUES[resultado.error] ?? "Não consegui salvar a marca agora.");
// Recusa que esta tela não sabe nomear não pode virar só um toast genérico:
// o texto cru é o que torna o problema diagnosticável para quem tem acesso
// ao servidor. Falhar fechado na ação, aberto na informação.
if (!ERRO_EM_PORTUGUES[resultado.error]) setErroTecnico(resultado.error);
});
}
return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
<Card className="space-y-2 p-6">
<Label htmlFor="app_name">Nome do sistema</Label>
<Input
id="app_name"
value={nome}
onChange={(e) => setNome(e.target.value)}
placeholder={nomeEmVigor}
maxLength={120}
autoComplete="off"
/>
<p className="text-xs text-text-muted">
Deixe em branco para voltar ao nome padrão. Hoje este nome aparece no título da aba do
navegador; o menu e a tela de entrada ainda mostram o nome gravado no arquivo de
instalação do servidor.
</p>
</Card>
<Card className="space-y-4 p-6">
<div className="space-y-2">
<Label htmlFor="accent_hex">Cor da marca</Label>
<div className="flex items-center gap-3">
{/* Atalho, nunca o controle principal: o seletor do navegador escolhe
UM pixel e não mostra o que o sistema faz com ele. Quem ensina é a
tira abaixo. */}
<label
className="relative h-10 w-10 shrink-0 cursor-pointer overflow-hidden rounded-sm border border-border"
style={{
backgroundColor: ehHexValido(hexLimpo)
? normalizarHex(hexLimpo)
: COR_NEUTRA_DO_SELETOR,
}}
>
<span className="sr-only">Escolher a cor visualmente</span>
<input
type="color"
value={ehHexValido(hexLimpo) ? normalizarHex(hexLimpo) : COR_NEUTRA_DO_SELETOR}
onChange={(e) => setHex(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<Input
id="accent_hex"
value={hex}
onChange={(e) => setHex(e.target.value)}
placeholder="#7a5cd6"
spellCheck={false}
autoComplete="off"
aria-invalid={!hexValido}
aria-describedby="ajuda-da-cor"
className="w-36 font-mono"
/>
{hexLimpo.length > 0 && !hexValido ? (
<span className="text-sm text-error-fg">
Use um código de cor como #7a5cd6.
</span>
) : null}
</div>
<p id="ajuda-da-cor" className="text-xs text-text-muted">
Deixe em branco para voltar à cor padrão do sistema.
</p>
</div>
{derivada ? (
<div className="space-y-2">
<p className="text-sm text-text-muted">
A partir da sua cor o sistema monta esta escala e escolhe, dentro dela, o tom que
vai nos botões:
</p>
<TiraDeTons tons={derivada.rampa} legenda={legenda} />
{/* Fato de desenho do produto, não diagnóstico desta cor: o modo
escuro parte de um tom mais claro da escala por construção. Sem
esta linha, ver dois tons diferentes marcados na tira parece
incoerência do sistema. */}
<p className="text-xs text-text-muted">
No modo escuro o sistema usa naturalmente um tom mais claro da escala, para a cor
não se perder no fundo escuro.
</p>
</div>
) : (
<p className="text-sm text-text-muted">
Sem cor definida, o sistema usa a cor padrão dele.
</p>
)}
</Card>
<EstadoDaMarca
origens={origens}
definidoNestaTela={definidoNestaTela}
fallbackEm={fallbackEm}
fallbackMotivo={fallbackMotivo}
derivada={derivada}
avisos={avisos}
seriaAplicada={serializacao.css !== null}
/>
<div className="flex items-center justify-end gap-3">
{erroTecnico ? (
<span className="font-mono text-xs text-text-muted">{erroTecnico}</span>
) : null}
<Button type="submit" disabled={isPending || !hexValido}>
{isPending ? "Salvando…" : "Salvar"}
</Button>
</div>
</form>
);
}
+320
View File
@@ -0,0 +1,320 @@
/**
* O diagnóstico da marca traduzido para a língua de quem instalou o sistema.
*
* ── Por que um módulo, e não frases soltas no JSX ─────────────────────────────
*
* O motor da marca (`lib/branding/`) fala em código: `accent_deslocado`,
* `valor_fora_da_allowlist`, `marca_acromatica`. Quem lê esta tela é o dono de
* uma VPS — para ele "o accent foi deslocado" não é informação, é ruído. A
* tradução mora aqui por duas razões que um `switch` dentro do JSX não daria:
*
* 1. `TRADUCOES` é `Record<CodigoDaMarca, Traducao>`, e `CodigoDaMarca` é a
* UNIÃO dos três tipos de código que o motor emite. Código novo lá em cima
* REPROVA O TYPECHECK aqui — a tela não pode ficar muda sobre um estado que
* o motor passou a distinguir, que é exatamente como um alarme morre.
* 2. o vocabulário proibido (rampa, stop, OKLCH, ΔE, token, WCAG) vira asserção
* de teste sobre um objeto — `tests/unit/marca-linguagem.test.ts` —, em vez
* de disciplina de quem escreve JSX às pressas.
*
* ── Por que `silencio` e `com_contexto` carregam um `porque` escrito ──────────
*
* Mesma regra da `MARCA_CONGELADA` de `tests/unit/branding.test.ts`: uma decisão
* de NÃO mostrar algo precisa estar escrita, senão é indistinguível de
* esquecimento — e a próxima pessoa "conserta" acrescentando ruído.
*/
import type { CodigoDeMotivo, Tema } from "@/lib/branding/contraste";
import type { CodigoDoCss } from "@/lib/branding/css";
import type { CodigoDaResolucao } from "@/lib/branding/resolve";
/** Todo código que o motor da marca sabe emitir, nas três camadas. */
export type CodigoDaMarca = CodigoDaResolucao | CodigoDeMotivo | CodigoDoCss;
export type Tom = "informativo" | "atencao" | "problema";
/** Uma linha pronta para a tela. Nunca carrega código, nunca carrega jargão. */
export type Aviso = { readonly tom: Tom; readonly texto: string };
export type Traducao =
| { readonly modo: "frase"; readonly tom: Tom; readonly texto: string }
/** A frase depende de contexto (tema, sentido, agrupamento) e é montada em `avisosDaMarca`. */
| { readonly modo: "com_contexto"; readonly porque: string }
/** Deliberadamente mudo — outra frase já cobre o mesmo fato. */
| { readonly modo: "silencio"; readonly porque: string };
/** A mesma frase serve aos três códigos da serialização: para quem lê, o fato é um só. */
const BARRADO_NA_SEGURANCA =
"A verificação de segurança barrou o resultado antes de ele chegar à tela, " +
"e a marca não foi aplicada.";
export const TRADUCOES: Record<CodigoDaMarca, Traducao> = {
// ── resolução: o que a camada de configuração achou do que estava gravado ──
cor_ausente: {
modo: "silencio",
porque:
"não é ajuste nem recusa — significa que aquela fonte não declara cor. A tela já diz 'nenhuma cor definida' no lugar certo, e repetir isso como aviso ensina a ignorar avisos",
},
envelope_malformado: {
modo: "frase",
tom: "problema",
texto: "O valor gravado para a cor não está na forma que o sistema entende.",
},
semente_invalida: {
modo: "frase",
tom: "problema",
texto: "A cor gravada não é um código de cor válido.",
},
formato_desconhecido: {
modo: "frase",
tom: "problema",
texto: "A cor foi gravada por uma versão mais nova do sistema, e esta não sabe lê-la.",
},
algoritmo_desconhecido: {
modo: "frase",
tom: "informativo",
texto:
"A cor foi salva por outra versão do sistema. Ela continua valendo — esta versão " +
"recalcula os tons a partir dela.",
},
papel_desconhecido: {
modo: "frase",
tom: "problema",
texto:
"Esta versão do sistema não sabe onde aplicar a cor gravada, então ela não pinta a interface.",
},
papel_nao_pinta: {
modo: "frase",
tom: "informativo",
texto: "A cor está guardada só como identidade: ela aparece no logo, mas não pinta os botões.",
},
derivacao_falhou: {
modo: "frase",
tom: "problema",
texto: "O cálculo dos tons a partir dessa cor não terminou.",
},
// ── derivação: o que o sistema fez com a cor para ela caber na interface ──
marca_acromatica: {
modo: "frase",
tom: "atencao",
texto:
"Sua cor é um tom neutro (cinza, preto ou branco), e uma cor assim não destaca nada " +
"na tela. Os botões seguem com a cor padrão do sistema, e a sua fica reservada ao logo.",
},
accent_deslocado: {
modo: "com_contexto",
porque:
"a frase muda com o modo (claro ou escuro) e com o sentido do ajuste — 'um tom mais escuro' e 'um tom mais claro' são as duas respostas certas, em situações opostas",
},
accent_sem_contraste_possivel: {
modo: "frase",
tom: "problema",
texto:
"Não existe tom desta cor que deixe todos os elementos legíveis. Alguns detalhes — " +
"como o contorno que marca o campo em foco — ficam difíceis de enxergar.",
},
semantica_deslocada: {
modo: "com_contexto",
porque:
"as cores de alerta afetadas entram numa frase só; uma linha por cor e por modo daria até oito avisos dizendo o mesmo",
},
redundancia_nao_cromatica_necessaria: {
modo: "com_contexto",
porque:
"é o caso extremo do anterior (não há afastamento possível) e entra na mesma frase agrupada",
},
// ── serialização: a última porta antes de a cor virar pixel ──
nome_de_token_invalido: { modo: "frase", tom: "problema", texto: BARRADO_NA_SEGURANCA },
valor_fora_da_allowlist: { modo: "frase", tom: "problema", texto: BARRADO_NA_SEGURANCA },
saida_suspeita: { modo: "frase", tom: "problema", texto: BARRADO_NA_SEGURANCA },
token_nao_serializado: {
modo: "silencio",
porque:
"é o outro lado do aviso sobre as cores de alerta, que já aparece agrupado; dizer o mesmo fato duas vezes só faz a lista crescer até ninguém ler",
},
};
/** Como o dono da instalação chama cada cor de estado do sistema. */
const NOME_DA_COR_DE_ALERTA: Record<string, string> = {
success: "sucesso",
warning: "atenção",
error: "erro",
info: "informação",
};
function ehCodigoConhecido(codigo: string): codigo is CodigoDaMarca {
return Object.hasOwn(TRADUCOES, codigo);
}
/** "a" · "a e b" · "a, b e c" */
function listar(itens: readonly string[]): string {
if (itens.length <= 1) return itens[0] ?? "";
return `${itens.slice(0, -1).join(", ")} e ${itens[itens.length - 1] ?? ""}`;
}
/**
* Dedup por TEXTO, não por código: três códigos distintos da serialização dizem
* a mesma frase de propósito, e `accent_sem_contraste_possivel` sai uma vez por
* tema. Repetir a linha idêntica faria a lista parecer pior do que o problema é.
*/
function semRepetir(avisos: readonly Aviso[]): Aviso[] {
const vistos = new Set<string>();
return avisos.filter((a) => {
if (vistos.has(a.texto)) return false;
vistos.add(a.texto);
return true;
});
}
/** A forma mínima que as três camadas de motivo compartilham. */
export type MotivoLido = {
readonly codigo: CodigoDaMarca;
readonly tema?: Tema | null;
readonly alvo?: string | null;
};
/**
* Onde o tom dos botões caiu em relação à COR DA PESSOA, por modo.
*
* Positivo = mais escuro que a cor dela; negativo = mais claro; zero = é a cor
* dela. `null` = a cor não está na escala mostrada — o caso da marca neutra, em
* que a escala é a do produto e comparar com "a sua cor" não faria sentido.
*
* ── Por que NÃO é o deslocamento que o motor devolve ──────────────────────────
*
* `TokensDoTema.deslocamento` conta a partir do tom PADRÃO DAQUELE MODO, não da
* cor da pessoa — e os dois modos partem de tons diferentes de propósito (o
* escuro parte de um mais claro, para a cor não sumir no fundo). Usá-lo direto
* produziu uma frase falsa, medida com `#f5c518`: o modo escuro anda +2 e a
* frase dizia "um tom mais escuro da sua cor", quando o botão pousa EXATAMENTE
* na cor dela. Quem lê a tela compara com a própria cor; a referência da frase
* tem de ser a mesma que a do leitor.
*/
export type DistanciaAteSuaCor = {
readonly claro: number | null;
readonly escuro: number | null;
};
/**
* A lista de frases que a tela mostra sob "o que o sistema fez com esta cor".
*
* Recebe os motivos JÁ EMITIDOS pelo motor, e não a cor: assim a tela nunca
* conclui coisa diferente do que o sistema decidiu. A distância entra separada
* porque o motivo carrega o TEMA, não o sentido — e sem o sentido a frase diria
* "um tom diferente", que não explica nada.
*/
export function avisosDaMarca(
motivos: readonly MotivoLido[],
distancia: DistanciaAteSuaCor,
): Aviso[] {
const avisos: Aviso[] = [];
const coresDeAlerta = new Set<string>();
let saiuDaSuaCor = false;
/**
* `null` nos dois modos significa que a cor da pessoa NÃO está na escala em
* uso — a interface segue com a do produto. Toda frase que começa com "sua
* cor" fica falsa aí, e a razão é a mesma nos dois casos que dependem disto:
* MEDIDO com `#808080` e `#ffffff`, a derivação emite `semantica_deslocada`
* porque o verde do PRODUTO colide com o verde de sucesso do PRODUTO — uma
* propriedade da nossa paleta, que existia antes de a pessoa colar cor
* nenhuma. Dizer "sua cor ficou parecida com sucesso" ali culpa quem não fez
* nada, e manda procurar problema no lugar errado.
*/
const suaCorPinta = distancia.claro !== null || distancia.escuro !== null;
for (const motivo of motivos) {
const traducao = TRADUCOES[motivo.codigo];
if (traducao.modo === "silencio") continue;
if (traducao.modo === "frase") {
avisos.push({ tom: traducao.tom, texto: traducao.texto });
continue;
}
if (motivo.codigo === "accent_deslocado") {
const noEscuro = motivo.tema === "escuro";
const degraus = noEscuro ? distancia.escuro : distancia.claro;
// Ver `suaCorPinta`: comparar com "a sua cor" apontaria para uma cor que
// não está em degrau nenhum da escala mostrada.
if (degraus === null) continue;
const modo = noEscuro ? "escuro" : "claro";
if (degraus === 0) {
// O sistema mexeu na escala, mas o botão pousou na cor dela. Dizer que
// houve ajuste aqui assustaria sem motivo — e calar deixaria o modo sem
// nenhuma frase, como se a tela não soubesse responder por ele.
avisos.push({
tom: "informativo",
texto:
`No modo ${modo}, os botões usam exatamente a sua cor — o sistema conferiu que o ` +
`texto em cima dela fica legível.`,
});
continue;
}
saiuDaSuaCor = true;
avisos.push({
tom: "informativo",
texto:
`No modo ${modo}, os botões usam um tom ${degraus > 0 ? "mais escuro" : "mais claro"} ` +
`que a sua cor — é o que mantém o texto em cima deles legível.`,
});
continue;
}
const nome = NOME_DA_COR_DE_ALERTA[motivo.alvo ?? ""];
if (nome && suaCorPinta) coresDeAlerta.add(nome);
}
// Só quando o botão de fato NÃO é a cor dela: senão a frase consolaria de uma
// perda que não houve, e frase de consolo desnecessária é o que faz alguém
// procurar um problema que não existe.
if (saiuDaSuaCor) {
avisos.push({
tom: "informativo",
texto: "A sua cor original continua no logo e nos destaques.",
});
}
if (coresDeAlerta.size > 0) {
avisos.push({
tom: "atencao",
texto:
`Sua cor ficou parecida com a que o sistema usa para indicar ` +
`${listar([...coresDeAlerta].sort())}. Nesta versão ele ainda não afasta esses tons ` +
`sozinho — vale conferir se os avisos continuam fáceis de distinguir.`,
});
}
return semRepetir(avisos);
}
/**
* Traduz `platform_branding.fallback_reason` — a coluna que diz por que a marca
* PAROU de ser aplicada.
*
* O formato é o que `motivoDoFallback` grava: códigos separados por vírgula,
* cada um podendo trazer `@alvo` colado. O alvo é nome de coisa interna
* (`--color-accent`) e é descartado aqui de propósito: para quem lê, ele não
* acrescenta nada e desfaz o resto da frase.
*
* Código desconhecido NÃO vira silêncio. O alarme está aceso — se esta versão
* não sabe explicá-lo, ela diz isso, em vez de mostrar um bloco de alerta vazio
* que parece defeito da tela.
*/
export function motivosDoFallback(motivo: string | null): Aviso[] {
const avisos: Aviso[] = [];
for (const parte of (motivo ?? "").split(",")) {
const codigo = (parte.split("@")[0] ?? "").trim();
if (codigo.length === 0) continue;
const traducao = ehCodigoConhecido(codigo) ? TRADUCOES[codigo] : null;
avisos.push(
traducao?.modo === "frase"
? { tom: "problema", texto: traducao.texto }
: {
tom: "problema",
texto:
"O sistema recusou a cor gravada por um motivo que esta versão não sabe explicar.",
},
);
}
return semRepetir(avisos);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* A tira dos 11 tons — o controle PRINCIPAL da cor, e não um enfeite.
*
* ── Por que a tira, e não um seletor de cor ───────────────────────────────────
*
* O sistema não usa o hex do cliente cru: ele gera uma escada de 11 tons a
* partir dele e escolhe, dentro dela, qual tom vira a cor dos botões — que pode
* NÃO ser o tom que a pessoa colou (`escolherAccent`, em
* `lib/branding/contraste.ts`, desliza a escada até o texto em cima dos botões
* ficar legível). Sem ver a escada e as duas marcações, o operador cola um roxo,
* vê um roxo mais escuro no botão e conclui que o produto errou a cor dele.
*
* Com a tira, a mesma informação vira uma frase óbvia sem precisar de frase: sua
* cor está ali, o botão é aquele outro degrau, os dois são a mesma cor em
* intensidades diferentes.
*
* ── Por que a marcação é desenhada com `melhorFrenteSobre` ────────────────────
*
* Uma borda de cor fixa some: branca desaparece nos tons claros da escada, preta
* some nos escuros — e a escada tem as duas pontas por construção. A mesma
* função que decide a cor do texto sobre o botão decide aqui a cor da marcação,
* então ela é visível em qualquer marca. É o design system aplicado ao próprio
* controle que o explica.
*/
import { melhorFrenteSobre } from "@/lib/branding/contraste";
/**
* Um papel que a tira aponta. `indice` é o degrau da escada, ou `null` quando a
* cor existe mas está FORA dela — o caso da marca neutra, em que a escada
* mostrada é a do produto e a cor do cliente não tem degrau nenhum.
*/
export type ItemDaLegenda = {
readonly rotulo: string;
readonly hex: string;
readonly indice: number | null;
/** Observação curta, quando o papel precisa de uma ressalva. */
readonly nota?: string;
};
interface Props {
readonly tons: readonly string[];
readonly legenda: readonly ItemDaLegenda[];
}
export function TiraDeTons({ tons, legenda }: Props) {
const marcados = new Set(
legenda.map((item) => item.indice).filter((i): i is number => i !== null),
);
return (
<div className="space-y-3">
<div
role="img"
aria-label={
`Escala de ${tons.length} tons gerada a partir da sua cor, do mais claro ao mais ` +
`escuro. Os tons em uso estão descritos na lista abaixo.`
}
className="flex overflow-hidden rounded-md border border-border"
>
{tons.map((hex, i) => (
<div
key={`${i}-${hex}`}
title={hex}
className="h-14 flex-1"
style={{
backgroundColor: hex,
// `inset` em vez de `outline`: o contorno externo seria cortado
// pelo `overflow-hidden` que arredonda as pontas da tira.
boxShadow: marcados.has(i) ? `inset 0 0 0 3px ${melhorFrenteSobre(hex)}` : undefined,
}}
/>
))}
</div>
<ul className="flex flex-wrap gap-x-6 gap-y-2">
{legenda.map((item) => (
<li key={item.rotulo} className="flex items-center gap-2 text-xs">
<span
aria-hidden
className="h-4 w-4 shrink-0 rounded-sm border border-border"
style={{ backgroundColor: item.hex }}
/>
<span className="text-text">{item.rotulo}</span>
<span className="font-mono text-text-muted">{item.hex}</span>
{item.nota ? <span className="text-text-muted">— {item.nota}</span> : null}
</li>
))}
</ul>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { notFound } from "next/navigation";
import { loadAuthUser } from "@/lib/auth/server";
import { marcaDaInstalacao } from "@/lib/branding/instalacao";
import { REGUA_DO_PRODUTO } from "@/lib/branding/regua-do-produto";
import { camadaDaInstalacao, camadaDoAmbiente, resolverMarca } from "@/lib/branding/resolve";
import { env } from "@/lib/env";
import { FormularioDaMarca } from "./_form";
export const metadata = { title: "Marca da instalação" };
export const dynamic = "force-dynamic";
/**
* Formata o instante do alarme AQUI, no servidor, e manda a string pronta.
*
* Formatar no cliente pareceria mais simples e traria um defeito conhecido: o
* HTML servido usa o fuso do contêiner e a hidratação usa o do navegador, então
* a mesma linha renderiza diferente dos dois lados. Fuso fixo porque a coluna é
* da INSTALAÇÃO — não há organização resolvida nesta tela de onde tirar um.
*/
function instanteLegivel(iso: string | null): string | null {
if (!iso) return null;
return new Date(iso).toLocaleString("pt-BR", {
timeZone: "America/Sao_Paulo",
dateStyle: "short",
timeStyle: "short",
});
}
/**
* A tela onde o dono da instalação troca a marca do produto.
*
* ── Por que `/admin`, e não `/app/settings` ───────────────────────────────────
*
* O que se edita aqui é a marca da INSTALAÇÃO — a que pinta o login, o e-mail de
* recuperação e a tela de erro, superfícies anteriores a qualquer organização.
* Num revendedor que hospeda várias empresas, dar isso ao admin de um tenant
* seria dar a um cliente o controle da fachada dos outros. O papel é o
* transversal, e o lugar dele no produto é o admin de plataforma. A marca POR
* organização é outra coisa e é a fase seguinte.
*
* ── Por que `notFound()`, e não `redirect('/403')` ────────────────────────────
*
* Mesma escolha de `app/app/settings/atualizacao/page.tsx`: para quem não
* administra a instalação, esta tela simplesmente não faz parte do produto — a
* existência dela não é assunto dele.
*
* O layout de `(protected)` já roda `requirePlatformAdmin()`, então este gate é
* redundante HOJE. Ele fica porque a garantia precisa ser local: um layout pode
* ser movido, e a única regra que não depende de vizinho é a que a própria
* página aplica.
*/
export default async function Page() {
const usuario = await loadAuthUser();
if (!usuario?.is_platform_admin) notFound();
const linha = await marcaDaInstalacao();
// A MESMA pilha do `app/layout.tsx` — banco acima, arquivo de instalação
// embaixo. Montar outra aqui faria a tela relatar uma precedência que o
// produto não usa, que é a pior mentira possível numa tela de diagnóstico.
const marca = resolverMarca(
[camadaDaInstalacao(linha), camadaDoAmbiente(env)],
REGUA_DO_PRODUTO,
);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Marca</h1>
<p className="mt-1 text-sm text-text-muted">
O nome e a cor que este sistema mostra para todo mundo que usa esta instalação.
</p>
</div>
<FormularioDaMarca
gravada={{
app_name: linha?.app_name ?? null,
logo_url: linha?.logo_url ?? null,
accent_hex: linha?.accent_hex ?? null,
// `true` é o default da coluna: sem linha ainda, é o valor que o
// `upsert` gravaria de qualquer forma.
show_powered_by: linha?.show_powered_by ?? true,
}}
nomeEmVigor={marca.name}
origens={marca.origens}
// `seeded_from_env` ligado significa que a linha é cópia do arquivo de
// instalação, não escolha de alguém nesta tela. Sem linha, também não é.
definidoNestaTela={linha !== null && !linha.seeded_from_env}
fallbackEm={instanteLegivel(linha?.fallback_at ?? null)}
fallbackMotivo={linha?.fallback_reason ?? null}
/>
</div>
);
}
+6
View File
@@ -11,6 +11,7 @@ import {
ChartBar,
Users,
ShieldCheck,
Palette,
ArrowRight,
} from "@/lib/ui/icons";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
@@ -33,6 +34,11 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/admin/usage", label: "Usage", icon: ChartBar },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/platform-admins", label: "Platform Admins", icon: ShieldCheck },
// A porta da tela de marca. Ela NÃO entra em `lib/navigation/registry.ts`:
// aquele registro descreve a navegação do tenant (`app/app/**`) e o teste de
// completude que o vigia varre só aquela raiz. O admin de plataforma tem
// navegação própria, e é esta lista.
{ href: "/admin/marca", label: "Marca", icon: Palette },
];
interface AdminSidebarProps {
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+2
View File
@@ -32,6 +32,8 @@ export {
Gauge,
WifiSlash,
Clock,
// marca da instalação (o revendedor troca nome e cor do produto)
Palette,
// health dashboard
WifiHigh,
Brain,
+190
View File
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import {
avisosDaMarca,
motivosDoFallback,
TRADUCOES,
type MotivoLido,
} from "@/app/admin/(protected)/marca/_linguagem";
/**
* A guarda da LÍNGUA da tela de marca.
*
* Quem lê `/admin/marca` é o dono de uma VPS, não um designer. "O accent foi
* deslocado dois stops na rampa" é uma frase correta que não informa nada a ele
* — e é exatamente a frase que sai quando alguém traduz um código novo com
* pressa, copiando o vocabulário do motor.
*
* A EXAUSTIVIDADE não é testada aqui de propósito: `TRADUCOES` é
* `Record<CodigoDaMarca, Traducao>` sobre a união dos três tipos de código do
* motor, então código novo em `lib/branding/` reprova o `pnpm typecheck`. Um
* teste de runtime sobre a mesma propriedade seria mais fraco (só pegaria o que
* alguém lembrasse de listar) e daria a sensação de já estar coberto.
*/
/**
* O vocabulário que NÃO pode chegar ao operador. Cada termo aqui foi tirado do
* próprio motor: são as palavras que existem em `lib/branding/` e que, copiadas
* para a tela, transformam um diagnóstico em ruído.
*/
const JARGAO_PROIBIDO =
/\boklch\b|\brampa\b|\bstop\b|ΔE|\bwcag\b|\btoken\b|\bcss\b|var\(--|\baccent\b|\bhex\b|allowlist|serializ|acrom[áa]t|dicromac/i;
function frases(): string[] {
return Object.values(TRADUCOES).flatMap((t) => (t.modo === "frase" ? [t.texto] : []));
}
describe("tradução dos códigos da marca", () => {
it("a varredura de frases não está vazia — senão o resto não prova nada", () => {
// Guarda de vacuidade: `TRADUCOES` só tem entradas `com_contexto`/`silencio`
// num futuro em que alguém esvaziou o mapa, e aí o teste de jargão passaria
// sobre uma lista de zero frases.
expect(frases().length).toBeGreaterThanOrEqual(8);
});
it("nenhuma frase carrega o vocabulário interno do motor", () => {
const ruins = frases().filter((texto) => JARGAO_PROIBIDO.test(texto));
expect(ruins, `frase com jargão:\n ${ruins.join("\n ")}`).toEqual([]);
});
it("o regex de jargão de fato reprova — controle positivo", () => {
// Sem isto, um regex quebrado devolveria zero achados e pareceria aprovação.
expect(JARGAO_PROIBIDO.test("o accent andou 2 stops na rampa (ΔE alto)")).toBe(true);
});
it("toda decisão de não mostrar algo vem com o porquê escrito", () => {
const mudas = Object.entries(TRADUCOES).filter(
([, t]) => t.modo !== "frase" && t.porque.trim().length < 40,
);
expect(mudas.map(([codigo]) => codigo)).toEqual([]);
});
});
const DESLOCADO_NOS_DOIS_MODOS: MotivoLido[] = [
{ codigo: "accent_deslocado", tema: "claro", alvo: "--color-accent" },
{ codigo: "accent_deslocado", tema: "escuro", alvo: "--color-accent" },
];
describe("avisosDaMarca", () => {
it("diz 'mais escuro' quando o botão desceu na escala e 'mais claro' quando subiu", () => {
const avisos = avisosDaMarca(DESLOCADO_NOS_DOIS_MODOS, { claro: 3, escuro: -1 });
expect(avisos.some((a) => /modo claro.*mais escuro que a sua cor/.test(a.texto))).toBe(true);
expect(avisos.some((a) => /modo escuro.*mais claro que a sua cor/.test(a.texto))).toBe(true);
// O fecho só aparece quando o botão de fato deixou de ser a cor da pessoa.
expect(avisos.some((a) => a.texto.includes("continua no logo"))).toBe(true);
});
it("distância zero: o botão É a cor da pessoa, e a frase não inventa ajuste", () => {
// MEDIDO com `#f5c518` (amarelo): o modo escuro anda +2 a partir do tom
// padrão dele e pousa EXATAMENTE na cor da pessoa. A versão anterior desta
// função lia o deslocamento do motor e dizia "um tom mais escuro da sua
// cor" — falso, e falso justamente na cor que mais estressa o contraste.
const avisos = avisosDaMarca(DESLOCADO_NOS_DOIS_MODOS, { claro: 3, escuro: 0 });
expect(avisos.some((a) => /modo escuro.*exatamente a sua cor/.test(a.texto))).toBe(true);
expect(avisos.some((a) => /modo escuro.*mais (escuro|claro)/.test(a.texto))).toBe(false);
});
it("sem ajuste nenhum além do que pousou na própria cor, não consola de perda que não houve", () => {
const avisos = avisosDaMarca(
[{ codigo: "accent_deslocado", tema: "claro", alvo: "--color-accent" }],
{ claro: 0, escuro: 0 },
);
expect(avisos.some((a) => a.texto.includes("continua no logo"))).toBe(false);
});
it("marca neutra: nenhuma frase começa com 'sua cor', porque ela não pinta nada", () => {
// MEDIDO com `#808080` e `#ffffff`: a escala em uso é a do produto, e a
// derivação emite `semantica_deslocada` porque o VERDE DO PRODUTO colide com
// o verde de sucesso do PRODUTO — colisão que existia antes de a pessoa
// colar cor nenhuma. As duas frases contextuais culpariam quem não fez nada.
const avisos = avisosDaMarca(
[
...DESLOCADO_NOS_DOIS_MODOS,
{ codigo: "semantica_deslocada", tema: "claro", alvo: "success" },
{ codigo: "marca_acromatica", tema: null, alvo: null },
],
{ claro: null, escuro: null },
);
expect(avisos).toHaveLength(1);
expect(avisos[0]?.texto).toContain("tom neutro");
expect(avisos.some((a) => a.texto.startsWith("Sua cor ficou parecida"))).toBe(false);
});
it("não promete afastar as cores de alerta — esta versão não afasta", () => {
// `semantica_deslocada` é emitido pela derivação, mas `lib/branding/css.ts`
// NÃO serializa as semânticas nesta fase (ele mesmo emite
// `token_nao_serializado` dizendo isso). Uma frase no passado — "afastamos o
// tom de erro" — descreveria um pixel que a tela não pinta.
const avisos = avisosDaMarca(
[
{ codigo: "semantica_deslocada", tema: "claro", alvo: "error" },
{ codigo: "semantica_deslocada", tema: "escuro", alvo: "error" },
{ codigo: "token_nao_serializado", alvo: "error" },
],
{ claro: 0, escuro: 0 },
);
expect(avisos).toHaveLength(1);
expect(avisos[0]?.texto).toContain("ainda não afasta");
expect(avisos[0]?.texto).toContain("erro");
});
it("agrupa as cores de alerta numa frase só, ordenadas", () => {
const avisos = avisosDaMarca(
[
{ codigo: "semantica_deslocada", tema: "claro", alvo: "warning" },
{ codigo: "semantica_deslocada", tema: "claro", alvo: "error" },
{ codigo: "redundancia_nao_cromatica_necessaria", tema: "escuro", alvo: "success" },
],
{ claro: 0, escuro: 0 },
);
expect(avisos).toHaveLength(1);
expect(avisos[0]?.texto).toContain("atenção, erro e sucesso");
});
it("não repete a mesma frase quando dois temas emitem o mesmo código", () => {
const avisos = avisosDaMarca(
[
{ codigo: "accent_sem_contraste_possivel", tema: "claro", alvo: null },
{ codigo: "accent_sem_contraste_possivel", tema: "escuro", alvo: null },
],
{ claro: 0, escuro: 0 },
);
expect(avisos).toHaveLength(1);
expect(avisos[0]?.tom).toBe("problema");
});
it("fica calado no caso normal — cor que coube sem ajuste nenhum", () => {
expect(avisosDaMarca([], { claro: 0, escuro: 0 })).toEqual([]);
});
});
describe("motivosDoFallback", () => {
it("traduz o formato gravado na coluna, com e sem alvo colado", () => {
// `motivoDoFallback` (lib/branding/instalacao.ts) grava
// "codigo, codigo@alvo" — o alvo é nome de coisa interna e não vai à tela.
const avisos = motivosDoFallback("semente_invalida, papel_desconhecido@--color-brand");
expect(avisos).toHaveLength(2);
expect(avisos.map((a) => a.texto).join(" ")).not.toContain("--color-brand");
expect(avisos.every((a) => a.tom === "problema")).toBe(true);
});
it("colapsa os três códigos da verificação de segurança numa linha", () => {
const avisos = motivosDoFallback(
"nome_de_token_invalido, valor_fora_da_allowlist, saida_suspeita",
);
expect(avisos).toHaveLength(1);
});
it("código desconhecido produz frase, nunca silêncio", () => {
// O alarme está aceso. Um bloco de alerta sem nenhuma linha embaixo parece
// defeito da tela e é onde o operador para de acreditar no alarme.
const avisos = motivosDoFallback("motivo_de_outra_versao");
expect(avisos).toHaveLength(1);
expect(avisos[0]?.texto).toContain("não sabe explicar");
});
it("sem alarme, nenhuma linha", () => {
expect(motivosDoFallback(null)).toEqual([]);
expect(motivosDoFallback("")).toEqual([]);
});
});