fix(cards): tiles da home usam espaçador de proporção (WebKit #265243)

No Orion/Safari os seis tiles de modelos colapsavam (só os rótulos
ficavam): a caixa do CardModelTile tirava a altura de um `aspect-ratio`
inline sendo filha de um flex com `overflow: hidden` — o construto do
bug WebKit #265243 (fix só no WebKit main 2026-05), invisível para o CI
Chromium-only.

O tile passa a tirar a altura de um espaçador em fluxo com
padding-bottom percentual (data-card-tile-ratio). Guardas: unit por
modelo (RED sem o fix), sweep de convenção nos cards e e2e de geometria
após relayout forçado. Post-mortem + changelog na mesma entrega.
This commit is contained in:
Francisco Solla 2026-09-24 14:49:43 -03:00
parent 38e6742673
commit 491046133a
6 changed files with 262 additions and 49 deletions

View file

@ -0,0 +1 @@
**Tiles dos cards personalizados — altura por espaçador de proporção (fix WebKit #265243), 2026-09-24:** na home (`#cards`, "Mostre que você está com Solla") os seis tiles de modelos do estúdio de cards não apareciam no Orion/Safari (WebKit) — a seção encolhia e sobravam só os títulos dos modelos. Causa: a caixa do tile em `src/components/cards/CardModelTile.tsx` derivava a altura de um `aspect-ratio` inline sendo filha de um `button` `display:flex` com `overflow: hidden`, o construto do bug WebKit #265243 (`aspect-ratio` em flex item perde a altura no relayout; reportado em 2023-11 para o Safari 17, corrigido só no WebKit main em 2026-05-13) — Chromium/Firefox imunes e nenhum projeto do CI roda WebKit, então o defeito passou da S13 (`681f3bb5`, 2026-09-12) até aqui invisível. O fix tira o `aspect-ratio` e põe a altura num espaçador em fluxo `<span aria-hidden data-card-tile-ratio={h/w}>` com `padding-bottom` percentual (resolvido contra a largura do tile), à prova de motor e mantendo a proporção exata de cada arte-mestre; o `Image` fill absoluto, canvas, badges e rótulo seguem intocados, e um comentário no componente registra o bug. Verificação: unit novo `tests/unit/cardModelTile.unit.spec.tsx` (6 modelos; RED sem o fix, GREEN com), sweep de convenção em `tests/unit/codebaseConventions.unit.spec.ts` banindo `aspectRatio:` inline e `aspect-*` em `src/components/cards/*.tsx`, e2e do bloco S14 em `tests/e2e/frontend.e2e.spec.ts` conferindo altura ≈ largura × proporção (±4px) após relayout forçado — `--project=frontend -g "Cards personalizados"` 21 passed (1.0m) e `pnpm gate:fast` verde; no WPE WebKit antigo o construto dava 200×667 contra 200×267 do fix, com Chromium em 200×267 nos dois. Sem migration/Consent/access; CI a confirmar no PR; prod pendente do approve no environment `production`.

View file

@ -0,0 +1,79 @@
# Post-mortem: tiles dos cards personalizados somem no WebKit — `aspect-ratio` em flex item (#265243)
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Data do post-mortem | 2026-09-24 |
| Severidade | alta (funil público de conversão; nenhum dado perdido) |
| Ambiente | prod |
| Issue(s) | sem Issue — relato do humano na sessão do worktree `fix/9` (o bug-fix não exige Issue; o post-mortem é o registro) |
| PR do fix | a definir (este PR) |
| Detectado por | humano |
## Timeline
| Momento | Data/hora | Evento |
| ------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Início provável | 2026-09-12 (S13, `681f3bb5`) | O construto do tile nasce com `aspect-ratio` inline num flex item e segue inalterado por S15/S30/S31/S34 |
| Detecção | 2026-09-24 (sessão `fix/9`) | Humano relata, em produção, que os seis tiles de modelos do estúdio de cards não aparecem: "A sessão fica menor de altura, mostrando só os títulos dos modelos." Navegador do relator: Orion/Safari (WebKit); o humano não sabia dizer se as imagens falhavam ou se era o layout |
| Diagnóstico | 2026-09-24 | Em prod (HEAD `38e67426`, deploy run 35968308781, workflow_dispatch, completed 2026-09-24T13:07:43Z): os 6 URLs de assets e todas as variantes `_next/image` retornam 200 (inclusive 120 requisições paralelas, todas 200); em Chromium a 360–1920 os 6 tiles carregam sem overflow horizontal → assets/serving saudáveis, o problema é de layout/motor. Reprodução local com o WPE WebKit `webkit-2248` fecha a causa |
| Correção mergeada | a definir (este PR) | — |
| Deploy | a definir | deploy começa no merge; produção só após approve humano no environment `production` |
| Verificado em prod | pendente (aguarda o humano) | confirmação pendente, inclusive no Orion |
## O bug
Na home de produção (`jorgesolla1313.com.br`), os seis tiles de modelos do estúdio de cards personalizados — seção `#cards`, "Mostre que você está com Solla" — não apareciam no Orion/Safari (WebKit). O relato do humano: "A sessão fica menor de altura, mostrando só os títulos dos modelos." O humano não sabia se as imagens falhavam ou se o layout quebrava.
Os checks read-only em produção descartaram imagem/serving: os 6 URLs de assets e todas as variantes de largura do `_next/image` retornaram 200 (incluindo 120 requisições paralelas, todas 200), e em Chromium, de 360 a 1920, os 6 tiles carregavam sem overflow horizontal. O defeito era de layout/motor, restrito ao WebKit.
## Causa-raiz
1. No WebKit, os seis tiles sumiam; sobravam os rótulos dos modelos e a seção encolhia.
2. A caixa do tile em `src/components/cards/CardModelTile.tsx` derivava a altura de um `style={{ aspectRatio: '<w>/<h>' }}` inline, sendo filha de um `display:flex` (o `button` com `flex ... flex-col`) com `overflow: hidden`.
3. Esse é exatamente o construto do bug **WebKit #265243**: `aspect-ratio` num flex item perde a altura no relayout (reportado em 2023-11 para o Safari 17; corrigido só no WebKit main em 2026-05-13, commit 313170@main) — e o WebKit embarcado no navegador do relator ainda contém o bug.
4. Sem a altura da caixa, o único filho em fluxo que restava era o rótulo do modelo — por isso a seção encolhia aos títulos.
5. O construto entrou na S13 (`681f3bb5`, 2026-09-12) e passou incólume por S15/S30/S31/S34: Chromium/Firefox são imunes, e nenhum projeto Playwright do CI roda WebKit (todos são Desktop Chrome/chromium) — o defeito era invisível para a suíte pelo tempo em que existiu.
**Evidência de reprodução (local).** O bundle WPE WebKit `webkit-2248` (build de 2026-01-14, anterior ao fix upstream) falha o caso WPT WebKit de #265243 (200×500 stale contra 200×200 do Chromium) e, num harness direto com o mesmo construto do tile como flex item sob um flex row cuja altura depende dele, fica **200×667 stale** após mudança de largura do container 500→200, enquanto o Chromium dá 200×267. Nota honesta de não-reprodução: a estrutura real truncada (button column flex) **não** falhou no WPE; o Apple WebKit do relator (Orion) falha — a hipótese se sustenta no bug upstream e nas assinaturas medidas.
## Correção
Em `src/components/cards/CardModelTile.tsx`, a caixa do tile deixa de usar `aspect-ratio`: a altura passa a vir de um espaçador em fluxo
`<span aria-hidden data-card-tile-ratio={h/w} class="block w-full" style={{ paddingBottom: '<ratio*100>%' }}/>`
— o `padding` percentual resolve contra a largura do tile, é à prova de motor e mantém a proporção exata de cada arte-mestre. O `Image` fill absoluto, o canvas, os badges e o layout do rótulo ficam inalterados. Um comentário no componente registra o bug do WebKit para quem mexer ali depois. Sem migration, sem Consent/access, sem contrato de URL/shape público.
## Verificação
- Teste de regressão: `tests/unit/cardModelTile.unit.spec.tsx` (6 modelos: o espaçador existe, ratio/padding batem com o modelo, e não há `aspect-ratio` no markup) — verificado **RED** sem o fix (6/6 falham no arquivo pré-fix, restaurado por hash) e **GREEN** com ele.
- Guard de convenção: `tests/unit/codebaseConventions.unit.spec.ts` — sweep "card studio geometry comes from the ratio spacer", proibindo `aspectRatio:` inline e utilitários `aspect-*` em `src/components/cards/*.tsx`.
- e2e: `tests/e2e/frontend.e2e.spec.ts` (bloco S14) confere, para cada tile visível, altura da caixa ≈ largura × proporção declarada (±4px) após relayout forçado (`setViewportSize(1200)`); `--project=frontend -g "Cards personalizados"` → **21 passed (1.0m)**.
- Suíte: `pnpm gate:fast` verde (lint, `tsc --noEmit`, unit 411 arquivos/4552 testes à época; o guard de convenção entrou depois).
- Engine real: WPE antigo com o construto pré-fix → 200×667 (bug); com o espaçador do fix → 200×267; Chromium dá 200×267 nos dois.
- CI: a confirmar no PR.
- Prod: pendente — o deploy começa no merge; produção só após approve humano no environment `production`; aguarda confirmação do humano (inclusive no Orion).
## Prevenção
| Estratégia | Custo | Estado |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------ |
| Unit do espaçador de proporção + sweep de convenção nos cards + asserção e2e de geometria após relayout forçado | barata | implementada agora (este PR) |
| Projeto e2e WebKit dedicado no CI (`playwright install --with-deps webkit` no runner; um `frontendWebkit.e2e.spec.ts` com poucos specs de geometria/relayout ligados ao risk mapping do `scripts/ci-scope.mjs` para rodar em PRs que tocam `src/components/**`) | cara | documentada — não implementada neste fluxo |
| Migrar os demais sítios da mesma classe para um primitivo compartilhado de ratio box: `src/components/CampaignStorySection.tsx` (`aspect-[9/16] ... shrink-0 ... overflow-hidden`, flex item, linha ~52) e `src/components/CampaignContentCard.tsx` (linhas ~43-45) | cara | documentada — não implementada neste fluxo |
| Guarda baseada em AST (TS compiler API) para `aspect-*` em flex item — o sweep textual não enxerga a forma original (flex child sem `shrink-0`/`flex-1`) | cara | documentada — não implementada neste fluxo |
**Estratégia implementada:** três guardas baratos que travam o construto frágil e a geometria: o unit por modelo, o sweep textual que bane `aspect-ratio`/`aspect-*` nos cards e a asserção e2e de altura ≈ largura × proporção depois de um relayout forçado — a operação que o WebKit errava.
**Estratégia documentada (cara):** cobertura de engine de verdade no CI (projeto WebKit) e a migração dos outros sites same-class para um primitivo compartilhado de ratio box; a guarda textual não alcança a forma original (flex item sem `shrink-0`/`flex-1`), o que pediria uma análise AST — candidatas a Issue futura.
## Lições
- **O CI é 100% Chromium.** Nenhum projeto Playwright roda WebKit; um construto quebrado só no WebKit viveu da S13 (2026-09-12) até aqui sem sinal nenhum na suíte.
- **"Passa em Chrome" não é prova para WebKit.** Os checks de assets e a varredura em Chromium a 360–1920 estavam todos verdes enquanto a produção estava quebrada para o relator.
- **Bug upstream pode demorar anos — e o browser pode embarcar WebKit próprio.** O #265243 foi reportado em 2023-11 e só foi corrigido no WebKit main em 2026-05-13; o Orion embarca o WebKit dele, então o produtor do construto precisa evitar a forma frágil, não esperar o fix.
- **Harness de engine local prova causa-raiz e fix quando o browser do relator não está disponível.** O WPE WebKit `webkit-2248` reproduziu a perda de altura (200×667 stale vs 200×267 do fix) e permitiu validar a correção antes de qualquer deploy — com a ressalva honesta de que a estrutura real truncada não falhou no WPE e o Apple WebKit do relator falha.

View file

@ -12,6 +12,9 @@ import { cn } from '@/lib/utils'
* show the official transparent overlay above a neutral photo slot; the team
* tiles show `previewSrc` (the filled example) and the colinha tile shows the
* approved art itself (`assetSrc`, S34), with the `NOVO` badge.
*
* The box ratio comes from an in-flow spacer, never from `aspect-ratio` — see
* the comment on the spacer for the WebKit bug that collapsed the tiles.
*/
export const CardModelTile = ({
model,
@ -21,57 +24,75 @@ export const CardModelTile = ({
model: CardModel
fontFamily: string
selected?: boolean
}) => (
<span
className={cn(
'relative block overflow-hidden rounded-xl border bg-(--campaign-band) transition duration-200',
selected
? 'border-(--pt-red) ring-2 ring-(--pt-red)'
: model.badge
? // S30 — the gate highlights the newest model even unselected.
'border-2 border-(--pt-red) shadow-lg group-hover:-translate-y-0.5'
: 'border-(--campaign-line) group-hover:-translate-y-0.5 group-hover:shadow-lg',
)}
style={{ aspectRatio: `${model.width} / ${model.height}` }}
>
{model.kind === 'photo' ? (
<>
<span
aria-hidden="true"
className="absolute inset-0 bg-[linear-gradient(160deg,#ded8d2,#f7f3ef)]"
/>
{model.photoWindow ? (
}) => {
const ratio = model.height / model.width
return (
<span
className={cn(
'relative block overflow-hidden rounded-xl border bg-(--campaign-band) transition duration-200',
selected
? 'border-(--pt-red) ring-2 ring-(--pt-red)'
: model.badge
? // S30 — the gate highlights the newest model even unselected.
'border-2 border-(--pt-red) shadow-lg group-hover:-translate-y-0.5'
: 'border-(--campaign-line) group-hover:-translate-y-0.5 group-hover:shadow-lg',
)}
>
{/*
The tile height comes from this in-flow ratio spacer — a percentage
padding resolved against the tile width — and NOT from `aspect-ratio`:
WebKit loses the aspect-ratio height of a flex item on relayout
(https://bugs.webkit.org/show_bug.cgi?id=265243, fixed only in WebKit
main 2026-05), which collapsed the tiles to their labels on Orion/Safari.
The spacer is engine-proof and keeps the master's exact ratio.
*/}
<span
aria-hidden="true"
data-card-tile-ratio={ratio}
className="block w-full"
style={{ paddingBottom: `${(ratio * 100).toFixed(4)}%` }}
/>
{model.kind === 'photo' ? (
<>
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 flex items-center justify-center"
style={{
height: `${((model.photoWindow.y + model.photoWindow.height) / model.height) * 100}%`,
}}
>
<UserRoundIcon className="h-[30%] w-auto text-(--campaign-muted) opacity-45" />
</span>
) : null}
</>
) : null}
className="absolute inset-0 bg-[linear-gradient(160deg,#ded8d2,#f7f3ef)]"
/>
{model.photoWindow ? (
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 flex items-center justify-center"
style={{
height: `${((model.photoWindow.y + model.photoWindow.height) / model.height) * 100}%`,
}}
>
<UserRoundIcon className="h-[30%] w-auto text-(--campaign-muted) opacity-45" />
</span>
) : null}
</>
) : null}
<Image
src={model.previewSrc ?? model.assetSrc}
alt=""
fill
sizes="(min-width: 1024px) 167px, 78vw"
className="object-cover"
/>
<Image
src={model.previewSrc ?? model.assetSrc}
alt=""
fill
sizes="(min-width: 1024px) 167px, 78vw"
className="object-cover"
/>
{model.kind === 'name' ? <CardNameTileCanvas model={model} fontFamily={fontFamily} /> : null}
{model.kind === 'name' ? <CardNameTileCanvas model={model} fontFamily={fontFamily} /> : null}
{selected ? (
<span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md">
<CheckIcon className="size-4" aria-hidden="true" />
</span>
) : model.badge ? (
<span className="absolute top-3 right-3 rounded-full bg-white/95 px-2.5 py-1 text-[10px] font-black text-(--team-blue) shadow">
{model.badge}
</span>
) : null}
</span>
)
{selected ? (
<span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md">
<CheckIcon className="size-4" aria-hidden="true" />
</span>
) : model.badge ? (
<span className="absolute top-3 right-3 rounded-full bg-white/95 px-2.5 py-1 text-[10px] font-black text-(--team-blue) shadow">
{model.badge}
</span>
) : null}
</span>
)
}

View file

@ -1801,6 +1801,39 @@ test.describe('Cards personalizados (S14)', () => {
'NOVO',
)
// The tile box takes its height from the in-flow ratio spacer — not from
// `aspect-ratio`, which WebKit drops on relayout (WebKit #265243) and
// collapsed the tiles to their labels. Force the container width change
// first: the same relayout that triggered the production bug is what the
// geometry below must survive. Each visible tile must be as tall as its
// width times the declared ratio (the border accounts for ≤4px).
await page.setViewportSize({ width: 1200, height: 900 })
for (const model of [
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
'time-de-voce',
'time-do-estadual',
'minha-colinha',
]) {
const geometry = await section
.locator(`[data-card-model-tile="${model}"]:visible`)
.first()
.evaluate((element) => {
const spacer = element.querySelector('[data-card-tile-ratio]')
const box = spacer?.parentElement
const rect = box?.getBoundingClientRect()
if (!rect) return null
return {
ratio: Number(spacer!.getAttribute('data-card-tile-ratio')),
width: rect.width,
height: rect.height,
}
})
expect(geometry).not.toBeNull()
expect(Math.abs(geometry!.height - geometry!.width * geometry!.ratio)).toBeLessThan(4)
}
// S14 — the name tile reproduces the real result: `SEU NOME` drawn by the
// composer pipeline, left-aligned with the `SOU` border (x≈213 at 1080).
const nameTileCanvas = section.locator('[data-card-model-tile="eu-sou-solla"] canvas').first()

View file

@ -0,0 +1,53 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CardModelTile } from '@/components/cards/CardModelTile'
import { CARD_MODELS } from '@/lib/cardModels'
type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
fill?: boolean
priority?: boolean
}
vi.mock('next/image', () => ({
default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => (
// eslint-disable-next-line @next/next/no-img-element
<img alt={alt} {...props} />
),
}))
// The name-tile canvas draws through the real font/canvas pipeline (jsdom has
// no canvas); the tile geometry under test does not depend on it.
vi.mock('@/components/cards/CardNameTileCanvas', () => ({
CardNameTileCanvas: () => null,
}))
afterEach(cleanup)
/**
* WebKit #265243: `aspect-ratio` on a flex item loses its height on relayout
* (fixed only in WebKit main, 2026-05). The model tile cannot depend on
* `aspect-ratio` for its box — the ratio must come from an in-flow spacer
* (percentage padding), which every engine resolves against the tile width.
*/
describe('CardModelTile — proporção do tile não usa aspect-ratio', () => {
it.each(CARD_MODELS.map((model) => [model.id, model] as const))(
'tile %s tira a altura do espaçador de proporção',
(_id, model) => {
const { container } = render(<CardModelTile model={model} fontFamily="Brexter" />)
const spacer = container.querySelector<HTMLElement>('[data-card-tile-ratio]')
expect(spacer).toBeTruthy()
const ratio = Number(spacer!.dataset.cardTileRatio)
expect(ratio).toBeCloseTo(model.height / model.width, 6)
// The computed style serializes integer percentages without decimals.
expect(parseFloat(spacer!.style.paddingBottom)).toBeCloseTo(ratio * 100, 3)
// The tile box (the spacer parent) must not carry the WebKit-fragile
// `aspect-ratio`; nothing inside the tile may either.
expect(spacer!.parentElement!.getAttribute('style') ?? '').not.toContain('aspect-ratio')
expect(container.innerHTML).not.toContain('aspect-ratio')
},
)
})

View file

@ -891,3 +891,29 @@ describe('no conflict markers in committed files', () => {
)
})
})
describe('card studio geometry comes from the ratio spacer', () => {
// WebKit #265243: `aspect-ratio` on a flex item loses its height on relayout
// (fixed only in WebKit main, 2026-05). The model tiles are flex children, and
// the 2026-09-24 production bug collapsed all six to their labels on
// Orion/Safari. The studio derives its box ratios from `CardModelTile`'s
// in-flow spacer (percentage padding) — the inline property and the
// `aspect-*` utilities must not come back here.
const studioRoot = resolve(repoRoot, 'src/components/cards')
const fragilePatterns = [/\baspectRatio\s*:/, /\baspect-(?:\[|square|video|auto)\b/] as const
it('keeps aspect-ratio out of the card studio components', () => {
const offenders: string[] = []
for (const file of walkSourceFiles(studioRoot, ['.tsx'])) {
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (fragilePatterns.some((pattern) => pattern.test(line))) {
offenders.push(`${repoPath(file)}:${index + 1}`)
}
}
}
expect(offenders, 'use the in-flow ratio spacer (CardModelTile) — WebKit #265243').toEqual([])
})
})