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:
- Consistência — devs novos não devem reinventar layouts; mudanças de paleta atingem produto inteiro
- Customização — consultorias e clínicas querem identidade própria
- Performance — overhead de theming não pode degradar
- Governança LGPD — fontes externas em runtime vazam dados pra serviços terceiros (Google Fonts)
- 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)):
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
#RRGGBBvá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/interbundled. 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-xsatext-4xl) - ❌ Componentes primitivos — estendidos via
variantno 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
| Contexto | Branding aplicado |
|---|---|
/login no domínio platform | Platform default |
/consultor/<slug>/login | Consultor |
/clinica/<slug>/login | Clínica |
<consultor-slug>.clinicgestor.com | Consultor (F1.5+) |
<clinica-slug>.clinicgestor.com | Clí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/dashboard | Consultor |
/platform/dashboard | Platform 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(...),fontFamilyinlinelint:pages— toda rota envolve<PageContainer>(excetoauth/*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