mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
0872214dd6
commit
50c2017978
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user