Neutralise light panels nested inside dark painted cards #311

Open
opened 2026-09-09 02:32:24 +00:00 by jcoffey-dev · 0 comments
Owner

Closes #310. Reported by @taisau in
https://github.com/Coffey-Labs/ihasmail/issues/290#issuecomment-5593496568

The bug

markKeptSurfaces() marks any element whose declared background is below
LIGHT_SURFACE_LUMINANCE, with no area cap, so a 600px layout card is marked
exactly like a button. The CSS then exempted the marked element and its whole
subtree
:

*:not([data-ihm-keep]):not([data-ihm-keep] *)

A light table nested inside a dark card was therefore never touched. In the
reported specimen 14 of 21 light panels survived and rendered as beige sheets
in an otherwise themed message.

The change

Being inside a painted surface is no longer inherited past a sheet.

The walk now tracks that state and emits a second mark, data-ihm-in-keep, on
elements that sit on paint with no background of their own. The CSS exempts
those explicitly rather than exempting every descendant. A nested light sheet
ends the protection, and paint resumes below it — so a button inside such a
sheet is still kept whole.

The walk is iterative rather than recursive; this runs over untrusted mail and
deeply nested tables are what old newsletter HTML is made of.

Alternatives not taken

Both were raised in the report, and it ranked them correctly:

  • Dropping the descendant half of the selector puts back exactly what #294
    fixed. <td bgcolor="#1155CC" style="color:#fff"> with a nested <a> is
    common, and the label goes unreadable.
  • An area threshold is a magic number that misfires on both a legitimate
    full-width hero banner and a small dark panel with a light chip in it.

Tests

Four, all in web/src/lib/__tests__/html.test.ts, and worth a note on how they
assert. They pull the neutraliser selector out of EMAIL_BASE_CSS and ask
el.matches() — so they test what the reader sees, and cannot drift from the
rule they check.

That matters here. The first draft asserted on which attributes got set, and
passed against the unfixed code — the bug was in the rule that reads the
marks, not in the marking, so attribute assertions proved nothing. All four now
fail without this change:

× keeps a coloured button and drops the white sheet around it
× neutralises a light panel nested inside a dark painted card
× still keeps a button that sits inside a nested light panel
× leaves no light panel exempt across the whole reported specimen

The last reconstructs the reported shape: 21 light panels, 14 nested in dark
cards, and asserts none is left exempt while all 7 cards stay painted.

Full suite: 1153 passing across 113 files. Typecheck clean.

Still wanted

@taisau offered to run a patch against the real specimen, with the
white-wrapper template as the regression. That is the check this cannot do from
a reconstruction, so I would like that before merging.

Merged 2026-09-08 as coffey-labs/ihasmail@38fb78a095

Rebuilt from: git history, notification email, session transcript.

Closes #310. Reported by @taisau in https://github.com/Coffey-Labs/ihasmail/issues/290#issuecomment-5593496568 ## The bug `markKeptSurfaces()` marks any element whose declared background is below `LIGHT_SURFACE_LUMINANCE`, with no area cap, so a 600px layout card is marked exactly like a button. The CSS then exempted the marked element **and its whole subtree**: ```css *:not([data-ihm-keep]):not([data-ihm-keep] *) ``` A light table nested inside a dark card was therefore never touched. In the reported specimen 14 of 21 light panels survived and rendered as beige sheets in an otherwise themed message. ## The change Being inside a painted surface is no longer inherited past a sheet. The walk now tracks that state and emits a second mark, `data-ihm-in-keep`, on elements that sit on paint with no background of their own. The CSS exempts those explicitly rather than exempting every descendant. A nested light sheet ends the protection, and paint resumes below it — so a button inside such a sheet is still kept whole. The walk is iterative rather than recursive; this runs over untrusted mail and deeply nested tables are what old newsletter HTML is made of. ## Alternatives not taken Both were raised in the report, and it ranked them correctly: - **Dropping the descendant half of the selector** puts back exactly what #294 fixed. `<td bgcolor="#1155CC" style="color:#fff">` with a nested `<a>` is common, and the label goes unreadable. - **An area threshold** is a magic number that misfires on both a legitimate full-width hero banner and a small dark panel with a light chip in it. ## Tests Four, all in `web/src/lib/__tests__/html.test.ts`, and worth a note on how they assert. They pull the neutraliser selector **out of `EMAIL_BASE_CSS`** and ask `el.matches()` — so they test what the reader sees, and cannot drift from the rule they check. That matters here. The first draft asserted on which attributes got set, and **passed against the unfixed code** — the bug was in the rule that reads the marks, not in the marking, so attribute assertions proved nothing. All four now fail without this change: ``` × keeps a coloured button and drops the white sheet around it × neutralises a light panel nested inside a dark painted card × still keeps a button that sits inside a nested light panel × leaves no light panel exempt across the whole reported specimen ``` The last reconstructs the reported shape: 21 light panels, 14 nested in dark cards, and asserts none is left exempt while all 7 cards stay painted. Full suite: 1153 passing across 113 files. Typecheck clean. ## Still wanted @taisau offered to run a patch against the real specimen, with the white-wrapper template as the regression. That is the check this cannot do from a reconstruction, so I would like that before merging. **Merged** 2026-09-08 as coffey-labs/ihasmail@38fb78a095fb <sub>Rebuilt from: git history, notification email, session transcript.</sub>
This repo is archived. You cannot comment on issues.