# Prototypes — Gotchas catalog

> **Catálogo vivo de bugs visuais e suas correções canônicas.**
>
> Cada entry: sintoma, causa raiz, fix canônico, audit hook.
> Quando descobrir gotcha novo durante prototyping, adicionar aqui ANTES de fechar PR. Consultado pelo audit script + skill [zapnotei-prototype](../../../../.claude/skills/zapnotei-prototype/SKILL.md) antes de gerar surface nova.

---

## Layout & Spacing

### G-001 — Margin em grid item `1fr` invade o gap

**Sintoma:** OTP slots (ou cards em grid) renderizam com larguras desiguais quando 1 deles tem `margin-right` aplicado pra criar separator visual.

**Causa raiz:** `display: grid` com track `1fr` distribui espaço APÓS subtrair gaps. Margin em item afeta sua track box, mas não acumula visualmente como em flex — slot fica menor pra compensar margin.

**Fix canônico:** Usar `display: flex` com `flex: 1; min-width: 0` em items, NÃO grid quando precisar margin per-item entre tracks.

```css
/* ❌ ERRADO — grid invade gap */
.otp-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.otp-row .otp-slot:nth-child(3) { margin-right: 8px; } /* não funciona como esperado */

/* ✅ CORRETO — flex acumula margin */
.otp-row { display: flex; gap: 8px; }
.otp-slot { flex: 1; min-width: 0; aspect-ratio: 1 / 1; }
.otp-row .otp-slot:nth-child(3) { margin-right: 8px; } /* extra spacing 3+3 group */
```

**Audit hook:** [`compare-surfaces.js`](../../../../design-system/shared/scripts/compare-surfaces.js) compara aspect ratio de `.otp-slot:nth-child(1)` ↔ `:nth-child(6)` — drift > 0.05 sinaliza grid bug.

**Caso real:** 2026-04-26 OTP row inconsistente entre `01.2-otp-verify` e `01.3-totp-2fa`.

---

### G-002 — Letter-spacing negativo corta caractere final

**Sintoma:** Último caractere de heading com `letter-spacing: -0.5` ou maior aparece cortado na renderização (visível em fonts heavy weight tipo Poppins 700).

**Causa raiz:** Browser não compensa letter-spacing negativo no último caractere — o glyph fica fora do bounding box visual.

**Fix canônico:** Adicionar `padding-right` igual ao módulo do letter-spacing × N caracteres restantes (rule of thumb: `padding-right: 4-8px`) OU usar letter-spacing menor (`-0.2`).

```css
.auth-title {
  font-weight: 600;
  letter-spacing: -0.2px;          /* ✅ leve, sem corte */
  /* OU */
  letter-spacing: -0.5px;
  padding-right: 4px;              /* ✅ compensação */
}
```

**Audit hook:** Visual review obrigatório em headings ≥18px com letter-spacing < -0.3.

---

### G-003 — Margin-collapse vertical entre block adjacents

**Sintoma:** Espaço entre 2 elementos block adjacentes aparece menor que esperado (margin "soma errada").

**Causa raiz:** Margins verticais de elementos block adjacentes colapsam — só o maior dos 2 vence.

**Fix canônico:** Usar `gap` com flex/grid container em vez de margin-bottom + margin-top em filhos.

```css
/* ❌ ERRADO — margins colapsam */
.field { margin-bottom: 14px; }
.field-error { margin-top: 6px; }   /* não soma 20px, vira 14px */

/* ✅ CORRETO — gap não colapsa */
.auth-form { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
```

---

### G-004 — `aspect-ratio: 1 / 1` em flex item colapsa sem `min-width: 0`

**Sintoma:** OTP slots renderizam altura zero ou comprimidos demais em containers narrow.

**Causa raiz:** Flex item com intrinsic min-content > flex-basis ignora aspect-ratio shrink.

**Fix canônico:** SEMPRE pareiar `flex: 1; min-width: 0; aspect-ratio: 1 / 1; min-height: 48px` (fallback height pra browsers sem aspect-ratio).

```css
.otp-slot {
  flex: 1;
  min-width: 0;          /* libera shrink */
  aspect-ratio: 1 / 1;
  min-height: 48px;      /* fallback pra @supports not (aspect-ratio) */
}
@supports not (aspect-ratio: 1) {
  .otp-slot { height: 56px; }
}
```

---

### G-005 — Centralização SVG falha por viewBox descentralizado

**Sintoma:** Logo aparenta deslocada dentro do container flex `justify-content: center`, mesmo com markup correto.

**Causa raiz:** SVG content extent (`getBBox()`) ≠ viewBox extent — paths com margin assimétrico dentro do viewBox.

**Fix canônico:** Validar via DevTools `el.getBBox()` que `bbox.x + bbox.width/2 ≈ viewBox.width/2` (tolerância 2px). Se assimétrico, ajustar paths OU recortar viewBox.

```js
// chrome-devtools MCP evaluate_script
const svg = document.querySelector('.auth-logo svg');
const bb = svg.getBBox();
const vb = svg.viewBox.baseVal;
const offsetX = (bb.x + bb.width / 2) - (vb.width / 2);
const offsetY = (bb.y + bb.height / 2) - (vb.height / 2);
console.log({ offsetX, offsetY });    // esperado |x| + |y| < 2
```

**Caso real:** Logo do DS corrigido em 2026-04-26.

---

## Typography & Form

### G-006 — Input com `font-size < 16px` em iOS dispara zoom auto

**Sintoma:** iOS Safari faz zoom in ao tocar em input com font-size pequeno, atrapalha UX form.

**Causa raiz:** iOS auto-zooms inputs com font-size < 16px pra "ajudar" leitura.

**Fix canônico:** SEMPRE `font-size: 16px` em `.field-input` e similares. NUNCA 14px ou menor.

```css
.field-input {
  font-size: 16px;       /* ✅ anti-zoom iOS */
  /* … */
}
```

**Audit hook:** Audit script poderia checar `font-size: 1[0-5]px` em selectors `.field-input|.otp-slot|input|textarea`.

---

## State & Persistence

### G-007 — localStorage / cookie missing entre surfaces durante navegação real

**Sintoma:** User digita email em `01.1-auth-login`, navega pra `01.2-otp-verify`, email aparece vazio.

**Causa raiz:** Submit do form usa `<form action="next.html">` (form submit clássico) que não preserva state JS. Ou JS não foi invocado antes do navigate.

**Fix canônico:** Usar helper `ZNProto.navigateNext('01.2-…', { withState: true })` de [`proto-interactions.js`](../../../../design-system/shared/proto-interactions.js) que persiste em `localStorage[zn-proto-flow]` ANTES de navegar.

```js
// ❌ ERRADO — perde state
form.addEventListener('submit', (e) => { window.location.href = 'next.html'; });

// ✅ CORRETO — persiste antes de navegar
form.addEventListener('submit', (e) => {
  e.preventDefault();
  ZNProto.setFlowState({ email: input.value });
  ZNProto.navigateNext('01.2-auth-otp-verify', { withState: true });
});
```

---

## Color & Theming

### G-008 — Hex hardcoded quebra dark mode

**Sintoma:** Surface vira quase invisível em dark mode (texto preto sobre fundo escuro). Ou border continua light em dark theme.

**Causa raiz:** Cor hardcoded `#1A2421` ignora override `[data-theme="dark"]`.

**Fix canônico:** SEMPRE `var(--token)` de [`foundations/tokens.css`](../../../../design-system/foundations/tokens.css). Tokens já têm overrides automáticos via `[data-theme="dark"]`.

```css
/* ❌ ERRADO — hardcoded */
.field-label { color: #1A2421; }

/* ✅ CORRETO — token */
.field-label { color: var(--text-primary); }
```

**Audit hook:** [`audit-prototypes.sh`](../../../../design-system/shared/scripts/audit-prototypes.sh) Check 1 bloqueia hex literal em `<style>`.

---

### G-009 — Logo single-img quebra dark swap

**Sintoma:** Em dark theme, logo continua mostrando versão light (texto preto sobre fundo dark).

**Causa raiz:** Apenas 1 `<img>` ou 1 `<img>` sem class `.logo-light-mode`/`.logo-dark-mode`. Swap CSS depende das 2 classes presentes.

**Fix canônico:** SEMPRE 2 imgs lado-a-lado, classes `.logo-light-mode` + `.logo-dark-mode`. CSS em `prototype-chrome.css` esconde a inativa via `[data-theme]`.

```html
<!-- ✅ CORRETO -->
<div class="auth-logo">
  <img src="…/logo.svg" alt="ZapNotei" class="logo-light-mode">
  <img src="…/logo-light.svg" alt="ZapNotei" class="logo-dark-mode">
</div>
```

**Audit hook:** [`audit-prototypes.sh`](../../../../design-system/shared/scripts/audit-prototypes.sh) Check 3 bloqueia auth/onboarding sem ambas as classes.

---

## Animation & Motion

### G-010 — Spinner CSS sem `animation` parece ícone estático

**Sintoma:** Loading button mostra círculo parado, user clica N vezes pensando que travou.

**Causa raiz:** `.spinner` definido só com border styling, sem `animation: spin … infinite`.

**Fix canônico:** SEMPRE incluir keyframe `@keyframes btn-spin { to { transform: rotate(360deg); } }` + `animation: btn-spin 700ms linear infinite`. Respeitar reduced-motion.

```css
.btn-primary .spinner {
  width: 16px; height: 16px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: var(--radius-full);
  animation: btn-spin 700ms linear infinite;     /* ✅ obrigatório */
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .btn-primary .spinner { animation: none; }
}
```

---

### G-011 — Timer countdown hardcoded vira "fake live"

**Sintoma:** OTP timer mostra `4:23` parado, ofende user (mostra que protótipo é "morto").

**Causa raiz:** Timer texto inserido como string hardcoded no HTML, sem `setInterval` decrementando.

**Fix canônico:** Usar `ZNProto.startCountdown({ el, seconds, format, onWarn, onExpire })` de [`proto-interactions.js`](../../../../design-system/shared/proto-interactions.js).

```js
ZNProto.startCountdown({
  el: document.querySelector('[data-countdown-expiry]'),
  seconds: 5 * 60,
  format: (s) => `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`,
  onWarn: (s) => s < 60,
  onExpire: () => navigateTo('01.2-auth-otp-verify?expired=1'),
});
```

---

## Accessibility

### G-012 — `:focus-visible` ring ausente — invisível pra teclado

**Sintoma:** User navegando por Tab não vê qual elemento está focado. WCAG fail.

**Causa raiz:** Componente customizado (input dressed, button) não herdou ring de `foundations/utilities.css` OU sobrescreveu com `outline: none` sem fornecer alternativa.

**Fix canônico:** SEMPRE definir `:focus-visible` ring com box-shadow accent. NUNCA `outline: none` sem replacement.

```css
.field-input:focus-visible {
  border-color: var(--border-focus);
  box-shadow: 0 0 0 3px var(--primary-15);
  outline: 0;                          /* ok porque box-shadow age como ring */
}
```

---

### G-013 — Touch target < 44×44 em mobile

**Sintoma:** User no celular tem dificuldade pra acertar botão/link pequeno.

**Causa raiz:** Componente desktop-first sem ajuste mobile, ou button height < 44px.

**Fix canônico:** SEMPRE 44px min-height em interactive em mobile. Exceto `.btn-link` pequeno (≥32px aceitável).

---

## Cross-surface Consistency

### G-014 — Card max-width diferente entre surfaces auth = OTP slots desiguais

**Sintoma:** OTP slot em `01.2-otp-verify` rendem 61px largura. Em `01.3-totp-2fa` rendem 74px. User percebe mudança ao navegar.

**Causa raiz:** `.auth-card max-width: 480px` (auth simples) vs `520px` (wizard). Mesmo `.otp-slot { flex: 1 }` produz larguras diferentes proporcionais ao container.

**Fix canônico:** `.auth-card max-width` SEMPRE 480px pra surfaces com OTP. Wizard só expande até 560px em viewport ≥640px (não afeta mobile onde OTP é mais sensível).

```css
.auth-card { max-width: 480px; }       /* canônico */
@media (min-width: 640px) {
  .auth-card.is-wizard { max-width: 560px; }   /* só ≥tablet, OTP renders OK */
}
```

**Audit hook:** [`compare-surfaces.js`](../../../../design-system/shared/scripts/compare-surfaces.js) compara `.auth-card` width entre 2 URLs — diff > 2px = bug.

**Caso real:** 2026-04-26.

---

### G-015 — Patterns canônicos copiados-e-adaptados drift em 3 commits

**Sintoma:** Botão primary em `01.1` é `48px height` mas em `02.1` é `52px`. Cor hover diferente.

**Causa raiz:** Surface 2 copiou CSS da surface 1 e ajustou inline em vez de importar shared.

**Fix canônico:** SEMPRE importar `shared/auth-shell.css` ou `shared/primitives/<pattern>.css`. Pattern duplicado em 2+ surfaces → vai pro shared antes da 3ª surface.

---

## Build & Deploy

### G-016 — Paths quebram em CF Pages mas funcionam em file://

**Sintoma:** Protótipo abre em file:// local mas em `https://app-prototype.zapnotei.com.br/01-auth/01.1-…` shows 404 nos CSS imports.

**Causa raiz:** `_dist/` build move `design-system/` pra `_design-system/`, mas algum `<link href>` ainda usa `../../../design-system/`. `build-cloud.sh` faz sed mas pode falhar em depths inesperados.

**Fix canônico:** SEMPRE usar paths canônicos relativos ao protótipo:
- Surface em `prototypes/<pasta>/<slug>.html` → `../../../../design-system/…`
- Surface em `prototypes/<root>.html` (index, _validation) → `../../../design-system/…`

`build-cloud.sh` cobre ambas profundidades. Outras profundidades exigem extender o sed.

---

### G-017 — Wrangler deploy serve cache antigo

**Sintoma:** User abre `https://app-prototype.zapnotei.com.br/01-auth/01.1-…` e vê versão de 5min atrás.

**Causa raiz:** CF Edge cache TTL ~5-10min para custom domains.

**Fix canônico:** Pra validação imediata, usar URL preview hash `https://<hash>.zapnotei-prototypes.pages.dev/<pasta>/<slug>` (sempre fresh). Custom domain reflecte após cache flush automático.

---

## Notas

- Adicionar gotcha novo: copiar template da entry G-001, atualizar nº sequencial.
- Audit hook deve referenciar arquivo + check #.
- Caso real datado ajuda futura investigação.
- Fix canônico precisa ser **CSS/JS exato copiável**, não descrição vaga.
