Files
Rafael MelgaçoandClaude Fable 5.1 15f0ba77c8 feat(marca): o produto ganha símbolo e logotipo — na barra, na fachada, no favicon e nos READMEs
Os SVGs desenhados pelo dono do produto entram em docs/brand/ como fonte.
O app NÃO os serve: a geometria vive em lib/branding/desenho.ts e é desenhada
inline por components/branding/MarcaDoProduto.tsx (barra lateral aberta e
recolhida, fachada de entrada, cabeçalhos do admin e do onboarding) e por
app/icon.tsx (satori aceita <svg> inline — medido). Cores seguem o tema pela
classe `dark:`, com os graus da régua do produto.

A condição é marcaEhADoProduto (lib/branding.ts): só sem logo E com o nome
padrão. Quem trocou o nome ou subiu logo não vê nada disto — provado pela
tela com platform_branding.app_name gravado (evidence/marca/*-revendedor.png).
Nada em public/: um .svg fixo ali seria a nossa marca na instalação de todo
revendedor, o vazamento que tests/unit/branding.test.ts vigia (segue em zero
ocorrências novas — o alt vem do nome resolvido, nunca de string fixa).

Também: og-card.html e og-social-preview.png regenerados com o logotipo
real; READMEs pt/en/es com <picture> claro/escuro; .changes/ com o
fragmento (nada_mudou / alterado).

Gates: typecheck limpo; lint 0 erros; pnpm test:unit 7967 passed, 6 failed
— 5 em lib/ai/dispatcher/rate-limit.test.ts (UPSTASH no .env.local sem Redis,
o falso vermelho documentado no CLAUDE.md) e 1 em evidencia-citada, que era
os SVGs ainda untracked e passa depois do add.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011W9v8akzZKhpwQw265hxCr
2026-09-08 11:42:39 -03:00

142 lines
6.8 KiB
TypeScript

import { ImageResponse } from "next/og";
import { marcaEhADoProduto } from "@/lib/branding";
import { CORES_DA_MARCA, SIMBOLO } from "@/lib/branding/desenho";
import { letraDoIcone } from "@/lib/branding/icone";
import { marcaDaSaida, NEUTROS_DE_SAIDA } from "@/lib/branding/saida";
/**
* O ícone da aba, DESENHADO em runtime com a marca da instalação.
*
* ─── O que existia antes: nada ──────────────────────────────────────────────
*
* Zero `app/icon.*`, zero `app/favicon.ico`, zero `public/favicon*` (medido:
* `public/` tem dois arquivos, `.gitkeep` e `llms.txt`). O navegador pedia
* `/favicon.ico` por conta própria e recebia 404 — em produção, 19.435 bytes,
* porque o 404 é a `app/not-found.tsx` INTEIRA servida para um pedido de
* ícone. Na prática: aba sem marca nenhuma, para nós e para todo revendedor.
*
* ─── Por que GERADO, e não um arquivo em `public/` ──────────────────────────
*
* `Dockerfile:75-79` copia `public/` para a imagem final, e a imagem é UMA SÓ
* para todas as marcas — a mesma tag do GHCR que cada clone puxa. Um
* `favicon.ico` estático resolveria o 404 e entregaria a NOSSA marca na aba de
* todo revendedor, que é o mesmo modo de falha que `lib/branding.ts:12-16`
* documenta para `NEXT_PUBLIC_*`: verde em dev, verde no CI, verde na Vercel, e
* errado exatamente na VPS de quem a feature existe para servir.
*
* ─── Cor + inicial, NUNCA o `logo_url` ──────────────────────────────────────
*
* `platform_branding.logo_url` é `text` livre, sem CHECK de host
* (`supabase/baseline.sql:11832-11848`). Buscá-la aqui seria uma requisição de
* saída disparada pelo `<head>` de TODA página, com a URL vinda de um campo que
* o operador digita — SSRF com gatilho em cada page load. Derivar o ícone de
* cor + inicial não toca a rede: o accent vem do mesmo resolvedor que pinta os
* e-mails (`marcaDaSaida`) e a fonte (`Geist-Regular.ttf`) vem embutida no
* `@vercel/og` que o Next já traz — nenhuma dependência nova, nenhum download.
*
* ─── O símbolo do produto, quando a marca é a do produto ────────────────────
*
* Sem nome nem logo configurados (`marcaEhADoProduto`), o ladrilho é o símbolo
* de `lib/branding/desenho.ts` sobre o creme da régua — o mesmo desenho que a
* barra lateral e a fachada mostram, para a aba e a tela contarem a mesma
* marca. O satori aceita `<svg>` inline (medido: 1.135 bytes de PNG válido com
* o símbolo, em 2026-09-08), então continua sem rede e sem arquivo em `public/`.
* Quem configurou um nome próprio segue com cor + inicial: o símbolo soletra
* "D", e um "D" na aba de quem se chama "Acme" seria a nossa marca vazando.
*
* ─── `force-dynamic` não é zelo ─────────────────────────────────────────────
*
* O loader de metadata NÃO injeta `force-static` na variante gerada por código
* (`next-metadata-route-loader.js`, `getSingleImageRouteCode`), mas também não
* a torna dinâmica sozinha — sem esta linha o `next build` congelaria o ícone
* dentro da imagem pré-buildada, com a marca de quem buildou. E o defeito seria
* invisível em dev, em teste e na Vercel: só apareceria na VPS do revendedor,
* que é o único lugar onde a marca é outra. O loader re-exporta todo named
* export do arquivo do usuário (`:43`), então declarar aqui basta.
*
* ─── Custo ──────────────────────────────────────────────────────────────────
*
* Um `ImageResponse` por requisição a `/icon`. O `Cache-Control` abaixo é o que
* mantém isso em uma renderização por minuto por navegador em vez de uma por
* navegação. A leitura da marca é a MESMA que o `generateMetadata` do layout já
* faz, memoizada por 30s (`lib/branding/instalacao.ts:209`) — nenhuma consulta
* a mais no banco.
*
* ⚠️ `/icon` precisa estar em `PUBLIC_PATHS` (`lib/auth/public-paths.ts`): o
* matcher do `proxy.ts:128` só dispensa caminho COM extensão, e `/icon` não tem
* — sem a entrada, o ícone responde 307 para `/login` a quem ainda não entrou,
* que é exatamente a primeira tela que um comprador vê.
*/
export const dynamic = "force-dynamic";
/** 64 e não 32: a aba pede 16-32 CSS px, e em tela retina isso são 32-64 reais. */
export const size = { width: 64, height: 64 };
export const contentType = "image/png";
export default async function Icon() {
const marca = await marcaDaSaida(null);
if (marcaEhADoProduto({ name: marca.nome, logoUrl: marca.logoUrl })) {
// 78% da aresta: o D ocupa ~75% do próprio viewBox, então sobra o mesmo
// respiro que a letra tem no ramo de baixo.
const lado = Math.round(size.width * 0.78);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: NEUTROS_DE_SAIDA.fundo,
}}
>
<svg viewBox={SIMBOLO.viewBox} width={lado} height={lado}>
<g fill={CORES_DA_MARCA.claro.simbolo} transform={SIMBOLO.transform}>
<path d={SIMBOLO.d} />
<rect {...SIMBOLO.modulo} />
</g>
</svg>
</div>
),
{ ...size, headers: CACHE },
);
}
const letra = letraDoIcone(marca.nome);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: marca.accent,
color: marca.accentFg,
// 62% da altura: a caixa maiúscula do Geist ocupa ~72% do em, então
// a letra fica com respiro sem virar um selo minúsculo no meio.
fontSize: Math.round(size.height * 0.62),
// O ladrilho é quadrado e cheio: o navegador já arredonda o favicon
// no chrome dele, e arredondar aqui também produz canto duplo.
borderRadius: 0,
}}
>
{letra ?? ""}
</div>
),
{ ...size, headers: CACHE },
);
}
// 60s é deliberado, e o par com o TTL da marca: o operador que troca a cor em
// `/admin/marca` vê a aba acompanhar dentro de um minuto. Um `immutable` de um
// ano tornaria a tela de marca uma promessa que o ícone não cumpre; `no-store`
// faria o satori rodar a cada navegação.
const CACHE = { "cache-control": "public, max-age=60, stale-while-revalidate=600" };