---
fonte: 01-canonicos/seed-componentes.md
versao_da_fonte: v1.43
secao: 02
titulo: "Form-field (campo de formulário) — `estável` · validado pelo Rafael em 2026-08-02 (v0.6)"
sequencia: 4 de 98
bytes_do_corpo: 30769
md5_do_corpo: e992bac7ebc76eb9fa3e0d77e6a21b87
gerado_por: 06-validacao/geradores/gen-camada-ia.py
nota: fatia GERADA — o corpo abaixo é byte a byte o trecho do canônico; edite o canônico, nunca esta fatia. Canônico inteiro em https://ds.seed.eng.br/01-canonicos/seed-componentes.md
---
## 2. Form-field (campo de formulário) — `estável` · validado pelo Rafael em 2026-08-02 (v0.6)

> **Emenda v0.7 (2026-08-02, aprovada pelo Rafael — supersedes formais):** (a) estados passam de 8 para **9** com a entrada do `warning` (§2.4); (b) tokens de warning adicionados ao §2.5 com contraste medido; (c) contrato HTML ganha os atributos de teclado virtual (§2.6); (d) fonte do campo em toque = 16px (tokens v1.2). O que estas emendas substituem: "8 estados" onde citado → 9; tabela §2.6 anterior → tabela com as colunas mobile.

> **O que é este item:** o componente-BASE anatômico de todo o Bloco 2. Define a estrutura que envolve QUALQUER controle de entrada — rótulo, texto auxiliar, mensagens, contador — mais estados, tokens, contrato HTML, arquitetura de máscaras, microcopy e acessibilidade. Os 12 itens seguintes do bloco (input de texto, busca, número, textarea, select, combobox, checkbox*, radio*, switch*, slider, date picker, upload) HERDAM esta spec e documentam apenas o que diverge. (*grupos de escolha usam `fieldset/legend` no lugar de `label` — divergência documentada nos próprios itens.)
>
> **Base de evidência:** 4 rodadas de pesquisa (2026-08-02, ~30 fontes primárias cruzadas): Carbon, GOV.UK + família, Material M1–M3, Polaris, Atlassian, Spectrum, USWDS, W3C WAI/WCAG 2.2, NN/g, Baymard, SAP Fiori, gov.br DS, web.dev (Google), Uber Base Web, Stripe, fintechs (Nubank/Revolut/Monzo/Wise), Konjević/Smashing, ecossistema BR de máscaras. Decisões B1–B6 aprovadas pelo Rafael em 2026-08-02 após sobreviverem às 4 rodadas.

### 2.1 Papel

Estruturar a comunicação entre o sistema e a pessoa em QUALQUER entrada de dado: o que o sistema pede (rótulo), como preencher (ajuda), o que aconteceu (erro/sucesso) e quanto cabe (contador). O form-field não é um input — é a moldura que faz qualquer input ser compreensível, acessível e consistente em todos os produtos SEED (ERP, chat, site institucional, formulários de captação).

### 2.2 Anatomia — 5 slots

```
 (A) ┌ Rótulo ──────────────── (opcional) ┐   ← label + indicador de opcionalidade
 (B) │ Texto auxiliar pré-digitação*      │   ← só quando PRECISA ser lido antes (exceção B1)
 (C) ┌────────────────────────────────────┐
     │  [controle de entrada]             │   ← input/select/textarea/etc (slot genérico)
     └────────────────────────────────────┘
 (D) │ ⓘ Texto auxiliar  ou  ⚠ Erro       │   ← zona de mensagem (erro SUBSTITUI ajuda — B2)
 (E) │                          120/500   │   ← contador (só quando há limite)
```

| Slot | Elemento | Regras |
|---|---|---|
| A | Rótulo (`<label for>`) | **Sempre visível, sempre no topo** (nunca placeholder como rótulo; nunca flutuante; nunca à esquerda). Montserrat SemiBold 14px, `--seed-field-label` (13.86:1). Sufixo `(opcional)` em Regular, `--seed-field-optional-text`. Variante `label-hidden` (visually-hidden, permanece p/ leitor de tela) só quando o contexto torna o rótulo redundante — ex.: campo de busca com ícone e placeholder "Buscar…" — regra Polaris: usar com muito critério |
| B | Ajuda pré-digitação (exceção) | Entre rótulo e controle SOMENTE quando a instrução precisa ser lida ANTES de digitar (formato obrigatório complexo, decisão pré-preenchimento). Padrão é (D) |
| C | Controle | Slot genérico. Altura md **44px** (alvo de toque WCAG, igual botão), sm 32px (densidade desktop), lg 52px. Radius `--seed-radius-md`. Borda 1px `--seed-field-border` (4.74:1 — é ela que identifica o controle, WCAG 1.4.11). Placeholder: DESENCORAJADO; quando usado, só exemplo de formato prefixado "Ex.:" — nunca instrução, nunca rótulo |
| D | Zona de mensagem | Ajuda: `--seed-field-help-text` (6.55:1 — acima do piso AA, na direção do 7:1 que o GOV.UK adotou após pesquisa). Erro: ícone ⚠ + texto `--seed-field-error-text` (7.18:1), prefixo "Erro:" visually-hidden p/ leitores de tela. **Erro substitui a ajuda** (B2); a mensagem de erro DEVE conter a instrução completa de correção (regra editorial que fecha o buraco da substituição). Sem reserva de altura: shift de 1 linha aceito (M3: substituição existe justamente p/ minimizar bump de layout) |
| E | Contador | Só quando há limite real. JetBrains Mono 12px (dado de medição = mono, regra dos tokens v1.1). Formato `usados/limite`. **SUPERSEDE (v0.10):** a política completa do contador vive no §6.3 (padrão GOV.UK) — o campo NUNCA trunca com `maxlength` duro; exceder o limite vira estado de ERRO com a contagem do excesso. A versão v0.6–v0.9 desta linha ('ao exceder, vira mensagem de erro' com maxlength no exemplo) fica substituída: o maxlength duro truncava colagem silenciosamente — perda de dado pega na pesquisa do textarea |

**Espaçamento vertical (mapeado da referência 8/16dp do Material para a escala SEED):** rótulo→controle `sp-2` (8px) · controle→mensagem `sp-2` (8px) · campo→campo `sp-6` (24px) confortável / `sp-4` (16px) denso (ERP).

### 2.3 As 6 decisões estruturais (aprovadas 2026-08-02)

| # | Decisão | Porquê (placar de fontes) | Descartado |
|---|---|---|---|
| B1 | Ajuda ABAIXO do controle; exceção: instrução pré-digitação vai acima | 6×1 (Carbon, Material, Polaris, Spectrum, Atlassian, MUI vs GOV.UK); padrão da stack shadcn; label topo lido 28% mais rápido que lateral (Baymard) | Hint sempre acima (GOV.UK — contexto one-thing-per-page de governo, não ERP denso) |
| B2 | Erro SUBSTITUI a ajuda + regra editorial (erro contém a correção completa) | 5×1 (Carbon, M1/M3, Atlassian, Spectrum, uxpatterns vs GOV.UK); M3: substituir evita empurrar o layout | Empilhar ajuda+erro (dobra a altura no pior momento); reservar altura fixa (desperdiça vertical em ERP) |
| B3 | Produto (ERP/chat): marcar SÓ `(opcional)`. Formulários públicos de conversão: marcar AMBOS (`*` + `(opcional)`) | Produto: Polaris (proíbe asterisco), Carbon, GOV.UK, Uber ("marque só os menos comuns"). Público: Baymard mediu 32% de falha marcando só opcionais no e-commerce; só 14% marcam ambos | Regra única para os dois contextos (a indústria não tem consenso único — tem consenso POR contexto); asterisco no ERP (ruído: quase tudo é obrigatório) |
| B4 | Validação onBlur com reward-early/punish-late + error summary no submit | Formulação operacional Polaris: valida quando foco sai E há ≥1 caractere; campo em erro revalida a cada tecla; obrigatório intocado só marca no submit. Refinamento Fiori p/ ERP: entrada massiva agrega erros em registro form-level. NN/g: correção imediata com o campo fresco na memória | Validar só no submit (GOV.UK — governo); validar enquanto digita desde a 1ª tecla (validação prematura, irrita — Baymard) |
| B5 | Arquitetura de máscara: REGISTRY EXTENSÍVEL, valor canônico sem formatação, telefone em E.164 | Stripe: tolerante na entrada, estrito no armazenamento; Uber: seletor de país + número (2 inputs, menos ambíguo); libphonenumber/E.164 = padrão da indústria; Baymard: 64% não usam máscara localizada e deviam. Crítica do Rafael derrubou o enum fixo: 0800 e clientes EUA não cabiam | Enum fechado de máscaras BR (proposta da rodada 3 — fecharia a porta p/ 0800, EUA e máscaras futuras) |
| B6 | Estado success EXISTE, com parcimônia: só onde a confirmação carrega informação real | Atlassian/Spectrum/Stripe têm; Stripe marca válido e mede ganho de confiança; Uber alerta contra marcar prematuramente | Sem success (perde o ganho em campos críticos: e-mail, documento, disponibilidade); success universal (ruído verde decorativo — GOV.UK desaconselha com razão) |

### 2.4 Estados — 9 (os 6 obrigatórios do §0 + readonly, success e warning¹)

¹ *warning adicionado na emenda v0.7 (era 8 na v0.6 — supersede formal).*

| Estado | Sinais (light) | Regras |
|---|---|---|
| default | borda `--seed-field-border` (4.74:1) | — |
| hover | borda `--seed-field-border-hover` | transição 100ms produtiva |
| focus | `--seed-focus-ring` (anel, NÃO engrossar borda) | mecanismo de foco ≠ mecanismo de erro (GOV.UK removeu borda grossa no erro por confundir com foco) |
| filled | valor em `--seed-field-value` (13.86:1) | valor ≠ placeholder tem que ser óbvio (por isso placeholder é 4.74 e valor 13.86) |
| **error** | borda `--seed-field-border-error` (5.19:1) + ícone ⚠ + mensagem (7.18:1) + `aria-invalid` | NUNCA só cor (WCAG 1.4.1): são 3 sinais. Erro nunca aparece antes de interação real (B4) |
| **success** | borda `--seed-field-border-success` (4.60:1) + ícone ✓ + mensagem opcional | só onde confirma algo real (B6) |
| **warning** (v0.7) | borda `--seed-field-border-warning` (4.82:1) + ícone ▲ + mensagem (6.68:1) | **Guarda-corpo duro:** SÓ para plausibilidade — dado válido em formato, mas incomum ("consumo 10× acima da sua média — confira"). NUNCA para obrigatoriedade, NUNCA bloqueia submit. Sem o guarda-corpo, warning banaliza e treina a ignorar avisos (por isso o GOV.UK nem o tem). Origem: SAP Fiori/Carbon (ERP) |
| disabled | fundo `surface-sunken` + texto `text-disabled` + `cursor:not-allowed` | **USO RESTRITO** (USWDS: contraste baixo, invisível p/ teclado e leitor de tela) — preferir campo habilitado com validação, ou readonly. Nunca esconder informação necessária num campo disabled |
| **readonly** | fundo `surface-sunken`, SEM borda de controle, texto pleno (12.75:1), focável, copiável | estado de primeira classe (Fiori/USWDS/Polaris): exibe dado não-editável NESTE contexto (ex.: UC vinda do cadastro). Readonly ≠ disabled: readonly participa do fluxo (tab, cópia, leitor de tela); não recebe estados de validação (Fiori) |

*loading (9º, condicional):* campos com operação assíncrona (CEP consultando ViaCEP, combobox buscando) mostram spinner 16px no slot direito do controle + `aria-busy` — herda a regra do botão (sem layout shift).

### 2.5 Tokens de componente (camada 3) — par light/dark obrigatório, contrastes medidos programaticamente (2026-08-02)

```css
/* Estrutura */
--seed-field-height-sm: 32px;  --seed-field-height-md: 44px;  --seed-field-height-lg: 52px;
--seed-field-radius: var(--seed-radius-md);
--seed-field-gap: var(--seed-sp-2);          /* rótulo→controle e controle→mensagem */
--seed-field-stack-gap: var(--seed-sp-6);    /* campo→campo; sp-4 no denso */

/* Cor — light (medições sobre branco; campo assenta em superfície branca) */
--seed-field-bg: var(--seed-surface-page);               /* branco */
--seed-field-label: var(--seed-cinza-900);               /* 13.86:1 */
--seed-field-value: var(--seed-cinza-900);               /* 13.86:1 */
--seed-field-placeholder: var(--seed-cinza-600);         /* 4.74:1 — AA e distinto do valor */
--seed-field-optional-text: var(--seed-cinza-600);       /* 4.74:1 */
--seed-field-help-text: var(--seed-cinza-700);           /* 6.55:1 — direção GOV.UK 7:1 */
--seed-field-border: var(--seed-cinza-600);              /* 4.74:1 ≥3:1 (WCAG 1.4.11) */
--seed-field-border-hover: var(--seed-cinza-700);
--seed-field-error-text: var(--seed-vermelho-700);       /* 7.18:1 */
--seed-field-border-error: var(--seed-vermelho-600);     /* 5.19:1 */
--seed-field-success-text: var(--seed-turquesa-700);     /* 6.32:1 */
--seed-field-border-success: var(--seed-turquesa-600);   /* 4.60:1 */
--seed-field-readonly-bg: var(--seed-surface-sunken);    /* texto 12.75:1 sobre cinza-50 */
--seed-field-warning-text: var(--seed-dourado-700);      /* 6.68:1 (v0.7) */
--seed-field-border-warning: var(--seed-dourado-600);    /* 4.82:1 (v0.7) */

/* Par dark — REGRA v0.5: token que referencia primitivo DECLARA o par (primitivo não troca de modo).
   Medições: rótulo/mensagens sobre dark-default #141D23; conteúdo do campo sobre dark-raised #1D272D */
[data-theme="dark"] {
  --seed-field-bg: var(--seed-surface-raised);           /* #1D272D */
  --seed-field-label: var(--seed-cinza-100);             /* 14.16:1 */
  --seed-field-value: var(--seed-cinza-100);             /* 12.61:1 */
  --seed-field-placeholder: var(--seed-cinza-400);       /* 6.01:1 */
  --seed-field-optional-text: var(--seed-cinza-400);
  --seed-field-help-text: var(--seed-cinza-300);         /* 8.92:1 */
  --seed-field-border: var(--seed-cinza-500);            /* 4.50:1 (600 sumia; 400 gritava em massa de campos do ERP) */
  --seed-field-border-hover: var(--seed-cinza-400);      /* 6.01:1 */
  --seed-field-error-text: var(--seed-vermelho-300);     /* 8.49:1 */
  --seed-field-border-error: var(--seed-vermelho-400);   /* 5.52:1 */
  --seed-field-success-text: var(--seed-turquesa-300);   /* 9.32:1 */
  --seed-field-border-success: var(--seed-turquesa-300); /* 8.31:1 */
  --seed-field-readonly-bg: var(--seed-surface-sunken);  /* texto = field-value cinza-100 #E3EBF0 sobre #141D23: 14.16:1 (v0.13 — supersede do comentário 9.72:1, que media cinza-300 sobre a página, par que o readonly não renderiza) */
  --seed-field-warning-text: var(--seed-dourado-300);    /* 9.22:1 (v0.7) */
  --seed-field-border-warning: var(--seed-dourado-300);  /* 8.21:1 (v0.7) */
}
```

### 2.6 Contrato HTML do campo (a camada que os DS visuais não cobrem)

Todo campo declara o trio `type` + `inputmode` + `autocomplete` + `name/id` ESTÁVEIS. Serve três frentes de uma vez: teclado mobile correto, autofill do navegador (conversão) e legibilidade por máquinas — agentes de IA e crawlers entendem o formulário pelos atributos semânticos (a frente "IA/SEO" do site institucional).

| Dado | type | inputmode | autocomplete | enterkeyhint² | autocapitalize/autocorrect/spellcheck² | Nota |
|---|---|---|---|---|---|---|
| Nome | text | — | name | next | on / off / off | |
| E-mail | email | email | email | next | **off / off / off** | Android "corrige" e-mail p/ palavra |
| Telefone | tel | tel | tel | next | off / off / off | armazena E.164 (§2.7) |
| CPF/CNPJ/CEP/UC | **text** | **numeric** | — (cpf não tem token oficial; cep usa postal-code) | next | **off / off / off** | **NUNCA type=number** (só p/ quantidade incremental — web.dev); number quebra máscara, zeros à esquerda e leitores |
| Endereço | text | — | street-address / address-line1… | next | on / on / on | |
| Valores (R$, kWh) | text | decimal | — | next | off / off / off | JetBrains Mono no valor (dado de medição) |
| Senha | password | — | current-password / new-password | done | off / off / off | norma §3.5 |

² *Colunas adicionadas na emenda v0.7 (mobile): `enterkeyhint` rotula a tecla Enter do teclado virtual ("próximo" no meio do fluxo, "concluído"/`done` no último campo, `search` na busca); o trio `autocapitalize/autocorrect/spellcheck` desligado em identificadores impede o Android de "corrigir" UC/códigos para palavras. **SC 3.3.7 Redundant Entry (WCAG 2.2):** dado já fornecido no mesmo fluxo não pode ser exigido de novo por transcrição — auto-popular ou oferecer seleção (mata o "confirme seu e-mail").*

Regras: `name`/`id` nunca gerados aleatoriamente por render (autofill exige estabilidade — web.dev) · formulário real `<form>` com botão de submit · **botão de submit NUNCA desabilitado** por campos vazios (Atlassian): a validação e o error summary dizem o que falta.

### 2.6b Grupos de controles — fieldset + legend (emenda v0.12)

Quando o "campo" é um GRUPO (radios §9, checkboxes §10, grupos de switch §8), o slot A vira `<legend>` dentro de `<fieldset>`: a pergunta é a legend; ajuda e erro do form-field continuam valendo via `aria-describedby`. Regra declarada UMA vez aqui — as specs da família citam. (GOV.UK/NHS/USWDS unânimes; a legend é lida junto de cada opção pelos leitores, dando contexto sem repetição.)

### 2.7 Máscaras — registry extensível (decisão B5)

**Três princípios arquiteturais:**
1. **Máscara é apresentação.** O valor canônico armazenado NÃO tem formatação: CPF = 11 dígitos, CEP = 8, telefone = **E.164** (`+5533999999999`, `+14155552671`). A exibição formata; o dado viaja limpo (bancos, APIs, SMS esperam E.164 — padrão libphonenumber/Google).
2. **Tolerante na entrada, estrita no armazenamento** (Stripe). Colar `033 99999-9999`, `(33)999999999` ou `33999999999` → tudo aceito e normalizado. Nunca rejeitar por pontuação.
3. **Registry aberto.** Máscaras são registráveis por nome (`registerMask("placa-mercosul", …)`) — produto novo adiciona máscara sem alterar o componente. NÃO é enum fechado (alternativa descartada na rodada 3 pela crítica do Rafael: 0800 e clientes EUA não cabiam).

**Registry inicial:**

| Máscara | Exibição | Canônico | Notas |
|---|---|---|---|
| `cpf` | 000.000.000-00 | 11 díg. | valida dígitos verificadores |
| `cnpj` | 00.000.000/0000-00 | 14 díg. | idem |
| `cpf-cnpj` | dinâmica pelo comprimento | 11 ou 14 | campo único B2C+B2B (padrão BR) |
| `cep` | 00000-000 | 8 díg. | + consulta ViaCEP (loading no campo; falha da API NUNCA bloqueia — preenchimento manual sempre possível) |
| `tel-br` | (00) 0000-0000 / (00) 9 0000-0000 | E.164 +55… | detecta fixo/celular pelo comprimento |
| `tel-nao-geografico` | 0800 000 0000 | dígitos puros | 0800/0300/0500/4004 — sem DDD, sem E.164 de assinante, categoria própria |
| `tel-internacional` | seletor de país + número nacional | E.164 | padrão Uber (2 inputs); default Brasil; cobre EUA e qualquer país; trocar país MANTÉM o número digitado (Evil Martians); validação libphonenumber |
| `moeda` | R$ 1.234,56 (ou US$ por locale) | decimal | |
| `data` | DD/MM/AAAA | ISO 8601 | tolera colar 01012026 |

**Armadilhas de implementação (documentadas pela comunidade BR — bugs reais a evitar):** `maxLength` conta os caracteres DA MÁSCARA (CPF = 14, não 11) · reposicionar o cursor após formatação programática (o value re-setado joga o cursor pro fim) · autofill do navegador entrega valor sem máscara → normalizar no change, não só no keypress.

### 2.8 Microcopy — tom SEED (vocabulário PT-BR alinhado ao gov.br DS: rótulo, texto auxiliar, mensagem)

1. **Rótulo:** substantivo curto, sentence case, sem dois-pontos. "Unidade consumidora", "E-mail", "CNPJ". Nunca instrução no rótulo.
2. **Texto auxiliar:** 1 linha, diz FORMATO ou PORQUÊ. "Somente números." · "Está na sua fatura de energia, no canto superior." **Nunca indica obrigatoriedade** (regra Uber: esse espaço é de formato/erro).
3. **Erro = o que houve + como corrigir, na voz SEED (direto, acolhedor, sem culpar):** ✅ "CPF incompleto — digite os 11 números." · "CEP não encontrado — confira ou preencha o endereço manualmente." | ❌ "Erro no campo." · "Entrada inválida." · "Você digitou errado." Específico > genérico (Baymard: "CEP curto demais" > "Inválido").
4. **Dado pessoal explica o porquê (LGPD + prática fintech):** texto auxiliar do CPF: "Usamos seu CPF apenas para emitir a proposta." Coleta sem porquê visível é atrito e risco.
5. **`(opcional)` literal em minúsculas** no produto; formulário público de conversão usa `*` + legenda "Campos com * são obrigatórios" no topo (B3).

### 2.9 Acessibilidade

`<label for>` sempre (nunca div-rotulada) · `aria-describedby` na ordem **erro ANTES da ajuda** (`"err-id help-id"` — AUI: o erro é anunciado primeiro) · `aria-invalid` só após validação real, NUNCA no estado inicial (W3C ARIA21), e SEMPRE com valor explícito `"true"` — nunca `toggleAttribute`, que grava o atributo com valor vazio: `[aria-invalid="true"]` do CSS não casa e a borda de erro some (bug flagrado na validação executada de 2026-08-03) · mensagem de erro com prefixo "Erro:" visually-hidden · erro dinâmico anunciado (região `aria-live="assertive"` ou `role="alert"`) · ícones de estado com par textual (1.4.1) · foco: anel, nunca borda engrossada · contraste: TODOS os pares da §2.5 medidos ≥4.5:1 texto e ≥3:1 não-texto · zoom 400%: layout de coluna única resiste · toque ≥44px no md.

### 2.10 Recuperação de erro no formulário (nível form — regras que o form-field expõe)

1. **NUNCA limpar campos após erro** (Baymard/Stripe: re-digitar é gatilho de abandono nº1).
2. **Error summary no submit** para formulário com 3+ campos: bloco no topo com título instrucional + lista de links-âncora para cada erro; foco move pro summary (GOV.UK); autoscroll até o primeiro campo em erro (Baymard).
3. **Erro também no `<title>` da página** quando o submit recarrega (USWDS — leitores de tela anunciam antes de tudo).
4. **ERP/entrada massiva (Fiori):** validação por campo no blur (B4) + agregado de erros form-level com contador; teclado flui sem interrupção. Detalhe: ponte com o Bloco 3 (alert/banner) — o componente de summary nasce lá.

### 2.11 Código — HTML/CSS

```html
<div class="seed-field">
  <label class="seed-field__label" for="cpf">
    CPF <span class="seed-field__optional">(opcional)</span>
  </label>
  <input class="seed-field__control" id="cpf" name="cpf" type="text"
         inputmode="numeric" maxlength="14" placeholder="Ex.: 000.000.000-00"
         aria-describedby="cpf-err cpf-help">
  <p class="seed-field__help" id="cpf-help">Usamos seu CPF apenas para emitir a proposta.</p>
  <p class="seed-field__error" id="cpf-err" hidden>
    <span class="seed-visually-hidden">Erro:</span>
    <svg class="seed-field__error-icon" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm-.9 3.5h1.8v5h-1.8v-5Zm.9 8.4a1.1 1.1 0 1 1 0-2.2 1.1 1.1 0 0 1 0 2.2Z"/></svg>
    CPF incompleto — digite os 11 números.
  </p>
</div>
```

```css
[hidden]{display:none!important} /* ARMADILHA (bug real pego pelo Rafael na validação do preview v0.7, 2026-08-02): classes de mensagem usam display:flex, que vence o display:none que o navegador aplica ao atributo `hidden` — sem esta regra, erro e sucesso aparecem SEMPRE. No React o problema não existe (renderização condicional), mas todo consumo HTML/CSS puro precisa desta linha. */
.seed-field{display:flex;flex-direction:column;gap:var(--seed-field-gap)}
.seed-field__label{font-family:var(--seed-font-sans);font-weight:var(--seed-fw-semibold);
  font-size:14px;color:var(--seed-field-label)}
.seed-field__optional{font-weight:400;color:var(--seed-field-optional-text)}
.seed-field__control{height:var(--seed-field-height-md);padding:0 12px;
  font-family:var(--seed-font-sans);font-size:14px;color:var(--seed-field-value);
  background:var(--seed-field-bg);border:1px solid var(--seed-field-border);
  border-radius:var(--seed-field-radius);
  transition:border-color var(--seed-dur-productive-fast) var(--seed-ease-productive)}
.seed-field__control::placeholder{color:var(--seed-field-placeholder)}
.seed-field__control:hover:not(:disabled):not([readonly]){border-color:var(--seed-field-border-hover)}
.seed-field__control:focus-visible{outline:none;box-shadow:var(--seed-focus-ring)}
.seed-field__control[aria-invalid="true"]{border-color:var(--seed-field-border-error)}
.seed-field__control[data-valid="true"]{border-color:var(--seed-field-border-success)}
.seed-field__control:disabled{background:var(--seed-surface-sunken);
  color:var(--seed-text-disabled);cursor:not-allowed}
.seed-field__control[readonly]{background:var(--seed-field-readonly-bg);border-color:transparent}
.seed-field__help{font-size:12px;color:var(--seed-field-help-text);margin:0}
.seed-field__error{display:flex;align-items:center;gap:6px;font-size:12px;
  font-weight:var(--seed-fw-semibold);color:var(--seed-field-error-text);margin:0}
.seed-field__counter{font-family:var(--seed-font-mono);font-size:12px;
  color:var(--seed-field-help-text);align-self:flex-end}
.seed-visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;
  padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
```

### 2.12 Código — React/TypeScript (padrão shadcn de composição; RHF pluga por cima)

```tsx
import * as React from "react";
import { cn } from "@/lib/utils";
import { CircleAlert, CircleCheck } from "lucide-react";

type FieldCtx = { id: string; helpId: string; errId: string;
  invalid?: boolean; valid?: boolean };
const FieldContext = React.createContext<FieldCtx | null>(null);
const useField = () => {
  const ctx = React.useContext(FieldContext);
  if (!ctx) throw new Error("Componentes Field* devem viver dentro de <FormField>");
  return ctx;
};

export function FormField({ id, invalid, valid, className, children }:
  React.PropsWithChildren<{ id: string; invalid?: boolean; valid?: boolean; className?: string }>) {
  const ctx = { id, helpId: `${id}-help`, errId: `${id}-err`, invalid, valid };
  return (
    <FieldContext.Provider value={ctx}>
      <div className={cn("flex flex-col gap-2", className)}>{children}</div>
    </FieldContext.Provider>
  );
}

export function FieldLabel({ optional, children }:
  React.PropsWithChildren<{ optional?: boolean }>) {
  const { id } = useField();
  return (
    <label htmlFor={id} className="text-sm font-semibold text-[var(--seed-field-label)]">
      {children}{" "}
      {optional && <span className="font-normal text-[var(--seed-field-optional-text)]">(opcional)</span>}
    </label>
  );
}

export function FieldControl(props: React.InputHTMLAttributes<HTMLInputElement>) {
  const { id, helpId, errId, invalid, valid } = useField();
  return (
    <input
      id={id}
      aria-invalid={invalid || undefined}
      aria-describedby={cn(invalid && errId, helpId) || undefined} /* erro ANTES da ajuda */
      data-valid={valid || undefined}
      className={cn(
        "h-11 rounded-md border bg-[var(--seed-field-bg)] px-3 text-sm",
        "text-[var(--seed-field-value)] placeholder:text-[var(--seed-field-placeholder)]",
        "border-[var(--seed-field-border)] hover:border-[var(--seed-field-border-hover)]",
        "transition-colors duration-100 focus-visible:outline-none",
        "focus-visible:ring-2 focus-visible:ring-[var(--seed-border-focus)] focus-visible:ring-offset-2",
        "disabled:cursor-not-allowed disabled:bg-[var(--seed-surface-sunken)] disabled:text-[var(--seed-text-disabled)]",
        "read-only:border-transparent read-only:bg-[var(--seed-field-readonly-bg)]",
        invalid && "border-[var(--seed-field-border-error)]",
        valid && "border-[var(--seed-field-border-success)]",
      )}
      {...props}
    />
  );
}

export function FieldHelp({ children }: React.PropsWithChildren) {
  const { helpId, invalid } = useField();
  if (invalid) return null; /* B2: erro substitui a ajuda */
  return <p id={helpId} className="text-xs text-[var(--seed-field-help-text)]">{children}</p>;
}

export function FieldError({ children }: React.PropsWithChildren) {
  const { errId, invalid } = useField();
  if (!invalid) return null;
  return (
    <p id={errId} role="alert"
       className="flex items-center gap-1.5 text-xs font-semibold text-[var(--seed-field-error-text)]">
      <span className="sr-only">Erro:</span>
      <CircleAlert className="size-4 shrink-0" aria-hidden />
      {children}
    </p>
  );
}

export function FieldSuccess({ children }: React.PropsWithChildren) {
  const { valid } = useField();
  if (!valid) return null;
  return (
    <p className="flex items-center gap-1.5 text-xs font-semibold text-[var(--seed-field-success-text)]">
      <CircleCheck className="size-4 shrink-0" aria-hidden />
      {children}
    </p>
  );
}
```

*Hook de validação B4 (reward-early/punish-late) e utilitários de máscara (registry + libphonenumber-js) são entregues no item "input de texto" deste bloco, que é o primeiro consumidor concreto.*

### 2.13 Decisões deste item (além de B1–B6, com alternativas descartadas)

| Decisão | Porquê | Descartado |
|---|---|---|
| Form-field como componente-base do bloco | 13 itens compartilham a mesma moldura; especificar 1 vez evita 13 variações | Especificar label/ajuda/erro dentro de cada item (deriva incoerência) |
| Ajuda em cinza-700 (6.55:1), não cinza-600 | GOV.UK escureceu o hint p/ 7:1 após usuários não lerem; 700 é o stop SEED mais próximo dessa direção mantendo hierarquia c/ o valor | cinza-600 (4.74 — passa AA mas repete o erro que o GOV.UK corrigiu) |
| Erro texto em vermelho-700 (7.18:1), borda em vermelho-600 | Texto de 12px pede folga acima do piso; borda só precisa 3:1 e o 600 casa com o botão destructive | Tudo em vermelho-500 (4.30:1 medido — serve p/ borda ≥3, mas REPROVA texto normal <4.5) |
| Borda dark = cinza-500 (4.50:1) | cinza-600 somia no dark; cinza-400 em massa de campos do ERP grita (outline de botão usa 400 porque botão é ação — campo é massa) | Reusar o token do botão outline (papéis diferentes: ação pontual vs grade de campos) |
| Foco = anel; erro = cor de borda + ícone + texto | Mecanismos distintos por estado (GOV.UK removeu borda grossa do erro por colidir com foco) | Engrossar borda no erro (colisão de sinal) |
| Contador em JetBrains Mono | "usados/limite" é dado de medição — papel exclusivo da mono nos tokens v1.1 | Montserrat (números dançam sem tabular) |
| ViaCEP com fallback manual sempre | API externa cai; endereço nunca pode ficar refém | CEP obrigatório via API (bloqueio real de cadastro) |

### 2.14 Aplicação dos 6 testes (§1.12)

1. **Container 3:1:** borda default 4.74:1 (light) / 4.50:1 (dark); erro 5.19/5.52; success 4.60/8.31 — MEDIDOS, tabela §2.5. ✅
2. **Escada de mecanismos:** estados não diferem só por tom — error = borda+ícone+mensagem; success = borda+ícone; readonly = REMOVE a borda e muda o fundo; disabled = fundo+cursor+texto; focus = anel. ✅
3. **Grayscale:** ícones ⚠/✓ e a presença/ausência de borda carregam os estados sem matiz — faixa cinza pré-renderizada no preview. ✅
4. **Teste do par:** campo default × campo em erro lado a lado no preview — distinguíveis a 1s de distância de braço. ✅
5. **Regra do um:** adaptação p/ formulário — UMA mensagem por campo por vez (B2 + regra Uber de nunca empilhar validações). ✅
6. **Estado atual:** campo em erro/sucesso/readonly identificável DE RELANCE por quem chega agora, sem interagir (3 sinais no erro; fundo+ausência de borda no readonly). ✅

---

