Skip to content

Commit 329c5a8

Browse files
authored
Merge pull request #24 from dcondrey/feat/gate-more-quirks
feat: gate ten more documented quirks, and correct the dark-mode claim
2 parents 2686b8d + 16eb6ed commit 329c5a8

8 files changed

Lines changed: 152 additions & 17 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ in translation on the oldest clients.
4949
project's conventions. See [Lint any email](#lint-any-email).
5050
- **Three ways to use it.** Copy the single-file master, assemble the documented partials, or run the
5151
build. All three produce identical markup.
52-
- **Dark mode, three ways.** `prefers-color-scheme`, Outlook.com `[data-ogsc]`/`[data-ogsb]`, and
53-
Gmail-safe explicit `bgcolor` on every cell.
52+
- **Dark mode, three ways.** `prefers-color-scheme`, Outlook.com `[data-ogsc]`/`[data-ogsb]`, and a
53+
Gmail-safe explicit `background-color` on every container the dark CSS repaints.
5454
- **Five branded templates, and a scaffolder.** Distinct designs (see below), each rebuilt in the
5555
house conventions with self-authored generated art and passing both gates clean. Start a new one
5656
with `node scripts/new-template.mjs <name> "Brand"` — a complete, conformant template out of the box.

docs/quirks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ offender; several quirks affect more than one client. Every fix is annotated inl
1313
| 5 | No `max-width` → layout won't center/constrain | Outlook Windows | 600px MSO ghost table wrapping a fluid `.email-container` |
1414
| 6 | No CSS background images | Outlook Windows (classic); *partial* in Outlook.com & Yahoo | VML `v:fill type="frame"` for Outlook; solid `bgcolor` fallback always behind |
1515
| 7 | Message clipped at ~102 KB (footer/unsub hidden) | Gmail | `--production` minify; build warns past the threshold |
16-
| 8 | Runs its own dark-mode inversion, ignores `prefers-color-scheme` | Gmail | Explicit `bgcolor` on **every** cell so nothing is left "unset" |
16+
| 8 | Runs its own dark-mode inversion, ignores `prefers-color-scheme` | Gmail | Explicit `background-color` on every element the dark CSS repaints, so nothing is left "unset" |
1717
| 9 | Inverts pure `#000000` / `#ffffff` unconditionally | Apple Mail (dark) | Off-black `#2b2b30` / off-white text; `supported-color-schemes` meta |
1818
| 10 | `prefers-color-scheme` support is inconsistent | Cross-client | Progressive enhancement + `color-scheme` meta; never depended on |
1919
| 11 | Partial colour inversion, custom attributes | Outlook.com (dark) | `[data-ogsc]` (text) / `[data-ogsb]` (background) targeted overrides |
20-
| 12 | Undefined `<td>` background repainted dark | Gmail (dark) | Same as #8no cell without an explicit colour |
20+
| 12 | Undefined `<td>` background repainted dark | Gmail (dark) | Same as #8every `darkmode-bg`/`darkmode-card` container declares its light colour inline, gated by the `dark-bg-explicit` rule |
2121
| 13 | Auto-links dates, phones, addresses in blue | iOS Mail | `format-detection` meta + `a[x-apple-data-detectors]` reset |
2222
| 14 | Auto-inflates "too small" text | iOS, Windows | `-webkit-text-size-adjust` / `-ms-text-size-adjust:100%` |
2323
| 15 | Auto-scales/reformats the whole layout | Apple Mail | `x-apple-disable-message-reformatting` meta |

framework/build/lint.test.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ assert(/color-scheme.*meta/.test(bad.out), 'bad.html: dark-mode-declaration rule
6363
assert(/PixelsPerInch/.test(bad.out), 'bad.html: Outlook-DPI rule fires');
6464
assert(/prefers-color-scheme: dark\) block/.test(bad.out), 'bad.html: dark-mode-CSS rule fires');
6565

66+
// --- quirk coverage: rules added for documented quirks must actually fire.
67+
assert(/mso-table-lspace/.test(bad.out), 'bad.html: Outlook table-gutter rule fires');
68+
assert(/ExternalClass/.test(bad.out), 'bad.html: Outlook.com .ExternalClass rule fires');
69+
assert(/text-size-adjust/.test(bad.out), 'bad.html: text-inflation rule fires');
70+
assert(/format-detection meta/.test(bad.out), 'bad.html: iOS auto-link rule fires');
71+
assert(/x-apple-disable-message-reformatting/.test(bad.out), 'bad.html: Apple reformatting rule fires');
72+
assert(/data-ogsc/.test(bad.out), 'bad.html: Outlook.com dark-mode rule fires');
73+
assert(/Times New Roman/.test(bad.out), 'bad.html: MSO font-fallback rule fires');
74+
75+
// The dark-mode background rule must be exercised, not vacuously passed: a
76+
// darkmode-* class with no light-state colour is the failure it exists to catch.
77+
const paintedOk = runHouse(GOOD);
78+
assert(/dark-mode painted elements declare an explicit background-color/.test(paintedOk.out),
79+
'good.html: dark-mode background rule runs against a real painted element');
80+
6681
// --- profile split: the universal rules must be safe to point at anyone's HTML.
6782
// bad.html omits every house convention, so if those rules leaked into the
6883
// default profile the CLI would fail on all third-party output.

framework/build/rules.mjs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,22 @@ function findStyleBlocks(html, tags) {
141141
return blocks;
142142
}
143143

144+
/** Elements the dark-mode CSS repaints, so their light state must be explicit. */
145+
const PAINTED_CLASS = /class=["'][^"']*darkmode-(?:bg|card)\b/i;
146+
const EXPLICIT_BG = /background-color\s*:|bgcolor\s*=/i;
147+
144148
function buildContext(html) {
145149
const structural = stripDocComments(html);
146150
const tags = scanTags(html);
147-
const imgs = openTags(scanTags(structural), 'img').map((t) => t.raw);
151+
const structuralTags = scanTags(structural);
152+
const imgs = openTags(structuralTags, 'img').map((t) => t.raw);
148153
const styleBlocks = findStyleBlocks(html, tags);
149154
return {
150155
html,
151156
structural,
152157
imgs,
153158
styleBlocks,
159+
painted: structuralTags.map((t) => t.raw).filter((t) => PAINTED_CLASS.test(t)),
154160
bytes: Buffer.byteLength(html, 'utf8'),
155161
preheader: findPreheader(html, tags),
156162
};
@@ -268,6 +274,27 @@ export const RULES = [
268274
? [SEVERITY.PASS, `viewport + charset meta present.`]
269275
: [SEVERITY.WARN, `Missing viewport or charset meta.`],
270276
},
277+
{
278+
// Quirk 16: an <img> is inline by default, so the line-box descender leaves
279+
// a ~3px gap under it in most clients. display:none is the dark-mode swap.
280+
id: 'img-display',
281+
scope: 'universal',
282+
run: ({ imgs }) => {
283+
const inline = imgs.filter((t) => !/display:\s*(?:block|none)/i.test(t));
284+
return inline.length
285+
? [SEVERITY.WARN, `${inline.length} <img> without display:block — most clients leave a ~3px gap under an inline image. First: ${inline[0].slice(0, 80)}…`]
286+
: [SEVERITY.PASS, `All ${imgs.length} <img> tags are display:block (or deliberately hidden).`];
287+
},
288+
},
289+
{
290+
// Quirk 6: classic Outlook drops CSS background images entirely.
291+
id: 'css-background-image',
292+
scope: 'universal',
293+
run: ({ html, structural }) =>
294+
!/background-image\s*:/i.test(structural) || /v:fill/i.test(html)
295+
? [SEVERITY.PASS, `No CSS background-image without a VML fallback.`]
296+
: [SEVERITY.WARN, `CSS background-image with no <v:fill> fallback — classic Outlook drops it and paints nothing behind the content.`],
297+
},
271298
{
272299
id: 'placeholder-href',
273300
scope: 'universal',
@@ -302,6 +329,82 @@ export const RULES = [
302329
? [SEVERITY.PASS, `Dark-mode CSS present (prefers-color-scheme block).`]
303330
: [SEVERITY.FAIL, `No @media (prefers-color-scheme: dark) block — the template opts into dark mode but never styles it.`],
304331
},
332+
{
333+
// Quirks 8 + 12: Gmail repaints a background it considers unset. The
334+
// framework declares the light colour on the element the dark CSS overrides,
335+
// not on every <td> — so that pairing is the invariant worth enforcing.
336+
id: 'dark-bg-explicit',
337+
scope: 'house',
338+
run: ({ painted }) => {
339+
const unset = painted.filter((t) => !EXPLICIT_BG.test(t));
340+
return unset.length
341+
? [SEVERITY.FAIL, `${unset.length} element(s) carry a darkmode-bg/darkmode-card class with no explicit background-color — the dark override has nothing to override, so Gmail repaints them. First: ${unset[0].slice(0, 80)}…`]
342+
: [SEVERITY.PASS, `All ${painted.length} dark-mode painted elements declare an explicit background-color.`];
343+
},
344+
},
345+
{
346+
// Quirk 17: Word injects space around tables unless both are zeroed.
347+
id: 'mso-table-spacing',
348+
scope: 'house',
349+
run: ({ html }) =>
350+
/mso-table-lspace/i.test(html) && /mso-table-rspace/i.test(html)
351+
? [SEVERITY.PASS, `mso-table-lspace/rspace zeroed (Outlook adds table gutters otherwise).`]
352+
: [SEVERITY.FAIL, `No mso-table-lspace/rspace reset — Outlook injects extra space around every table.`],
353+
},
354+
{
355+
// Quirk 18: Outlook.com wraps the body in .ExternalClass and restyles it.
356+
id: 'external-class',
357+
scope: 'house',
358+
run: ({ html }) =>
359+
/\.ExternalClass/i.test(html)
360+
? [SEVERITY.PASS, `.ExternalClass reset present (Outlook.com line-height/width).`]
361+
: [SEVERITY.FAIL, `No .ExternalClass reset — Outlook.com alters line-height and width of the whole message.`],
362+
},
363+
{
364+
// Quirk 14: iOS and Windows inflate text they judge too small.
365+
id: 'text-size-adjust',
366+
scope: 'house',
367+
run: ({ html }) =>
368+
/-webkit-text-size-adjust/i.test(html) && /-ms-text-size-adjust/i.test(html)
369+
? [SEVERITY.PASS, `text-size-adjust pinned for WebKit and Windows.`]
370+
: [SEVERITY.FAIL, `Missing -webkit-/-ms-text-size-adjust — iOS and Windows auto-inflate "too small" text.`],
371+
},
372+
{
373+
// Quirk 13: iOS auto-links dates, phones and addresses in system blue.
374+
id: 'auto-link-detection',
375+
scope: 'house',
376+
run: ({ html }) =>
377+
/name=["']format-detection["']/i.test(html) && /x-apple-data-detectors/i.test(html)
378+
? [SEVERITY.PASS, `format-detection meta + x-apple-data-detectors reset present.`]
379+
: [SEVERITY.FAIL, `Missing format-detection meta or the x-apple-data-detectors reset — iOS recolours dates, phones and addresses.`],
380+
},
381+
{
382+
// Quirk 15: Apple Mail rescales the whole layout without this.
383+
id: 'apple-reformatting',
384+
scope: 'house',
385+
run: ({ html }) =>
386+
/x-apple-disable-message-reformatting/i.test(html)
387+
? [SEVERITY.PASS, `x-apple-disable-message-reformatting present (Apple Mail leaves the layout alone).`]
388+
: [SEVERITY.FAIL, `No x-apple-disable-message-reformatting meta — Apple Mail auto-scales and reformats the layout.`],
389+
},
390+
{
391+
// Quirk 11: Outlook.com signals dark mode with its own attributes.
392+
id: 'outlook-com-dark',
393+
scope: 'house',
394+
run: ({ html }) =>
395+
/\[data-ogsc\]/i.test(html) && /\[data-ogsb\]/i.test(html)
396+
? [SEVERITY.PASS, `[data-ogsc]/[data-ogsb] overrides present (Outlook.com dark mode).`]
397+
: [SEVERITY.FAIL, `No [data-ogsc]/[data-ogsb] rules — Outlook.com inverts text and background on its own terms.`],
398+
},
399+
{
400+
// Quirk 3: Word falls back to Times New Roman, not the next stack entry.
401+
id: 'mso-font-fallback',
402+
scope: 'house',
403+
run: ({ html }) =>
404+
/\[if mso\]/i.test(html) && /mso-font-alt|font-family\s*:\s*Arial/i.test(html)
405+
? [SEVERITY.PASS, `MSO font fallback declared (Outlook would use Times New Roman otherwise).`]
406+
: [SEVERITY.FAIL, `No [if mso] font fallback — classic Outlook renders web fonts as Times New Roman.`],
407+
},
305408
{
306409
id: 'preheader',
307410
scope: 'house',

framework/build/test/good.html

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<!--
22
lint fixture: GOOD — a minimal but valid email that must pass every lint rule
3-
(0 fail). If a change to lint.mjs makes this file fail, either the change is
4-
wrong or this fixture needs a matching, deliberate update. See lint.test.mjs.
3+
(0 fail, 0 warn) under --profile house. It carries every house convention, so
4+
it doubles as the smallest complete example of them. If a change to the rules
5+
makes this file fail, either the change is wrong or this fixture needs a
6+
matching, deliberate update. See lint.test.mjs.
57
-->
68
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
79
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
@@ -10,25 +12,36 @@
1012
<meta name="viewport" content="width=device-width, initial-scale=1">
1113
<meta name="color-scheme" content="light dark">
1214
<meta name="supported-color-schemes" content="light dark">
15+
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no">
16+
<meta name="x-apple-disable-message-reformatting">
1317
<title>Good fixture</title>
1418
<!--[if mso]>
1519
<noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
20+
<style type="text/css">
21+
body, table, td, a { font-family: Arial, Helvetica, sans-serif !important; }
22+
</style>
1623
<![endif]-->
1724
<style type="text/css">
25+
body { margin:0; padding:0; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }
26+
table, td { mso-table-lspace:0pt; mso-table-rspace:0pt; border-collapse:collapse; }
27+
.ExternalClass { width:100%; }
28+
.ExternalClass, .ExternalClass p, .ExternalClass td { line-height:100%; }
1829
.email-container { max-width:600px; }
1930
a { text-decoration:none; }
31+
a[x-apple-data-detectors] { color:inherit !important; text-decoration:none !important; }
2032
@media (prefers-color-scheme: dark) {
2133
.darkmode-bg { background-color:#111114 !important; }
2234
.darkmode-text { color:#e6e6e6 !important; }
2335
}
2436
[data-ogsc] .darkmode-text { color:#e6e6e6 !important; }
37+
[data-ogsb] .darkmode-bg { background-color:#111114 !important; }
2538
</style>
2639
</head>
2740
<body>
2841
<div style="display:none; font-size:1px; line-height:1px; max-height:0; max-width:0; opacity:0; overflow:hidden; mso-hide:all;">
2942
Hidden preheader controls the inbox preview line. &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
3043
</div>
31-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
44+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="darkmode-bg" style="background-color:#f4f4f7;">
3245
<tr>
3346
<td align="center">
3447
<img src="hero.jpg" width="600" height="200" alt="A valid hero image" style="display:block;">
@@ -40,7 +53,7 @@
4053
<!--[if !mso]><!-->
4154
<a href="#" style="background:#445942; color:#ffffff; display:inline-block; padding:12px 30px;">Shop now</a>
4255
<!--<![endif]-->
43-
<p><a href="#unsub">Unsubscribe</a> from this mailing list.</p>
56+
<p class="darkmode-text"><a href="#unsub">Unsubscribe</a> from this mailing list.</p>
4457
</td>
4558
</tr>
4659
</table>

framework/dist/email.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,9 @@
211211
212212
- width="600" attribute + class="fluid" = full width on desktop,
213213
shrinks to screen on mobile, never upscales in Outlook.
214-
- The <td> has an explicit bgcolor so Gmail's dark-mode engine does
215-
not paint its own background behind a transparent PNG.
214+
- The wrapping table declares an explicit background-color (and the
215+
darkmode-card class that overrides it) so Gmail's dark-mode engine
216+
does not paint its own background behind a transparent PNG.
216217
217218
OPTIONAL: to bake text OVER a background image (the 2014 signature
218219
trick — still the only way to get a background image behind text in
@@ -329,8 +330,9 @@
329330
- Background is #2b2b30, NOT pure #000000. Apple Mail inverts pure
330331
black/white outright in dark mode; an off-black is left alone.
331332
(Same reason the light text is #d8d8d8, not #ffffff.)
332-
- Every cell has an explicit bgcolor so Gmail's dark engine cannot
333-
paint an unset cell.
333+
- The wrapping table declares an explicit background-color, paired with
334+
the darkmode-bg class that overrides it, so Gmail's dark engine has
335+
nothing unset to repaint. The lint rule dark-bg-explicit enforces it.
334336
- The unsubscribe link is REQUIRED for CAN-SPAM / CASL / GDPR and
335337
for one-click unsubscribe (RFC 8058) that Gmail & Yahoo now
336338
enforce for bulk senders. Keep it real, not a sample value.

framework/partials/30-hero.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
1010
- width="600" attribute + class="fluid" = full width on desktop,
1111
shrinks to screen on mobile, never upscales in Outlook.
12-
- The <td> has an explicit bgcolor so Gmail's dark-mode engine does
13-
not paint its own background behind a transparent PNG.
12+
- The wrapping table declares an explicit background-color (and the
13+
darkmode-card class that overrides it) so Gmail's dark-mode engine
14+
does not paint its own background behind a transparent PNG.
1415
1516
OPTIONAL: to bake text OVER a background image (the 2014 signature
1617
trick — still the only way to get a background image behind text in

framework/partials/60-footer.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
- Background is #2b2b30, NOT pure #000000. Apple Mail inverts pure
66
black/white outright in dark mode; an off-black is left alone.
77
(Same reason the light text is #d8d8d8, not #ffffff.)
8-
- Every cell has an explicit bgcolor so Gmail's dark engine cannot
9-
paint an unset cell.
8+
- The wrapping table declares an explicit background-color, paired with
9+
the darkmode-bg class that overrides it, so Gmail's dark engine has
10+
nothing unset to repaint. The lint rule dark-bg-explicit enforces it.
1011
- The unsubscribe link is REQUIRED for CAN-SPAM / CASL / GDPR and
1112
for one-click unsubscribe (RFC 8058) that Gmail & Yahoo now
1213
enforce for bulk senders. Keep it real, not a sample value.

0 commit comments

Comments
 (0)