Pular para o conteúdo

0003 — Design System & Theming Multi-Tenant

Status

Accepted · 2026-04-26 · Autor: Alexandre + Claude.

Contexto

Sistema multi-tenant exige branding por tenant em runtime. Princípios concorrentes:

  1. Consistência — devs novos não devem reinventar layouts; mudanças de paleta atingem produto inteiro
  2. Customização — consultorias e clínicas querem identidade própria
  3. Performance — overhead de theming não pode degradar
  4. Governança LGPD — fontes externas em runtime vazam dados pra serviços terceiros (Google Fonts)
  5. Acessibilidade — WCAG 2.1 AA é base

Decisão

1. Tokens em CSS custom properties

Não classes Tailwind hard-coded.

:root {
--color-primary: 222 47% 11%; /* navy do brand */
--color-accent: 38 92% 50%; /* gold do brand */
--color-background: 0 0% 100%;
--color-foreground: 222 47% 11%;
--color-success: 142 71% 45%; /* derivado */
--color-danger: 0 72% 51%; /* derivado */
--radius: 10px;
}
[data-theme='dark'] {
--color-background: 222 47% 11%;
--color-foreground: 0 0% 100%;
/* ... */
}

Tailwind preset (em @clinicgestor/config-tailwind) consome via hsl(var(--color-primary)):

preset.ts
colors: {
primary: 'hsl(var(--color-primary) / <alpha-value>)',
accent: 'hsl(var(--color-accent) / <alpha-value>)',
// ...
}

Resultado: bg-primary resolve dinamicamente. Mudar --color-primary em runtime → toda UI atualiza.

2. BrandingProvider (runtime)

apps/web/src/lib/branding-resolver.ts resolve tenant baseado em hostname/pathname:

export async function resolveBranding(hostname: string, pathname: string): Promise<Branding> {
// 1. F2: Custom domain
const customMatch = await lookupCustomDomain(hostname);
if (customMatch) return loadBrandingFor(customMatch);
// 2. F1.5: Subdomain (e.g., karla.clinicgestor.com)
const subdomainMatch = parseSubdomain(hostname);
if (subdomainMatch) {
const tenant = await lookupBySlug(subdomainMatch);
if (tenant) return loadBrandingFor(tenant);
}
// 3. F1: Path-based (/consultor/<slug> ou /clinica/<slug>)
const pathMatch = pathname.match(/^\/(consultor|clinica)\/([^/]+)/);
if (pathMatch) {
const tenant = await lookupBySlug(pathMatch[2], pathMatch[1]);
if (tenant) return loadBrandingFor(tenant);
}
// 4. Default — branding plataforma
return platformBranding;
}

<BrandingProvider> em apps/web/src/main.tsx envolve toda a aplicação. Injeta CSS custom properties em :root baseado no resultado.

3. Customização permitida

Cada consultancy e clinic armazena em branding jsonb:

{
"logo_url": "https://...supabase.co/storage/v1/object/public/consultancy-logos/...",
"primary_color": "#1e40af",
"accent_color": "#f59e0b"
}

Validações:

  • Logo: SVG ≤ 320×80 px ou PNG transparente, max 1MB. Validado em upload no Supabase Storage.
  • Cores: hex #RRGGBB válido. Se contraste com background derivado < 4.5 (WCAG AA), reject.
  • Cores derivadas (success, danger, surface, text) calculadas algoritmicamente a partir de primary + accent + theme (light/dark) com guarantees de contraste.

4. Customização proibida

  • Fonte — fixa em @fontsource-variable/inter bundled. Razões:
    • LGPD: fontes externas em runtime (Google Fonts CDN) vazam IP/User-Agent pra Google
    • Performance: bundling evita FOIT/FOUT
    • Legibilidade consistente cross-tenant
  • Tamanhos — escala mantida (text-xs a text-4xl)
  • Componentes primitivos — estendidos via variant no primitivo, não substituídos

5. 5 shells canônicos

  • AppShell (@clinicgestor/ui/AppShell) — clínica logada (/dashboard, /perfil, etc)
  • AuthShell — telas de auth (/login, /signup, /reset-password)
  • PublicShell — telas públicas SEM auth (/proposta/:token, /contrato/:token, /programa-acolhe, /consultores)
  • ConsultancyShell — consultor logado (/consultancy/dashboard, etc)
  • PlatformShell — platform_admin logado (/platform/...)

Nunca reimplementar sidebar/topbar/layout por página.

6. Hierarquia de branding por contexto

ContextoBranding aplicado
/login no domínio platformPlatform default
/consultor/<slug>/loginConsultor
/clinica/<slug>/loginClínica
<consultor-slug>.clinicgestor.comConsultor (F1.5+)
<clinica-slug>.clinicgestor.comClínica (F1.5+)
<consultor>.com.br (custom)Consultor (F2+)
/dashboard (clínica logada)Consultoria dela (sensação “rede”)
/perfil (clínica logada)Consultoria dela
/consultancy/dashboardConsultor
/platform/dashboardPlatform default
/consultores (marketplace público)Platform default
/consultores/<slug>Consultor
/programa-acolhe (vitrine)Platform default + Selo Acolhe destacado

7. Light + Dark + Mobile-first

  • data-theme="dark" em <html> ou <body> muda variável :root
  • Toggle de tema persistido em localStorage (theme: 'light' | 'dark' | 'system')
  • Default: sistema do user (prefers-color-scheme)
  • Toda tela testada nos 2 temas antes de PR (regra inviolável §5.8.4)
  • Mobile-first: regra §5.9 do AGENTS.md

8. Lints obrigatórios

  • lint:tokens — proíbe hex inline, style={{}} color, rgb(...), fontFamily inline
  • lint:pages — toda rota envolve <PageContainer> (exceto auth/* e _dev/*)

Consequências

Positivas

  • Mudança de paleta = editar 1 arquivo (globals.css)
  • Branding por tenant em runtime sem rebuild
  • Acessibilidade WCAG AA enforced por algoritmo de cores derivadas
  • Performance: bundling de fontes; CSS custom properties são nativas (zero overhead)
  • LGPD-safe: zero fontes externas em runtime

Negativas

  • Restrição forte (fonte fixa) pode frustrar consultor que quer “marca completa”
  • Algoritmo de cores derivadas complexo; requer testes de contraste

Riscos

  • CSS custom property em selector mais específico vence: cuidado com cascade. Mitigação: docs explícitos sobre onde definir tokens.
  • Bug em algoritmo de derivação: contraste insuficiente passa despercebido. Mitigação: test custom que valida WCAG AA pra cores comuns.

Alternativas consideradas

1. Tailwind classes hard-coded

bg-blue-700, text-amber-500, etc.

  • Prós: simplicidade
  • Contras: mudar paleta = refactoring 200 arquivos. Rejeitado.

2. Theme via class (em vez de data-attribute)

<html class="dark"> em vez de <html data-theme="dark">.

  • Prós: padrão Tailwind
  • Contras: classe pode conflitar com outras classes de tema/branding. data-attribute é mais semântico e seletor menos ambíguo.
  • Aceito por convenção mas equivalente tecnicamente.

3. Customização total (fonte, tamanhos, componentes)

  • Prós: liberdade total
  • Contras: governança UX impossível; cada consultor cria experiência diferente; quebra brand de plataforma. Rejeitado.

4. CSS-in-JS (styled-components, emotion)

  • Prós: dinamismo total em runtime
  • Contras: runtime overhead; não aproveita Tailwind do v1 reaproveitado; cache pior. Rejeitado.

Atualizações de documentos exigidas neste ADR

  • AGENTS.md §5.8 (Design System)
  • BLUEPRINT.md §6 (Branding hierárquico)
  • docs/design-system.md (detalhes de tokens, primitivos, shells)
  • docs/clinic/identidade-visual.md (UX de configurar branding)
  • docs/consultancy/configuracoes-branding.md (UX consultor)

Referências

  • ADR 0001 (multi-consultancy)
  • ADR 0012 (white-label faseamento)
  • AGENTS.md §5.8, §5.9