Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
180 changes: 180 additions & 0 deletions docs/changelog/2026-08-09-retire-per-family-state-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# 2026-08-09 — Aposentar os contratos de estado por família (T11)

## Prompt original

> veja este relatório. Não quero simplesmente aplicar uma das sugestões dele, mas quero ver qual a
> solução mais idiomática, que segue melhor o princípio de converter o bootswatch o mais
> literalmente possível para o Vanilla Extract, mesmo que isso quebre a compatibilidade retroativa.

Relatório: `dropdownItemActive`, `dropdownItemDisabled`, `listGroupItemActive` e
`listGroupItemDisabled` são exportados por `@arijs/bootswatch-ve@0.2.0` e referenciados por nenhum
dos 27 temas. Aplicar um deles é um no-op silencioso.

## Diagnóstico

Os fatos do relatório conferem; a causa não. Os quatro não foram esquecidos — são o resto não-ligado
de uma **categoria inteira**.

Um apelido por família colapsa um seletor composto num identificador só: `.dropdown-item.active` →
`dropdownItemActive`, `.btn.show` → `btnShowHook`, `.nav-link.active` → `navLinkActive`. Eram **33
identificadores para 9 classes de estado do Bootstrap**. Como o emissor literal traduz 1:1, cada
apelido precisava de uma entrada no manifesto de divergências para existir no CSS: 12 `remapSymbols`
mais 8 espelhos, escritos à mão, um por família que tinha um componente exigindo. Famílias sem demo
(item de dropdown, item de list-group, breadcrumb, carousel, painel de tab, …) nunca ganharam
entrada, então **18 dos 33 eram publicados sem uma linha de CSS**. Nada comparava as duas listas
mantidas à mão.

O histórico está no próprio plano: T6 unificou os estados conforme §8.6, T8 descobriu que o CSS de
modal/accordion/toast ainda usava os símbolos por família e **reverteu pelo lado errado** — manteve
os apelidos e ensinou o emissor a produzi-los, em vez de regenerar o CSS.

A regra citada pelo relatório como invariante violada
(`docs/ve2-theme-generator-audit-playbook.md:208`) documenta o gerador v1, deletado no T10, e
*manda* fazer exatamente o anti-padrão.

## Por que apagar, e não ligar

A sugestão nº 1 do relatório (emitir os dois) é o padrão `addMirrorRule` que já existia e já tinha
produzido 8 entradas de manutenção. Ligar os quatro faltantes levaria a lista de 15 para 19 e
manteria o modo de falha.

Um apelido por família é um **rename puro**: `${navLink}${navLinkActive}` e `${navLink}${active}`
têm especificidade idêntica e casam o mesmo conjunto. Não compram isolamento — o seletor composto já
isola (`${modal}${show}` não casa um dropdown) — e custam 20 entradas de manifesto, 33 identificadores
e a deriva que gerou o bug.

## Mudanças

- **Manifesto de divergências: 22 → 2 entradas.** Restam `body-split` (vazamento sob o backdrop) e
`legend-element-mirror`. O mecanismo `remapSymbols` fica para um caso real de interferência.
- **36 contratos removidos**: os 33 apelidos, mais `tooltipVe`/`popoverVe` (os templates já
carimbavam `tooltip`/`popover` vivos ao lado) e as cópias duplicadas de `fade`/`show`/`collapse`
em `ui/navs`, `ui/modal` e `ui/navbar`. `literal/` passa a ser dono único de cada token de estado.
- **Preservados**: `modalOpenHook` e `carouselSlide` — `.modal-open` e `.slide` não têm regra em
nenhum `bootstrap.css`, são marcadores de runtime, não apelidos.
- **27 temas regenerados**: −4 290 linhas de CSS gerado; os espelhos de `.fade` e `.fade:not(.show)`
por família viram uma regra genérica cada.
- **8 adapters + ~80 componentes** carimbam os hashes literais. `ve-dropdown` passa um único `show`
para `CLASS_NAME_SHOW_TRIGGER` e `CLASS_NAME_SHOW_MENU`; `ve-carousel` perde o carimbo duplo;
`ve-toast` larga `CLASS_NAME_HIDE`.

## O gate

`scripts/check-contract-wiring.mjs` (estático, pré-build) e uma asserção de liveness em
`build-contract.mjs` (autoritativa, hashes reais vs CSS emitido). Inércia deliberada é declarada em
`scripts/contract-wiring-allowlist.mjs` com motivo; um hook de estado nunca é entrada válida.

Ele precisa chavear por **(módulo, símbolo)** e não por nome: `fade` é declarado em dois lugares e um
check por nome dá o par vivo como prova de que o morto está ok.

## Dois bugs que o gate expôs

1. `build-contract.mjs` só checava liveness para candidatas **duplicadas** — um nome declarado por um
único módulo era publicado sem checagem. É o mecanismo exato que publicou os quatro.
2. `contractModules()` filtrava por nome de arquivo e deixava `layout/container.css.ts` de fora, então
`containerFluid` caía para a cópia morta de `literal/` enquanto os temas estilizavam a de `layout/`.

## Resultado

| | antes | depois |
|---|---|---|
| exports mortos no pacote | 90 | 68 (todos na allowlist) |
| entradas no manifesto | 22 | 2 |
| nomes de contrato | 2240 | 2205 |

`active` mantém o hash `b17c3vgbe3` — quem já compunha com ele não é afetado.

## Quebra de compatibilidade

`cx(dropdownItem, isActive && dropdownItemActive)` vira `cx(dropdownItem, isActive && active)`, com
`active` vindo do entry `global` do pacote. Os nomes removidos agora falham na compilação em vez de
falharem na pintura.

## Verificação

**Equivalência de CSS (a prova principal).** `scripts/_cssdiff.mjs` extrai todo `globalStyle(seletor,
{…})` dos chunks por família nos dois lados, reescreve os apelidos do lado antigo para os contratos
literais que eles representavam, e compara os conjuntos. Nos **27 temas: 0 regras distintas
removidas, 0 adicionadas**, contagem distinta idêntica. A contagem bruta cai exatamente 11 por tema —
as regras-espelho duplicadas colapsando na genérica. Como os apelidos eram renames puros (mesma
especificidade, mesmo conjunto de casamento), isso demonstra que o CSS é o mesmo.

```
bootstrap|2977→2966 raw|2965→2965 distinct|dropped=0 added=0
zephyr |3069→3058 raw|3057→3057 distinct|dropped=0 added=0
```

**TypeScript.** Zero erros novos contra a baseline de `main` (o `tsconfig.json` da raiz tem uma
incompatibilidade `module`/`moduleResolution` pré-existente com tsc 6, então a comparação roda com
`--ignoreConfig` e flags equivalentes, dos dois lados).

**Screenshots.** As baselines commitadas vêm de um Chromium diferente do disponível aqui (o
Playwright do repo fixa `chromium_headless_shell-1217`, o container só tem o 1194), então comparar
contra elas mede a fonte, não o CSS — um spinner intocado já diverge 0,43%. A saída é **regerar as
baselines com o mesmo binário** (o app raiz, que renderiza com o CSS real do Bootswatch e não é
afetado por `ve-project2`) e só então verificar. Aí o ruído cancela dos dois lados:

| tema | rotas | iguais | divergentes |
|---|---|---|---|
| bootstrap | 339 (todas as famílias afetadas, inclusive `ui/buttons`) | **333** | **0** |
| darkly | 119 | **119** | **0** |

A maioria fecha em `0.000000` — pixel-perfeito, não "dentro da tolerância". As 6 "skipped" no
bootstrap são rotas cuja diretiva `@screenshot` fixa uma altura calculada no browser correto; aqui o
conteúdo renderiza mais alto e a baseline daquele tamanho não existe.

A primeira rodada tinha 13 divergentes, todas confirmadas **pré-existentes** rodando a mesma
verificação com o código de `main`, com ratio idêntico até a sexta casa. A causa era outra e foi
corrigida — veja abaixo.

As baselines regeradas **não** foram commitadas: vêm do binário errado e quebrariam as próximas
verificações de quem tem o binário fixado.

### A regressão que só isto pegou

Com as baselines certas, 9 rotas de tooltip/popover falharam nesta branch e passavam em `main`. O
dedupe do codemod removia `${token}` repetido por template literal — mas os templates de
tooltip/popover têm vários elementos num literal só, e `${theme}` repete ali de propósito, um por
elemento. Sem a classe de escopo nos nós internos, `${scope}${tooltipInner}` parou de casar e o balão
virava texto solto. Corrigido em `aad221dd`; `scripts/_fixdedupe.mjs` reporta qualquer token perdido
em relação a uma revisão base e hoje reporta zero.

O ruído de fonte estava **escondendo** essa regressão: no primeiro run tudo divergia 2–16%, e os
tooltips não se destacavam.

## Conserto pré-existente necessário para verificar

`ve-project2` não buildava em `main`: `theme-runtime/theme-runtime.ts` ainda importava
`themes/{theme}/utilities/used/styles.css` para os 27 temas, e essa família foi retirada em `dd753ef4`
("retire utilities/used split"). O arquivo é gerado; `node scripts/generate-ve-theme/generate-granular-loaders.mjs`
regenera ele e os 27 `theme.ts`. Sem isso nenhum style-loader builda.

## O que estava por trás das divergências "pré-existentes"

`card-tabs`, `navbar`, `navs/tabbed-nav`, `pagination/large-pagination` e `toasts/toast-example`
divergiam em todos os temas, e em `main` também. A causa não tinha nada a ver com contratos de
estado: **em modo granular nenhuma regra de utilitário carregava, em rota nenhuma.**

`dd753ef4` retirou o split `utilities/used` e apagou os chunks, mas deixou todo mundo apontando para
eles: 350 componentes declaravam `'utilities/used'` em `ve2RequiredStyleFamilies`, e
`style-families.ts` ainda listava o id como válido. `Ve2GranularShell` loga um `console.warn` só em
DEV para família desconhecida e **retorna** — silencioso num build de produção. Resultado: o chunk
`utilities` não era pedido por ninguém.

Aparecia onde um utilitário governava layout. Em `/ui/pagination/large-pagination` o `.flex-wrap` do
`<ul>` nunca aplicava e a paginação vazava em vez de quebrar linha; em `/ui/navs/tabbed-nav` o
`.mb-3` perdia para `.nav { margin-bottom: 0 }`, porque a regra com `!important` não estava lá para
ganhar.

Foi a terceira consequência da mesma retirada pela metade, depois dos imports em `theme-runtime.ts`.
Corrigido em `6f5eb014`: as 350 declarações voltam para `'utilities'` e o id morto sai da taxonomia.

O diagnóstico decisivo foi trocar `--style-loader=granular` por `--style-loader=theme`: com o tema
inteiro carregado as três rotas fechavam em `0.000000`, provando que o CSS estava certo e o problema
era o que chegava ao browser.

**`tabbed-nav` e `toast-example` não são bug.** A diretiva `@screenshot *: 360x322` fixa a altura
medida no browser correto; aqui o mesmo conteúdo renderiza 370 e a verificação procura uma baseline
de 322 que a captura nunca escreveu. Medindo os dois apps no mesmo browser, ambos dão exatamente
370 px.
7 changes: 7 additions & 0 deletions docs/ve-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ ve-project2/src/
17. **Theme-local selectors and resolved component values must come from `screenshots/{theme}/**/style.css`.** If a component needs a special-case rule beyond the global `theme.css` vars, derive it from the matching route-level `style.css` files for the same theme rather than from another theme or from package sources.
18. **Source CSS element rules (`h1`/`h4`/`p`/`hr`, etc.) must be migrated via `contents` contracts, never by targeting raw elements in selectors.** Create a contract class in `theme-contract/contents/contract.css.ts`, implement theme rules in `themes/{theme}/contents/styles.css.ts`, and stamp those classes directly on component elements (for example `<h4 class={`${theme} ${h4}`}>`). Do not use selectors like ``${scope} hr`` or ``${scope}${component} p``.
19. **Static string class selectors from source CSS (for example `.container`, `.container-fluid`) must not be authored inside `themes/{theme}/{family}/styles.css.ts`.** Migrate these rules through `theme-contract/contents/contract.css.ts` and implement them in `themes/{theme}/contents/styles.css.ts`, then apply the corresponding contents contract classes in markup. Do not write raw selector strings like `.container` or `.container-fluid` in component-family theme files.
20. **One contract per source class token — never a per-family alias for a shared state class.** `.dropdown-item.active` is two tokens and emits `${dropdownItem}${active}`; the composition happens at the call site. Do not add `dropdownItemActive`, `btnShowHook`, `navLinkDisabled` or anything shaped like them. `active`, `disabled`, `show`, `showing`, `fade`, `collapsed`, `collapsing` are owned by `theme-contract/literal/contract.css.ts` and by nothing else — a second copy in a family module shadows the live one depending on directory read order. Compound selectors already isolate components (`${modal}${show}` cannot match a dropdown), so a shared hash is safe. A genuine interference case gets a named entry in the divergence manifest, never a family-specific contract.
21. **Every zero-style contract must be styled by some theme.** An unwired contract compiles, exports a real hashed class and renders nothing — the failure is invisible until someone reports that an element "looks unstyled". `npm run check:contract-wiring` enforces this before a build and `build-contract.mjs` re-checks it against real hashes; deliberate inertness goes in `scripts/contract-wiring-allowlist.mjs` with a reason.

---

Expand All @@ -293,6 +295,11 @@ export const myComponentVariant = style({})

All exports must be `style({})` — no properties.

One export per Bootstrap class token. A rule like `.my-component.active` does **not** get a
`myComponentActive` contract: `.active` is already owned by `theme-contract/literal/contract.css.ts`,
and the theme rule composes the two (`${myComponent}${active}`). See Core Rule 20 — this is the
mistake that left four state contracts exported and unwired for two releases.

Create `ve-project2/src/theme-contract/ui/{family}/_vars.css.ts` with a `createVar()` for each Bootstrap CSS custom property used by this family:

```ts
Expand Down
27 changes: 27 additions & 0 deletions docs/ve2-literal-conversion-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@ TypeScript check exits 0.

> **T6 gap (discovered in T8):** The CSS styles for modal, accordion, and toast use *component-specific* contract symbols for their "show" state (`modalShowHook`, `accordionButtonCollapsed`, `toastFade`/`toastShow`) — NOT the shared literal `show`/`fade` — so the adapter/component changes in T6 caused those CSS rules to never fire. T8 reverted the three affected adapters to match their respective `styles.css.ts`.

> **T6 completed (T11).** The T8 revert fixed the symptom on the wrong side: it kept the per-family hooks and taught the emitter to produce them, via 20 `remapSymbols`/mirror entries in the divergence manifest. The CSS was never regenerated, so the contract layer kept both universes and the manifest became a hand-maintained sync list between two hand-maintained files. Nothing checked it, and 18 of the 33 per-family state aliases ended up exported with no rule in any theme — including the four in the `dropdownItemActive` report. T11 finishes §8.6 in the original direction: the aliases are deleted, the manifest drops to 2 entries (`body-split`, `legend-element-mirror`), the themes are regenerated onto `${component}${state}`, and the adapters pass the literal hashes. See T11 below.

---

**✅ T7 — Markup‑parity check (Phase 3).** Extend `diff-scenario-markup.mjs`/`markup-diff-core.mjs` to assert class+tag contract stamping and `@screenshot` annotations; emit gap reports.
Expand Down Expand Up @@ -591,6 +593,31 @@ Biome `--write` applied across all modified dirs (11 files fixed, import sorting

---

**✅ T11 — Finish §8.6: retire the per‑family state aliases.** Delete every contract that is an alias for a shared Bootstrap state class, regenerate the 27 themes onto `${component}${state}`, point the adapters and components at the literal hashes, and gate the invariant so it cannot rot again.

*Trigger:* the `dropdownItemActive` report — four contracts exported by the package, referenced by no theme, silently no‑op at the call site. The four were not an oversight; they were the unwired remainder of a whole category.

*What the category was.* A per‑family alias collapses a compound source selector into one identifier: `.dropdown-item.active` → `dropdownItemActive`, `.btn.show` → `btnShowHook`, `.nav-link.active` → `navLinkActive`. 33 identifiers stood for 9 Bootstrap state classes. Because the literal emitter translates 1:1, each alias needed a manifest entry to exist in the CSS at all — 12 `remapSymbols` plus 8 mirror rules, hand‑written, one per family that happened to have a component demanding it. Families without a demo (dropdown item, list‑group item, breadcrumb, carousel, tab pane, …) never got an entry, so 18 of the 33 shipped with zero CSS. Nothing compared the two hand‑maintained lists.

*Why deleting beats wiring.* Per‑family aliases are a pure rename: `${navLink}${navLinkActive}` and `${navLink}${active}` have identical specificity and identical match sets. They buy no isolation — the compound selector already provides it (`${modal}${show}` cannot match a dropdown) — while costing 20 manifest entries, 33 identifiers, and the drift that produced the bug. §8.6 said this in the original plan; T8 reverted it on the wrong side.

*Changes.*
- Divergence manifest 22 → 2 entries. Only `body-split` (overlay bleed) and `legend-element-mirror` (component stamps the `legend` class) remain — both justified under §7.2, neither a naming accommodation. `remapSymbols` stays as a mechanism for a real, documented interference case.
- 36 contracts deleted: the 33 aliases, plus `tooltipVe`/`popoverVe` (templates already stamped the live `tooltip`/`popover` alongside them) and the duplicate `fade`/`show`/`collapse` copies in `ui/navs`, `ui/modal`, `ui/navbar` that shadowed the literal ones. `literal/` is now the single owner of every shared state token.
- Kept: `modalOpenHook` and `carouselSlide` — `.modal-open` and `.slide` have no rule in any theme's `bootstrap.css`, so they are runtime markers, not aliases of a literal contract.
- 27 themes regenerated: −4 290 lines of generated CSS, the per‑family `.fade` / `.fade:not(.show)` mirrors gone, one generic rule each.
- 8 adapters + ~80 components stamp the literal hashes. `ve-dropdown` passes one `show` for both `CLASS_NAME_SHOW_TRIGGER` and `CLASS_NAME_SHOW_MENU`; `ve-carousel` drops the double‑stamp workaround; `ve-toast` drops `CLASS_NAME_HIDE` (`.hide` has no rule to map).

*Gate.* `scripts/check-contract-wiring.mjs` (static, pre‑build) plus a liveness assertion in `build-contract.mjs` (authoritative, real hashes vs emitted CSS). Deliberate inertness is declared in `scripts/contract-wiring-allowlist.mjs` with a reason; a state hook is never a valid entry.

*Two further bugs the gate surfaced.* `build-contract.mjs` only checked liveness for duplicate candidates — a name declared by exactly one module was published unchecked, which is the precise mechanism that shipped the four. And `contractModules()` filtered by filename, excluding `layout/container.css.ts`, so `containerFluid` resolved to literal's dead copy while the themes styled layout's.

*Result:* package dead exports 90 → 68, all 68 allowlisted (element markers, Bootstrap 3/4 names BS5 dropped, demo scaffolding, and inherited debt tagged TRIAGE). `active` keeps hash `b17c3vgbe3`, so consumers already composing with it are unaffected.

*Breaking:* `cx(dropdownItem, isActive && dropdownItemActive)` becomes `cx(dropdownItem, isActive && active)`, with `active` from the package's `global` entry. The removed names now fail to compile instead of failing to paint.

---

## 13. File inventory

**Create**
Expand Down
Loading
Loading