@emailens/engine 0.10.4 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7495,12 +7495,12 @@ h1 {
7495
7495
  description: "Account for padding in width manually (no box-sizing)",
7496
7496
  before: `<div style="width: 300px; padding: 20px;
7497
7497
  box-sizing: border-box;">
7498
- Content \u2014 total width stays 300px
7498
+ Content; total width stays 300px
7499
7499
  </div>`,
7500
7500
  after: `<!-- Set width to content-width (300 - 40 = 260px) -->
7501
7501
  <div style="width: 300px;">
7502
7502
  <div style="padding: 20px;">
7503
- Content \u2014 padding on inner element
7503
+ Content; padding on inner element
7504
7504
  </div>
7505
7505
  </div>`
7506
7506
  },
@@ -7581,7 +7581,7 @@ h1 {
7581
7581
  // ── transition (not supported) ────────────────────────────────────────
7582
7582
  "transition": {
7583
7583
  language: "css",
7584
- description: "Transitions don't work in email \u2014 style the default state well",
7584
+ description: "Transitions don't work in email; style the default state well",
7585
7585
  before: `.button {
7586
7586
  background-color: #6d28d9;
7587
7587
  transition: background-color 0.2s;
@@ -7601,7 +7601,7 @@ h1 {
7601
7601
  // ── background-size (Outlook) → VML ───────────────────────────────────
7602
7602
  "background-size": {
7603
7603
  language: "html",
7604
- description: "Outlook ignores background-size \u2014 use VML or sized images",
7604
+ description: "Outlook ignores background-size; use VML or sized images",
7605
7605
  before: `<td style="background: url('bg.jpg') center/cover no-repeat;">
7606
7606
  Content
7607
7607
  </td>`,
@@ -7623,7 +7623,7 @@ h1 {
7623
7623
  // ── overflow (Gmail strips it) ────────────────────────────────────────
7624
7624
  "overflow": {
7625
7625
  language: "html",
7626
- description: "Content will always be visible \u2014 design for full content display",
7626
+ description: "Content will always be visible; design for full content display",
7627
7627
  before: `<div style="max-height: 200px; overflow: hidden;">
7628
7628
  Long content that gets clipped...
7629
7629
  </div>`,
@@ -7725,7 +7725,7 @@ h1 {
7725
7725
  // ── max-height → fixed height ────────────────────────────────────────
7726
7726
  "max-height": {
7727
7727
  language: "html",
7728
- description: "Outlook ignores max-height \u2014 truncate content server-side",
7728
+ description: "Outlook ignores max-height; truncate content server-side",
7729
7729
  before: `<div style="max-height: 200px; overflow: hidden;">
7730
7730
  Long content...
7731
7731
  </div>`,
@@ -7956,7 +7956,7 @@ var JSX_FIX_DATABASE = {
7956
7956
  </Row>`,
7957
7957
  after: `import { Row, Column } from "@react-email/components";
7958
7958
 
7959
- {/* Use padding on Column \u2014 gap is not supported in email clients */}
7959
+ {/* Use padding on Column; gap is not supported in email clients */}
7960
7960
  <Row>
7961
7961
  <Column style={{ paddingRight: "16px" }}>Item 1</Column>
7962
7962
  <Column style={{ paddingRight: "16px" }}>Item 2</Column>
@@ -7966,7 +7966,7 @@ var JSX_FIX_DATABASE = {
7966
7966
  // ── <style> (Gmail JSX) ─────────────────────────────────────────────
7967
7967
  "<style>::gmail::jsx": {
7968
7968
  language: "jsx",
7969
- description: "React Email inlines styles via style props \u2014 manual <style> blocks won't survive Gmail",
7969
+ description: "React Email inlines styles via style props; manual <style> blocks won't survive Gmail",
7970
7970
  before: `import { Head } from "@react-email/components";
7971
7971
 
7972
7972
  <Head>
@@ -8027,7 +8027,7 @@ var JSX_FIX_DATABASE = {
8027
8027
  // ── @media (JSX) ────────────────────────────────────────────────────
8028
8028
  "@media::jsx": {
8029
8029
  language: "jsx",
8030
- description: "Design mobile-first \u2014 @media queries are stripped by many clients",
8030
+ description: "Design mobile-first; @media queries are stripped by many clients",
8031
8031
  before: `import { Head } from "@react-email/components";
8032
8032
 
8033
8033
  <Head>
@@ -8169,7 +8169,7 @@ var JSX_FIX_DATABASE = {
8169
8169
  </div>`,
8170
8170
  after: `import { Img } from "@react-email/components";
8171
8171
 
8172
- {/* CSS transforms are not supported in email \u2014 pre-render as an image */}
8172
+ {/* CSS transforms are not supported in email: pre-render as an image */}
8173
8173
  <Img
8174
8174
  src="https://example.com/rotated-content.png"
8175
8175
  width={200}
@@ -8185,7 +8185,7 @@ var JSX_FIX_DATABASE = {
8185
8185
  before: `<span style={{ animation: "pulse 2s infinite" }}>New!</span>`,
8186
8186
  after: `import { Img } from "@react-email/components";
8187
8187
 
8188
- {/* CSS animations are not supported \u2014 use an animated GIF */}
8188
+ {/* CSS animations are not supported: use an animated GIF */}
8189
8189
  <Img
8190
8190
  src="https://example.com/badge-animated.gif"
8191
8191
  width={60}
@@ -8197,7 +8197,7 @@ var JSX_FIX_DATABASE = {
8197
8197
  // ── transition (JSX) ─────────────────────────────────────────────
8198
8198
  "transition::jsx": {
8199
8199
  language: "jsx",
8200
- description: "Transitions don't work in email \u2014 style the default state well",
8200
+ description: "Transitions don't work in email; style the default state well",
8201
8201
  before: `<a
8202
8202
  href="#"
8203
8203
  style={{
@@ -8210,7 +8210,7 @@ var JSX_FIX_DATABASE = {
8210
8210
  </a>`,
8211
8211
  after: `import { Button } from "@react-email/components";
8212
8212
 
8213
- {/* Transitions are not supported \u2014 style the default state: */}
8213
+ {/* Transitions are not supported: style the default state: */}
8214
8214
  <Button
8215
8215
  href="https://example.com"
8216
8216
  style={{
@@ -8227,13 +8227,13 @@ var JSX_FIX_DATABASE = {
8227
8227
  // ── overflow (JSX) ───────────────────────────────────────────────
8228
8228
  "overflow::jsx": {
8229
8229
  language: "jsx",
8230
- description: "Content will always be visible \u2014 design for full content display",
8230
+ description: "Content will always be visible; design for full content display",
8231
8231
  before: `<div style={{ maxHeight: "200px", overflow: "hidden" }}>
8232
8232
  Long content that gets clipped...
8233
8233
  </div>`,
8234
8234
  after: `import { Link, Text } from "@react-email/components";
8235
8235
 
8236
- {/* overflow:hidden is stripped \u2014 show full content or truncate server-side */}
8236
+ {/* overflow:hidden is stripped; show full content or truncate server-side */}
8237
8237
  <div>
8238
8238
  <Text>Shortened content that fits...</Text>
8239
8239
  <Link href="https://example.com/full">Read more</Link>
@@ -8246,7 +8246,7 @@ var JSX_FIX_DATABASE = {
8246
8246
  before: `<div style={{ visibility: "hidden" }}>
8247
8247
  Hidden preheader text
8248
8248
  </div>`,
8249
- after: `{/* visibility:hidden is stripped by most clients \u2014 use the preheader trick.
8249
+ after: `{/* visibility:hidden is stripped by most clients; use the preheader trick.
8250
8250
  msoHide is non-standard but needed to hide content in Outlook. */}
8251
8251
  <div
8252
8252
  style={{
@@ -8284,7 +8284,7 @@ var JSX_FIX_DATABASE = {
8284
8284
  // ── background-size (JSX) ────────────────────────────────────────
8285
8285
  "background-size::jsx": {
8286
8286
  language: "jsx",
8287
- description: "Outlook ignores background-size \u2014 use sized images instead",
8287
+ description: "Outlook ignores background-size; use sized images instead",
8288
8288
  before: `<div style={{
8289
8289
  background: "url('bg.jpg') center/cover no-repeat",
8290
8290
  }}>
@@ -8310,12 +8310,12 @@ var JSX_FIX_DATABASE = {
8310
8310
  padding: "20px",
8311
8311
  boxSizing: "border-box",
8312
8312
  }}>
8313
- Content \u2014 total width stays 300px
8313
+ Content (total width stays 300px)
8314
8314
  </div>`,
8315
8315
  after: `{/* Set outer width, use inner element for padding */}
8316
8316
  <div style={{ width: "300px" }}>
8317
8317
  <div style={{ padding: "20px" }}>
8318
- Content \u2014 padding on inner element
8318
+ Content; padding on inner element
8319
8319
  </div>
8320
8320
  </div>`
8321
8321
  },
@@ -8355,7 +8355,7 @@ var MJML_FIX_DATABASE = {
8355
8355
  // ── word-break (MJML) ────────────────────────────────────────────────
8356
8356
  "word-break::mjml": {
8357
8357
  language: "mjml",
8358
- description: "MJML renders text in table cells by default \u2014 word-break works via mj-text",
8358
+ description: "MJML renders text in table cells by default; word-break works via mj-text",
8359
8359
  before: `<mj-text>
8360
8360
  <span style="word-break: break-all;">Long URL here</span>
8361
8361
  </mj-text>`,
@@ -8409,7 +8409,7 @@ var MJML_FIX_DATABASE = {
8409
8409
  // ── border-radius (Outlook MJML) ────────────────────────────────────
8410
8410
  "border-radius::outlook::mjml": {
8411
8411
  language: "mjml",
8412
- description: "MJML limitation: border-radius is unsupported in Outlook \u2014 MJML does not generate VML",
8412
+ description: "MJML limitation: border-radius is unsupported in Outlook; MJML does not generate VML",
8413
8413
  before: `<mj-button border-radius="6px" background-color="#6d28d9">
8414
8414
  Click Here
8415
8415
  </mj-button>`,
@@ -8449,7 +8449,7 @@ var MJML_FIX_DATABASE = {
8449
8449
  </div>
8450
8450
  </mj-raw>`,
8451
8451
  after: `<!-- Flexbox in MJML is not Outlook-compatible.
8452
- Use mj-section and mj-column \u2014 MJML compiles these to table-based layouts. -->
8452
+ Use mj-section and mj-column: MJML compiles these to table-based layouts. -->
8453
8453
  <mj-section>
8454
8454
  <mj-column width="50%">
8455
8455
  <mj-text>Column 1</mj-text>
@@ -8509,7 +8509,7 @@ var MAIZZLE_FIX_DATABASE = {
8509
8509
  // ── @font-face (Maizzle) ────────────────────────────────────────────
8510
8510
  "@font-face::maizzle": {
8511
8511
  language: "maizzle",
8512
- description: 'Add fonts via the googleFonts key in config.js \u2014 Maizzle injects the Google Fonts link tag automatically. Set googleFonts: "Inter:ital,wght@0,400;0,700" in your environment config, then reference the font family in your template.',
8512
+ description: 'Add fonts via the googleFonts key in config.js; Maizzle injects the Google Fonts link tag automatically. Set googleFonts: "Inter:ital,wght@0,400;0,700" in your environment config, then reference the font family in your template.',
8513
8513
  before: `<style>
8514
8514
  @font-face {
8515
8515
  font-family: 'Inter';
@@ -8522,12 +8522,12 @@ var MAIZZLE_FIX_DATABASE = {
8522
8522
  // ── <style> (Gmail Maizzle) ─────────────────────────────────────────
8523
8523
  "<style>::gmail::maizzle": {
8524
8524
  language: "maizzle",
8525
- description: "Maizzle automatically inlines CSS via juice during build (inlineCSS: true in config.js). Manual <style> blocks bypass juice and will be stripped by Gmail \u2014 prefer Tailwind utility classes instead.",
8525
+ description: "Maizzle automatically inlines CSS via juice during build (inlineCSS: true in config.js). Manual <style> blocks bypass juice and will be stripped by Gmail; prefer Tailwind utility classes instead.",
8526
8526
  before: `<style>
8527
8527
  .custom { color: #6d28d9; }
8528
8528
  </style>
8529
8529
  <div class="custom">Hello</div>`,
8530
- after: `<!-- Prefer Tailwind classes \u2014 Maizzle inlines them automatically during build -->
8530
+ after: `<!-- Prefer Tailwind classes; Maizzle inlines them automatically during build -->
8531
8531
  <div class="text-[#6d28d9]">Hello</div>`
8532
8532
  },
8533
8533
  // ── max-width (Outlook Maizzle) ─────────────────────────────────────
@@ -8616,7 +8616,7 @@ var HTML_SUGGESTION_DATABASE = {
8616
8616
  "opacity": "Use solid colors instead of opacity.",
8617
8617
  // ── word-break ────────────────────────────────────────────────────────
8618
8618
  "word-break": "Wrap long text in a <table><td> to force wrapping in clients that don't support word-break.",
8619
- "word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width \u2014 tables always wrap.",
8619
+ "word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width; tables always wrap.",
8620
8620
  // ── overflow-wrap ─────────────────────────────────────────────────────
8621
8621
  "overflow-wrap": "Wrap text in a <table><td> to force wrapping. overflow-wrap is ignored by Outlook and unreliable in Yahoo.",
8622
8622
  // ── white-space ───────────────────────────────────────────────────────
@@ -8652,7 +8652,7 @@ var HTML_SUGGESTION_DATABASE = {
8652
8652
  // src/fix-snippets/jsx-suggestions.ts
8653
8653
  var JSX_SUGGESTION_DATABASE = {
8654
8654
  // ── <style> ───────────────────────────────────────────────────────────
8655
- "<style>::jsx": "Move styles to inline style props \u2014 React Email components accept style objects directly.",
8655
+ "<style>::jsx": "Move styles to inline style props; React Email components accept style objects directly.",
8656
8656
  "<style>:partial::jsx": "Use inline style props on React Email components. Reserve <style> in <Head> for progressive enhancement only.",
8657
8657
  // ── <link> ────────────────────────────────────────────────────────────
8658
8658
  "<link>::jsx": "Use the React Email <Head> component for font imports; place all other styles inline via style props.",
@@ -8714,7 +8714,7 @@ var MJML_SUGGESTION_DATABASE = {
8714
8714
  // ── @media ────────────────────────────────────────────────────────────
8715
8715
  "@media::mjml": "MJML generates responsive @media queries automatically. Use mj-breakpoint and mj-column widths.",
8716
8716
  // ── display:flex ──────────────────────────────────────────────────────
8717
- "display:flex::mjml": "Use mj-section and mj-column \u2014 MJML compiles these to table-based layouts.",
8717
+ "display:flex::mjml": "Use mj-section and mj-column; MJML compiles these to table-based layouts.",
8718
8718
  // ── display:grid ──────────────────────────────────────────────────────
8719
8719
  "display:grid::mjml": "Use mj-section and mj-column for grid-like layouts.",
8720
8720
  // ── linear-gradient ───────────────────────────────────────────────────
@@ -8722,7 +8722,7 @@ var MJML_SUGGESTION_DATABASE = {
8722
8722
  // ── box-shadow ────────────────────────────────────────────────────────
8723
8723
  "box-shadow::mjml": "Use a border attribute on mj-section or mj-column as an alternative.",
8724
8724
  // ── border-radius ─────────────────────────────────────────────────────
8725
- "border-radius::mjml": 'MJML does not generate VML \u2014 border-radius will not render in Outlook. Set border-radius="0" or accept flat corners.',
8725
+ "border-radius::mjml": 'MJML does not generate VML; border-radius will not render in Outlook. Set border-radius="0" or accept flat corners.',
8726
8726
  // ── max-width ─────────────────────────────────────────────────────────
8727
8727
  "max-width::mjml": "Set the width attribute on mj-body or mj-section for maximum compatibility.",
8728
8728
  // ── gap ───────────────────────────────────────────────────────────────
@@ -8730,7 +8730,7 @@ var MJML_SUGGESTION_DATABASE = {
8730
8730
  // ── float ─────────────────────────────────────────────────────────────
8731
8731
  "float::mjml": "Use mj-section with multiple mj-column elements for side-by-side layout.",
8732
8732
  // ── background-image ──────────────────────────────────────────────────
8733
- "background-image::mjml": "Use background-url attribute on mj-section \u2014 MJML generates VML automatically.",
8733
+ "background-image::mjml": "Use background-url attribute on mj-section; MJML generates VML automatically.",
8734
8734
  // ── position ──────────────────────────────────────────────────────────
8735
8735
  "position::mjml": "Use mj-section and mj-column for layout positioning.",
8736
8736
  // ── opacity ───────────────────────────────────────────────────────────
@@ -8742,10 +8742,10 @@ var MJML_SUGGESTION_DATABASE = {
8742
8742
  // src/fix-snippets/maizzle-suggestions.ts
8743
8743
  var MAIZZLE_SUGGESTION_DATABASE = {
8744
8744
  // ── <style> ───────────────────────────────────────────────────────────
8745
- "<style>::maizzle": "Prefer Tailwind utility classes \u2014 Maizzle inlines CSS via juice during build (inlineCSS: true in config.js).",
8745
+ "<style>::maizzle": "Prefer Tailwind utility classes; Maizzle inlines CSS via juice during build (inlineCSS: true in config.js).",
8746
8746
  "<style>:partial::maizzle": "Use Tailwind utility classes for critical styles. Maizzle automatically inlines them at build time.",
8747
8747
  // ── <link> ────────────────────────────────────────────────────────────
8748
- "<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes \u2014 Maizzle inlines them at build time.",
8748
+ "<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes; Maizzle inlines them at build time.",
8749
8749
  // ── <svg> ─────────────────────────────────────────────────────────────
8750
8750
  "<svg>::maizzle": "Replace inline SVG with an <img> tag pointing to a hosted PNG.",
8751
8751
  // ── <video> ───────────────────────────────────────────────────────────
@@ -8753,7 +8753,7 @@ var MAIZZLE_SUGGESTION_DATABASE = {
8753
8753
  // ── <form> ────────────────────────────────────────────────────────────
8754
8754
  "<form>::maizzle": "Replace the form with a CTA link/button pointing to a hosted form page.",
8755
8755
  // ── @font-face ────────────────────────────────────────────────────────
8756
- "@font-face::maizzle": "Use the googleFonts key in config.js \u2014 Maizzle injects the Google Fonts link tag automatically.",
8756
+ "@font-face::maizzle": "Use the googleFonts key in config.js; Maizzle injects the Google Fonts link tag automatically.",
8757
8757
  // ── @media ────────────────────────────────────────────────────────────
8758
8758
  "@media::maizzle": "Use Tailwind responsive utility classes and Maizzle's breakpoints config instead of hand-written @media.",
8759
8759
  // ── display:flex ──────────────────────────────────────────────────────
@@ -8924,6 +8924,30 @@ function serializeStyle(map) {
8924
8924
  });
8925
8925
  return parts.join("; ");
8926
8926
  }
8927
+ function splitTopLevel(value) {
8928
+ const parts = [];
8929
+ let current = "";
8930
+ let depth = 0;
8931
+ let quote = null;
8932
+ for (const ch of value) {
8933
+ if (quote) {
8934
+ if (ch === quote) quote = null;
8935
+ } else if (ch === '"' || ch === "'") {
8936
+ quote = ch;
8937
+ } else if (ch === "(") {
8938
+ depth++;
8939
+ } else if (ch === ")") {
8940
+ depth = Math.max(0, depth - 1);
8941
+ } else if (ch === "," && depth === 0) {
8942
+ parts.push(current);
8943
+ current = "";
8944
+ continue;
8945
+ }
8946
+ current += ch;
8947
+ }
8948
+ parts.push(current);
8949
+ return parts;
8950
+ }
8927
8951
 
8928
8952
  // src/downlevel.ts
8929
8953
  import * as cheerio from "cheerio";
@@ -9299,6 +9323,71 @@ function alphaBlend(fg, bgR, bgG, bgB) {
9299
9323
  Math.round(fg.b * a + bgB * (1 - a))
9300
9324
  ];
9301
9325
  }
9326
+ function backgroundShorthandColor(value) {
9327
+ if (!value) return null;
9328
+ const trimmed = value.trim();
9329
+ if (parseColor(trimmed)) return trimmed;
9330
+ for (const token of trimmed.split(/\s+/)) {
9331
+ if (token.includes("(")) continue;
9332
+ if (parseColor(token)) return token;
9333
+ }
9334
+ return null;
9335
+ }
9336
+ function functionArgs(value, nameRe) {
9337
+ const out = [];
9338
+ const re = new RegExp(nameRe.source + "\\(", nameRe.flags.includes("i") ? "gi" : "g");
9339
+ let match;
9340
+ while ((match = re.exec(value)) !== null) {
9341
+ let depth = 1;
9342
+ let i = match.index + match[0].length;
9343
+ const start = i;
9344
+ while (i < value.length && depth > 0) {
9345
+ if (value[i] === "(") depth++;
9346
+ else if (value[i] === ")") depth--;
9347
+ i++;
9348
+ }
9349
+ if (depth === 0) out.push(value.slice(start, i - 1));
9350
+ re.lastIndex = i;
9351
+ }
9352
+ return out;
9353
+ }
9354
+ function gradientStops(value) {
9355
+ if (!value) return [];
9356
+ const stops = [];
9357
+ for (const args of functionArgs(value, /(?:repeating-)?(?:linear|radial|conic)-gradient/i)) {
9358
+ for (const part of splitTopLevel(args)) {
9359
+ const trimmed = part.trim();
9360
+ if (!trimmed || /^(?:to\b|at\b|from\b|in\b|[\d.]+(?:deg|turn|rad|grad)\b)/i.test(trimmed)) continue;
9361
+ const fnMatch = trimmed.match(/^(?:rgba?|hsla?|color|lab|lch|oklab|oklch)\([^)]*\)/i);
9362
+ const token = fnMatch ? fnMatch[0] : trimmed.split(/\s+/)[0];
9363
+ const parsed = parseColor(token);
9364
+ if (parsed && parsed.a > 0) stops.push(parsed);
9365
+ }
9366
+ }
9367
+ return stops;
9368
+ }
9369
+ function gammaToLinear(c) {
9370
+ const s = c / 255;
9371
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
9372
+ }
9373
+ function rgbToOklab(rgba) {
9374
+ const r = gammaToLinear(rgba.r);
9375
+ const g = gammaToLinear(rgba.g);
9376
+ const b = gammaToLinear(rgba.b);
9377
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
9378
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
9379
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
9380
+ return [
9381
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
9382
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
9383
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
9384
+ ];
9385
+ }
9386
+ function colorDistance(a, b) {
9387
+ const [l1, a1, b1] = rgbToOklab(a);
9388
+ const [l2, a2, b2] = rgbToOklab(b);
9389
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
9390
+ }
9302
9391
 
9303
9392
  // src/downlevel.ts
9304
9393
  var LOGICAL_PROPERTY_MAP = {
@@ -9704,6 +9793,7 @@ var EMPTY_INBOX_PREVIEW = { subject: null, preheader: null, subjectLength: 0, pr
9704
9793
  var EMPTY_SIZE = { htmlBytes: 0, humanSize: "0 B", clipped: false, issues: [] };
9705
9794
  var EMPTY_TEMPLATE = { unresolvedCount: 0, issues: [] };
9706
9795
  var EMPTY_OVERFLOW = { hasOverflow: false, issues: [] };
9796
+ var EMPTY_VML = { hasVml: false, issues: [] };
9707
9797
  var EMPTY_VISUAL = { issues: [] };
9708
9798
  var EMAIL_MAX_WIDTH = 600;
9709
9799
  var UNBREAKABLE_STRING_LENGTH = 30;
@@ -9739,6 +9829,10 @@ var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
9739
9829
  "ui-monospace"
9740
9830
  ]);
9741
9831
  var EMPTY_DELIVERABILITY = { domain: "", checks: [], score: 0, issues: [] };
9832
+ var EMPTY_DESIGN = {
9833
+ issues: [],
9834
+ palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] }
9835
+ };
9742
9836
 
9743
9837
  // src/transform.ts
9744
9838
  function hasPseudoSelector(selector) {
@@ -10109,7 +10203,7 @@ var CLIENT_CONFIGS = {
10109
10203
  "outlook-macos": {
10110
10204
  id: "outlook-macos",
10111
10205
  // caniemail: the new (WebKit) Outlook for Mac supports position/transform/
10112
- // animation/box-shadow/opacity only transition is unsupported. It is NOT
10206
+ // animation/box-shadow/opacity; only transition is unsupported. It is NOT
10113
10207
  // the Word engine, so do not mirror outlook-web's broader strip set.
10114
10208
  strippedProperties: /* @__PURE__ */ new Set(["transition"]),
10115
10209
  stripMode: "strip",
@@ -10201,7 +10295,7 @@ var CLIENT_CONFIGS = {
10201
10295
  additionalChecks: heyAdditionalChecks
10202
10296
  },
10203
10297
  // ponytail: strip sets below are data-driven from caniemail (the same source
10204
- // as CSS_SUPPORT), not hand-guessed no manual re-verification debt.
10298
+ // as CSS_SUPPORT), not hand-guessed; no manual re-verification debt.
10205
10299
  "protonmail": {
10206
10300
  id: "protonmail",
10207
10301
  // caniemail: strips animation + external stylesheets + forms; supports
@@ -10215,7 +10309,7 @@ var CLIENT_CONFIGS = {
10215
10309
  },
10216
10310
  "aol": {
10217
10311
  id: "aol",
10218
- // caniemail: restrictive drops animation, box-shadow, opacity, transform,
10312
+ // caniemail: restrictive; drops animation, box-shadow, opacity, transform,
10219
10313
  // external stylesheets, and SVG. Forms are kept.
10220
10314
  strippedProperties: /* @__PURE__ */ new Set(["animation", "box-shadow", "opacity", "transform"]),
10221
10315
  stripMode: "strip",
@@ -10769,7 +10863,7 @@ function simulateDarkMode(html, clientId) {
10769
10863
  client: clientId,
10770
10864
  property: "dark-mode",
10771
10865
  message: "Superhuman respects @media (prefers-color-scheme: dark). Many Superhuman users run in dark mode.",
10772
- suggestion: "Add @media (prefers-color-scheme: dark) styles \u2014 Superhuman's power-user audience often prefers dark mode."
10866
+ suggestion: "Add @media (prefers-color-scheme: dark) styles; Superhuman's power-user audience often prefers dark mode."
10773
10867
  });
10774
10868
  }
10775
10869
  break;
@@ -10908,11 +11002,11 @@ function locOfElement(el) {
10908
11002
  if (!raw) return void 0;
10909
11003
  return toLoc((_a = raw.startTag) != null ? _a : raw);
10910
11004
  }
10911
- function locOfAttr(el, attr) {
11005
+ function locOfAttr(el, attr2) {
10912
11006
  var _a, _b, _c, _d;
10913
11007
  const raw = el == null ? void 0 : el.sourceCodeLocation;
10914
11008
  if (!raw) return void 0;
10915
- const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[attr]) != null ? _d : (_c = (_b = raw.startTag) == null ? void 0 : _b.attrs) == null ? void 0 : _c[attr];
11009
+ const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[attr2]) != null ? _d : (_c = (_b = raw.startTag) == null ? void 0 : _b.attrs) == null ? void 0 : _c[attr2];
10916
11010
  return attrLoc ? toLoc(attrLoc) : locOfElement(el);
10917
11011
  }
10918
11012
  function locInAttr(attrLoc, source, property, occurrence = 0) {
@@ -11145,16 +11239,6 @@ function positionAt(data, index, anchor) {
11145
11239
  // src/dark-mode-checker.ts
11146
11240
  var DARK_MEDIA_RE = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
11147
11241
  var MAX_UNCOVERED_ELEMENTS = 3;
11148
- function backgroundShorthandColor(value) {
11149
- if (!value) return null;
11150
- const trimmed = value.trim();
11151
- if (parseColor(trimmed)) return trimmed;
11152
- for (const token of trimmed.split(/\s+/)) {
11153
- if (token.includes("(")) continue;
11154
- if (parseColor(token)) return token;
11155
- }
11156
- return null;
11157
- }
11158
11242
  function isLight(value) {
11159
11243
  const c = parseColor(value);
11160
11244
  if (!c || c.a < 0.5) return false;
@@ -11272,7 +11356,7 @@ function checkDarkModeFromDom($, source) {
11272
11356
  severity: "warning",
11273
11357
  client: clientId,
11274
11358
  property: "dark-mode-coverage",
11275
- message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode \u2014 the dark block never overrides it. The rest of the email inverts around it, producing a half-inverted render (e.g. light text left on a still-white background).`,
11359
+ message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode; the dark block never overrides it. The rest of the email inverts around it, producing a half-inverted render (e.g. light text left on a still-white background).`,
11276
11360
  suggestion: inline ? `Override it inside @media (prefers-color-scheme: dark) with a dark background-color and !important (an inline style beats a plain rule), or move the colour onto a class the dark block already targets.` : `Override it inside @media (prefers-color-scheme: dark) with a dark background-color, or give the element a class the dark block already targets.`,
11277
11361
  fixType: "css",
11278
11362
  selector
@@ -11345,7 +11429,7 @@ var COMPOUND_DETECTORS = [
11345
11429
  var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
11346
11430
  key: fn,
11347
11431
  pattern: `${fn}(`
11348
- // require opening paren matches "min(" but not "Minion"
11432
+ // require opening paren, matches "min(" but not "Minion"
11349
11433
  }));
11350
11434
  function analyzeEmailFromDom($, framework, source) {
11351
11435
  const warnings = [];
@@ -11843,14 +11927,14 @@ ${originalHtml}
11843
11927
  - **${w.property}** (${clientName})${fixTypeLabel}: ${w.message}`;
11844
11928
  if (w.fixType === "structural") {
11845
11929
  issueSection += `
11846
- - **Fix type: structural** \u2014 CSS-only changes will NOT work. HTML restructuring required.`;
11930
+ - **Fix type: structural**. CSS-only changes will NOT work. HTML restructuring required.`;
11847
11931
  }
11848
11932
  if (w.suggestion) {
11849
11933
  issueSection += `
11850
11934
  - Suggestion: ${w.suggestion}`;
11851
11935
  }
11852
11936
  if (w.fix) {
11853
- const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML \u2014 adapt to ${format.toUpperCase()} syntax)` : "Fix";
11937
+ const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML, adapt to ${format.toUpperCase()} syntax)` : "Fix";
11854
11938
  issueSection += `
11855
11939
  - ${fixLabel}:`;
11856
11940
  issueSection += `
@@ -11882,10 +11966,10 @@ Client-agnostic rendering bugs. Apply each fix directly to the code.
11882
11966
  sections.push(s);
11883
11967
  }
11884
11968
  const formatInstructions = {
11885
- jsx: `Apply all the fixes listed above to the original email code. Return complete fixed JSX code using @react-email/components. Use Row, Column, Container, Font, Img, Head, and Link components from @react-email/components wherever the fix suggests them. Keep all style values as camelCase JavaScript object properties (e.g. { backgroundColor: "#fff" }). Ensure the result is compatible with ${clientLabel}. Do not remove any content \u2014 only modify the JSX structure and style props needed to fix the issues.`,
11886
- mjml: `Apply all the fixes listed above to the original email code. Return complete fixed MJML markup. Use MJML-native elements (mj-section, mj-column, mj-text, mj-button, mj-font, mj-style, mj-raw) as indicated in the fixes. Ensure the result is valid MJML that compiles without errors. Ensure the result is compatible with ${clientLabel}. Do not remove any content \u2014 only modify the MJML structure and attributes needed to fix the issues.`,
11887
- maizzle: `Apply all the fixes listed above to the original email code. Return the complete fixed Maizzle template. Use Tailwind CSS utility classes and Maizzle config settings as indicated in the fixes. Add MSO conditional comment table wrappers where needed for Outlook compatibility. Ensure the result is compatible with ${clientLabel}. Do not remove any content \u2014 only modify the Tailwind classes and HTML structure needed to fix the issues.`,
11888
- html: `Apply all the fixes listed above to the original email code. Return the complete fixed HTML code. Ensure the result is compatible with ${clientLabel}. Do not remove any content \u2014 only modify the CSS and HTML attributes needed to fix the issues.`
11969
+ jsx: `Apply all the fixes listed above to the original email code. Return complete fixed JSX code using @react-email/components. Use Row, Column, Container, Font, Img, Head, and Link components from @react-email/components wherever the fix suggests them. Keep all style values as camelCase JavaScript object properties (e.g. { backgroundColor: "#fff" }). Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the JSX structure and style props needed to fix the issues.`,
11970
+ mjml: `Apply all the fixes listed above to the original email code. Return complete fixed MJML markup. Use MJML-native elements (mj-section, mj-column, mj-text, mj-button, mj-font, mj-style, mj-raw) as indicated in the fixes. Ensure the result is valid MJML that compiles without errors. Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the MJML structure and attributes needed to fix the issues.`,
11971
+ maizzle: `Apply all the fixes listed above to the original email code. Return the complete fixed Maizzle template. Use Tailwind CSS utility classes and Maizzle config settings as indicated in the fixes. Add MSO conditional comment table wrappers where needed for Outlook compatibility. Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the Tailwind classes and HTML structure needed to fix the issues.`,
11972
+ html: `Apply all the fixes listed above to the original email code. Return the complete fixed HTML code. Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the CSS and HTML attributes needed to fix the issues.`
11889
11973
  };
11890
11974
  sections.push(
11891
11975
  `## Instructions
@@ -12017,7 +12101,7 @@ var AI_FIX_SYSTEM_PROMPT = `You are an expert email developer specializing in cr
12017
12101
  Rules:
12018
12102
  - Return ONLY the fixed code inside a single code fence. No explanations before or after.
12019
12103
  - Preserve all existing content, text, links, and visual design.
12020
- - For structural issues (fixType: "structural"), you MUST restructure the HTML \u2014 CSS-only changes will not work.
12104
+ - For structural issues (fixType: "structural"), you MUST restructure the HTML; CSS-only changes will not work.
12021
12105
  - Common structural patterns:
12022
12106
  - word-break/overflow-wrap unsupported \u2192 wrap text in <table><tr><td> with constrained width
12023
12107
  - display:flex/grid \u2192 convert to <table> layout (match the original column count and proportions)
@@ -12028,7 +12112,7 @@ Rules:
12028
12112
  - <svg> \u2192 replace with <img> pointing to a hosted PNG
12029
12113
  - For CSS-only issues (fixType: "css"), swap properties or add fallbacks.
12030
12114
  - For "Layout & Visual Issues", apply the provided Fix directly: add the background-color fallback beneath a gradient/image, append a web-safe font to the stack, and constrain fixed widths wider than the frame to width:100% with max-width.
12031
- - Apply ALL fixes from the issues list \u2014 do not skip any.
12115
+ - Apply ALL fixes from the issues list: do not skip any.
12032
12116
  - Use the framework syntax specified (JSX/MJML/Maizzle/HTML).
12033
12117
  - For JSX: use camelCase style props, React Email components, and proper TypeScript types.
12034
12118
  - For MJML: use mj-* elements and attributes.
@@ -12439,7 +12523,7 @@ function checkCapsRatio(text) {
12439
12523
  return {
12440
12524
  rule: "caps-ratio",
12441
12525
  severity: "warning",
12442
- message: `${Math.round(ratio * 100)}% of words are ALL CAPS \u2014 spam filters flag excessive capitalization.`,
12526
+ message: `${Math.round(ratio * 100)}% of words are ALL CAPS; spam filters flag excessive capitalization.`,
12443
12527
  detail: `Found ${capsWords.length} of ${words.length} words in all caps.`
12444
12528
  };
12445
12529
  }
@@ -12454,7 +12538,7 @@ function checkExcessivePunctuation(text) {
12454
12538
  return {
12455
12539
  rule: "excessive-punctuation",
12456
12540
  severity: "warning",
12457
- message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$") \u2014 common spam trigger.`
12541
+ message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$"), a common spam trigger.`
12458
12542
  };
12459
12543
  }
12460
12544
  return null;
@@ -12496,14 +12580,14 @@ function checkUnsubscribe($, text, options) {
12496
12580
  return {
12497
12581
  rule: "missing-unsubscribe",
12498
12582
  severity: "info",
12499
- message: "No unsubscribe link found, but email appears transactional \u2014 may not be required.",
12583
+ message: "No unsubscribe link found, but email appears transactional, so it may not be required.",
12500
12584
  detail: `Detected transactional signals: ${signalMatches.join(", ")}${hasOtp ? ", OTP code" : ""}`
12501
12585
  };
12502
12586
  }
12503
12587
  return {
12504
12588
  rule: "missing-unsubscribe",
12505
12589
  severity: "error",
12506
- message: "No unsubscribe link found \u2014 required by CAN-SPAM and GDPR. Most spam filters penalize this.",
12590
+ message: "No unsubscribe link found; required by CAN-SPAM and GDPR. Most spam filters penalize this.",
12507
12591
  detail: 'Add an <a> link with "unsubscribe" text or href.'
12508
12592
  };
12509
12593
  }
@@ -12553,7 +12637,7 @@ function checkHiddenText($) {
12553
12637
  return {
12554
12638
  rule: "hidden-text",
12555
12639
  severity: "error",
12556
- message: "Hidden text detected \u2014 major spam filter red flag.",
12640
+ message: "Hidden text detected: a major spam filter red flag.",
12557
12641
  detail
12558
12642
  };
12559
12643
  }
@@ -12576,7 +12660,7 @@ function checkUrlShorteners($) {
12576
12660
  issues.push({
12577
12661
  rule: "url-shortener",
12578
12662
  severity: "warning",
12579
- message: `URL shortener detected (${shortener}) \u2014 spam filters distrust shortened links.`,
12663
+ message: `URL shortener detected (${shortener}); spam filters distrust shortened links.`,
12580
12664
  detail: href
12581
12665
  });
12582
12666
  }
@@ -12591,7 +12675,7 @@ function checkImageToTextRatio($, text) {
12591
12675
  return {
12592
12676
  rule: "image-only",
12593
12677
  severity: "error",
12594
- message: `Image-heavy email with almost no text (${text.length} chars, ${images} images) \u2014 likely to be flagged as spam or clipped.`
12678
+ message: `Image-heavy email with almost no text (${text.length} chars, ${images} images), likely to be flagged as spam or clipped.`
12595
12679
  };
12596
12680
  }
12597
12681
  const ratio = images / (text.length / 100);
@@ -12599,7 +12683,7 @@ function checkImageToTextRatio($, text) {
12599
12683
  return {
12600
12684
  rule: "high-image-ratio",
12601
12685
  severity: "warning",
12602
- message: `High image-to-text ratio (${images} images for ${text.length} chars of text) \u2014 consider adding more text content.`
12686
+ message: `High image-to-text ratio (${images} images for ${text.length} chars of text); consider adding more text content.`
12603
12687
  };
12604
12688
  }
12605
12689
  return null;
@@ -12630,7 +12714,7 @@ function checkDeceptiveLinks($) {
12630
12714
  issues.push({
12631
12715
  rule: "deceptive-link",
12632
12716
  severity: "error",
12633
- message: `Link text shows "${textDomain}" but links to "${hrefDomain}" \u2014 phishing red flag.`,
12717
+ message: `Link text shows "${textDomain}" but links to "${hrefDomain}": a phishing red flag.`,
12634
12718
  detail: `Text: ${text}
12635
12719
  Href: ${href}`
12636
12720
  });
@@ -12669,7 +12753,7 @@ function checkPhysicalAddress($, text, options) {
12669
12753
  return {
12670
12754
  rule: "missing-physical-address",
12671
12755
  severity: "warning",
12672
- message: "No physical mailing address detected \u2014 required by CAN-SPAM for marketing emails.",
12756
+ message: "No physical mailing address detected; required by CAN-SPAM for marketing emails.",
12673
12757
  detail: "Include a street address or P.O. Box in the email footer."
12674
12758
  };
12675
12759
  }
@@ -12692,7 +12776,7 @@ function checkAllCapsTitle($) {
12692
12776
  return {
12693
12777
  rule: "all-caps-subject",
12694
12778
  severity: "warning",
12695
- message: "Email title/subject is ALL CAPS \u2014 common spam indicator."
12779
+ message: "Email title/subject is ALL CAPS: a common spam indicator."
12696
12780
  };
12697
12781
  }
12698
12782
  return null;
@@ -12851,7 +12935,7 @@ function validateLinksFromDom($) {
12851
12935
  issues.push(__spreadValues({
12852
12936
  severity: "warning",
12853
12937
  rule: "protocol-relative",
12854
- message: "Protocol-relative URL may break in email clients \u2014 use https:// explicitly",
12938
+ message: "Protocol-relative URL may break in email clients; use https:// explicitly",
12855
12939
  href: href.slice(0, 120),
12856
12940
  text: text.slice(0, 80) || "(no text)"
12857
12941
  }, hrefLoc ? { loc: hrefLoc } : {}));
@@ -12860,7 +12944,7 @@ function validateLinksFromDom($) {
12860
12944
  issues.push(__spreadValues({
12861
12945
  severity: "warning",
12862
12946
  rule: "generic-link-text",
12863
- message: `Link text "${text}" is vague \u2014 use descriptive text for accessibility and engagement`,
12947
+ message: `Link text "${text}" is vague; use descriptive text for accessibility and engagement`,
12864
12948
  href: href.slice(0, 120),
12865
12949
  text
12866
12950
  }, elLoc ? { loc: elLoc } : {}));
@@ -12895,7 +12979,7 @@ function validateLinksFromDom($) {
12895
12979
  issues.push(__spreadValues({
12896
12980
  severity: "info",
12897
12981
  rule: "long-url",
12898
- message: "URL exceeds 2000 characters \u2014 may be truncated by some email clients",
12982
+ message: "URL exceeds 2000 characters, may be truncated by some email clients",
12899
12983
  href: href.slice(0, 120) + "...",
12900
12984
  text: text.slice(0, 80) || "(no text)"
12901
12985
  }, hrefLoc ? { loc: hrefLoc } : {}));
@@ -12924,7 +13008,7 @@ function validateLinksFromDom($) {
12924
13008
  issues.push({
12925
13009
  severity: "info",
12926
13010
  rule: "duplicate-links",
12927
- message: `URL appears ${count} times \u2014 consider consolidating`,
13011
+ message: `URL appears ${count} times; consider consolidating`,
12928
13012
  href: href.slice(0, 120)
12929
13013
  });
12930
13014
  }
@@ -12936,6 +13020,8 @@ function validateLinks(html, options) {
12936
13020
  }
12937
13021
 
12938
13022
  // src/accessibility-checker.ts
13023
+ import * as cheerio5 from "cheerio";
13024
+ import * as csstree6 from "css-tree";
12939
13025
  var RULE_PENALTY_CAPS = {
12940
13026
  "img-missing-alt": 3,
12941
13027
  "link-generic-text": 3,
@@ -13008,7 +13094,7 @@ function checkImageAlt($) {
13008
13094
  issues.push(__spreadProps(__spreadValues({
13009
13095
  severity: "info",
13010
13096
  rule: "img-empty-alt",
13011
- message: "Image has empty alt text \u2014 verify it is decorative",
13097
+ message: "Image has empty alt text; verify it is decorative",
13012
13098
  element: describeElement($, el)
13013
13099
  }, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
13014
13100
  details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
@@ -13083,7 +13169,352 @@ function checkTableAccessibility($) {
13083
13169
  });
13084
13170
  return issues;
13085
13171
  }
13086
- function checkTextSizeAndContrast($) {
13172
+ var CLIENT_DEFAULT_BACKGROUND = { r: 255, g: 255, b: 255 };
13173
+ var CLIENT_DEFAULT_TEXT = { r: 0, g: 0, b: 0, a: 1 };
13174
+ var UNRESOLVABLE_PAINT_RE = /url\(|(?:linear|radial|conic)-gradient\(/i;
13175
+ var NON_RENDERING_TAGS = /* @__PURE__ */ new Set(["head", "style", "script", "title", "meta", "link"]);
13176
+ var CASCADE_PROPS = /* @__PURE__ */ new Set([
13177
+ "color",
13178
+ "background",
13179
+ "background-color",
13180
+ "background-image",
13181
+ "font-size",
13182
+ "font-weight",
13183
+ // Not for contrast itself, for deciding whether the text renders at all.
13184
+ "display",
13185
+ "visibility",
13186
+ "opacity"
13187
+ ]);
13188
+ var DESKTOP_RENDER = { width: 640, dark: false };
13189
+ var MOBILE_RENDER = { width: 375, dark: false };
13190
+ var DARK_RENDER = { width: 640, dark: true };
13191
+ function mediaPx(raw) {
13192
+ const m = raw.match(/([\d.]+)\s*(px|em|rem)?/i);
13193
+ if (!m) return null;
13194
+ const n = parseFloat(m[1]);
13195
+ if (!Number.isFinite(n)) return null;
13196
+ return (m[2] || "px").toLowerCase() === "px" ? n : n * 16;
13197
+ }
13198
+ function mediaApplies(prelude, ctx) {
13199
+ const text = prelude.toLowerCase();
13200
+ if (/\bprint\b/.test(text) && !/\bscreen\b/.test(text)) return false;
13201
+ const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
13202
+ if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
13203
+ for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
13204
+ const px = mediaPx(m[2]);
13205
+ if (px === null) continue;
13206
+ if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
13207
+ }
13208
+ return true;
13209
+ }
13210
+ var FORWARDING_PSEUDOS = /* @__PURE__ */ new Set(["not", "is", "has", "matches", "any"]);
13211
+ function maxSpecificity(list) {
13212
+ let best = [0, 0, 0];
13213
+ list.children.forEach((sel) => {
13214
+ const s = specificityOfSelector(sel);
13215
+ if (moreSpecific(s, best)) best = s;
13216
+ });
13217
+ return best;
13218
+ }
13219
+ function specificityOfSelector(node) {
13220
+ const acc = [0, 0, 0];
13221
+ if (node.type !== "Selector") return acc;
13222
+ node.children.forEach((child) => {
13223
+ var _a;
13224
+ switch (child.type) {
13225
+ case "IdSelector":
13226
+ acc[0]++;
13227
+ break;
13228
+ case "ClassSelector":
13229
+ case "AttributeSelector":
13230
+ acc[1]++;
13231
+ break;
13232
+ case "TypeSelector":
13233
+ if (child.name !== "*") acc[2]++;
13234
+ break;
13235
+ case "PseudoElementSelector":
13236
+ acc[2]++;
13237
+ break;
13238
+ case "PseudoClassSelector": {
13239
+ const name = child.name.toLowerCase();
13240
+ if (name === "where") break;
13241
+ const inner = (_a = child.children) == null ? void 0 : _a.first;
13242
+ if (inner && inner.type === "SelectorList" && FORWARDING_PSEUDOS.has(name)) {
13243
+ const nested = maxSpecificity(inner);
13244
+ acc[0] += nested[0];
13245
+ acc[1] += nested[1];
13246
+ acc[2] += nested[2];
13247
+ break;
13248
+ }
13249
+ acc[1]++;
13250
+ break;
13251
+ }
13252
+ default:
13253
+ break;
13254
+ }
13255
+ });
13256
+ return acc;
13257
+ }
13258
+ function specificityFromTokens(selector) {
13259
+ const bare = selector.replace(/\([^)]*\)/g, "").replace(/["'][^"']*["']/g, "");
13260
+ return [
13261
+ (bare.match(/#[\w-]+/g) || []).length,
13262
+ (bare.match(/\.[\w-]+|\[[^\]]*\]|:[\w-]+/g) || []).length,
13263
+ (bare.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length
13264
+ ];
13265
+ }
13266
+ function specificityOf(selector) {
13267
+ try {
13268
+ const ast = csstree6.parse(selector, { context: "selector" });
13269
+ return specificityOfSelector(ast);
13270
+ } catch (e) {
13271
+ return specificityFromTokens(selector);
13272
+ }
13273
+ }
13274
+ function moreSpecific(a, b) {
13275
+ for (let i = 0; i < 3; i++) {
13276
+ if (a[i] !== b[i]) return a[i] > b[i];
13277
+ }
13278
+ return false;
13279
+ }
13280
+ function wins(next, current) {
13281
+ if (!current) return true;
13282
+ if (next.important !== current.important) return next.important;
13283
+ if (next.presentational !== current.presentational) return current.presentational;
13284
+ if (next.inline !== current.inline) return next.inline;
13285
+ if (moreSpecific(next.specificity, current.specificity)) return true;
13286
+ if (moreSpecific(current.specificity, next.specificity)) return false;
13287
+ return next.order >= current.order;
13288
+ }
13289
+ function computeCascade($, ctx) {
13290
+ const cascade = /* @__PURE__ */ new Map();
13291
+ let order = 0;
13292
+ const record = (el, prop, decl) => {
13293
+ let props = cascade.get(el);
13294
+ if (!props) {
13295
+ props = /* @__PURE__ */ new Map();
13296
+ cascade.set(el, props);
13297
+ }
13298
+ if (wins(decl, props.get(prop))) props.set(prop, decl);
13299
+ };
13300
+ $("[bgcolor]").each((_, el) => {
13301
+ const value = $(el).attr("bgcolor");
13302
+ if (!value) return;
13303
+ record(el, "background-color", {
13304
+ value,
13305
+ important: false,
13306
+ inline: false,
13307
+ presentational: true,
13308
+ specificity: [0, 0, 0],
13309
+ order: order++
13310
+ });
13311
+ });
13312
+ const handleRule = (node) => {
13313
+ const selectorList = csstree6.generate(node.prelude).trim();
13314
+ if (!selectorList) return;
13315
+ const decls = [];
13316
+ node.block.children.forEach((child) => {
13317
+ if (child.type !== "Declaration") return;
13318
+ const prop = child.property.toLowerCase();
13319
+ if (!CASCADE_PROPS.has(prop)) return;
13320
+ decls.push({ prop, value: csstree6.generate(child.value), important: !!child.important });
13321
+ });
13322
+ if (!decls.length) return;
13323
+ for (const selector of splitTopLevel(selectorList).map((x) => x.trim()).filter(Boolean)) {
13324
+ const specificity = specificityOf(selector);
13325
+ let matched;
13326
+ try {
13327
+ matched = $(selector).toArray();
13328
+ } catch (e) {
13329
+ continue;
13330
+ }
13331
+ for (const el of matched) {
13332
+ for (const d of decls) {
13333
+ record(el, d.prop, __spreadProps(__spreadValues({}, d), {
13334
+ inline: false,
13335
+ presentational: false,
13336
+ specificity,
13337
+ order: order++
13338
+ }));
13339
+ }
13340
+ }
13341
+ }
13342
+ };
13343
+ const visitBlock = (children) => {
13344
+ children.forEach((node) => {
13345
+ if (node.type === "Rule") {
13346
+ handleRule(node);
13347
+ return;
13348
+ }
13349
+ if (node.type !== "Atrule" || node.name.toLowerCase() !== "media" || !node.block) return;
13350
+ const prelude = node.prelude ? csstree6.generate(node.prelude) : "";
13351
+ if (!mediaApplies(prelude, ctx)) return;
13352
+ visitBlock(node.block.children);
13353
+ });
13354
+ };
13355
+ $("style").each((_, styleEl) => {
13356
+ let ast;
13357
+ try {
13358
+ ast = csstree6.parse($(styleEl).text());
13359
+ } catch (e) {
13360
+ return;
13361
+ }
13362
+ if (ast.type !== "StyleSheet") return;
13363
+ visitBlock(ast.children);
13364
+ });
13365
+ $("[style]").each((_, el) => {
13366
+ for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
13367
+ const colon = part.indexOf(":");
13368
+ if (colon === -1) continue;
13369
+ const prop = part.slice(0, colon).trim().toLowerCase();
13370
+ if (!CASCADE_PROPS.has(prop)) continue;
13371
+ let value = part.slice(colon + 1).trim();
13372
+ const important = /!\s*important$/i.test(value);
13373
+ if (important) value = value.replace(/!\s*important$/i, "").trim();
13374
+ record(el, prop, {
13375
+ value,
13376
+ important,
13377
+ inline: true,
13378
+ presentational: false,
13379
+ specificity: [0, 0, 0],
13380
+ order: order++
13381
+ });
13382
+ }
13383
+ });
13384
+ return cascade;
13385
+ }
13386
+ function paintedColor(value) {
13387
+ if (!value) return null;
13388
+ const solid = backgroundShorthandColor(value);
13389
+ if (solid) {
13390
+ const c = parseColor(solid);
13391
+ if (c && c.a > 0) return [c, ...gradientStops(value)];
13392
+ }
13393
+ const stops = gradientStops(value);
13394
+ if (stops.length) return stops;
13395
+ return UNRESOLVABLE_PAINT_RE.test(value) ? "unknown" : null;
13396
+ }
13397
+ function asColors(painted) {
13398
+ return painted && painted !== "unknown" ? painted : [];
13399
+ }
13400
+ function elementBackground(props) {
13401
+ const shorthand = props.get("background");
13402
+ const longhand = props.get("background-color");
13403
+ const image = props.get("background-image");
13404
+ const colorDecl = shorthand && wins(shorthand, longhand) ? shorthand : longhand;
13405
+ const candidates = [
13406
+ ...colorDecl ? asColors(paintedColor(colorDecl.value)) : [],
13407
+ ...image ? asColors(paintedColor(image.value)) : []
13408
+ ];
13409
+ if (candidates.length) return candidates;
13410
+ for (const decl of [colorDecl, image]) {
13411
+ if (decl && UNRESOLVABLE_PAINT_RE.test(decl.value)) return "unknown";
13412
+ }
13413
+ return null;
13414
+ }
13415
+ function resolveBackground($, el, cascade) {
13416
+ for (const node of [el, ...$(el).parents().toArray()]) {
13417
+ const props = cascade.get(node);
13418
+ if (!props) continue;
13419
+ const painted = elementBackground(props);
13420
+ if (painted !== null) return painted;
13421
+ }
13422
+ return null;
13423
+ }
13424
+ function isLargeText(size, weight) {
13425
+ const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
13426
+ if (!match) return false;
13427
+ const px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
13428
+ const w = weight == null ? void 0 : weight.trim().toLowerCase();
13429
+ const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
13430
+ return px >= 18 || px >= 14 && bold;
13431
+ }
13432
+ function inherited($, el, cascade, prop) {
13433
+ var _a, _b;
13434
+ for (const node of [el, ...$(el).parents().toArray()]) {
13435
+ const value = (_b = (_a = cascade.get(node)) == null ? void 0 : _a.get(prop)) == null ? void 0 : _b.value;
13436
+ if (value) return { value, source: node };
13437
+ }
13438
+ return null;
13439
+ }
13440
+ function isHiddenText($, el, cascade) {
13441
+ var _a, _b, _c, _d, _e, _f, _g, _h;
13442
+ for (const node of [el, ...$(el).parents().toArray()]) {
13443
+ const props = cascade.get(node);
13444
+ if (!props) continue;
13445
+ const display = (_b = (_a = props.get("display")) == null ? void 0 : _a.value) == null ? void 0 : _b.trim().toLowerCase();
13446
+ if (display === "none") return true;
13447
+ const visibility = (_d = (_c = props.get("visibility")) == null ? void 0 : _c.value) == null ? void 0 : _d.trim().toLowerCase();
13448
+ if (visibility === "hidden" || visibility === "collapse") return true;
13449
+ const opacity = (_f = (_e = props.get("opacity")) == null ? void 0 : _e.value) == null ? void 0 : _f.trim();
13450
+ if (opacity !== void 0 && parseFloat(opacity) === 0) return true;
13451
+ const size = (_h = (_g = props.get("font-size")) == null ? void 0 : _g.value) == null ? void 0 : _h.trim();
13452
+ if (size !== void 0 && /^0(?:px|pt|em|rem|%)?$/i.test(size)) return true;
13453
+ }
13454
+ return $(el).closest("[style*='mso-hide']").length > 0;
13455
+ }
13456
+ function hasOwnText($, el) {
13457
+ return $(el).contents().toArray().some((n) => n.type === "text" && typeof n.data === "string" && n.data.trim().length > 0);
13458
+ }
13459
+ function checkContrast($, cascade) {
13460
+ const issues = [];
13461
+ $("*").each((_, el) => {
13462
+ var _a, _b, _c, _d, _e;
13463
+ const tag = (el.tagName || "").toLowerCase();
13464
+ if (NON_RENDERING_TAGS.has(tag)) return;
13465
+ if (!hasOwnText($, el)) return;
13466
+ if (isHiddenText($, el, cascade)) return;
13467
+ const declared = inherited($, el, cascade, "color");
13468
+ const fg = declared ? parseColor(declared.value) : CLIENT_DEFAULT_TEXT;
13469
+ if (!fg) return;
13470
+ const colorValue = (_a = declared == null ? void 0 : declared.value) != null ? _a : "the client default";
13471
+ const background = resolveBackground($, el, cascade);
13472
+ if (background === "unknown") return;
13473
+ const surfaces = background != null ? background : [null];
13474
+ let ratio = Infinity;
13475
+ let worst = "";
13476
+ for (const surface of surfaces) {
13477
+ let { r: bgR, g: bgG, b: bgB } = surface != null ? surface : CLIENT_DEFAULT_BACKGROUND;
13478
+ if (surface && surface.a < 1) {
13479
+ [bgR, bgG, bgB] = alphaBlend(surface, 255, 255, 255);
13480
+ }
13481
+ const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
13482
+ const candidate = contrastRatio(relativeLuminance(fR, fG, fB), relativeLuminance(bgR, bgG, bgB));
13483
+ if (candidate < ratio) {
13484
+ ratio = candidate;
13485
+ worst = formatRgb(surface != null ? surface : __spreadProps(__spreadValues({}, CLIENT_DEFAULT_BACKGROUND), { a: 1 }));
13486
+ }
13487
+ }
13488
+ const against = surfaces.length > 1 ? ` (worst gradient stop ${worst})` : "";
13489
+ const large = isLargeText(
13490
+ (_b = inherited($, el, cascade, "font-size")) == null ? void 0 : _b.value,
13491
+ (_c = inherited($, el, cascade, "font-weight")) == null ? void 0 : _c.value
13492
+ );
13493
+ const grade = wcagGrade(ratio);
13494
+ const styleLoc = (_e = locOfAttr((_d = declared == null ? void 0 : declared.source) != null ? _d : el, "style")) != null ? _e : locOfAttr(el, "style");
13495
+ if (grade === "Fail") {
13496
+ issues.push(__spreadProps(__spreadValues({
13497
+ severity: "error",
13498
+ rule: "low-contrast",
13499
+ message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG minimum`,
13500
+ element: describeElement($, el)
13501
+ }, styleLoc ? { loc: styleLoc } : {}), {
13502
+ details: `Foreground ${colorValue} on background${against} needs at least ${large ? "3:1" : "4.5:1"} contrast ratio.`
13503
+ }));
13504
+ } else if (!large && grade === "AA Large") {
13505
+ issues.push(__spreadProps(__spreadValues({
13506
+ severity: "warning",
13507
+ rule: "low-contrast",
13508
+ message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG AA for normal text`,
13509
+ element: describeElement($, el)
13510
+ }, styleLoc ? { loc: styleLoc } : {}), {
13511
+ details: `Foreground ${colorValue} on background${against} needs at least 4.5:1 for normal-sized text.`
13512
+ }));
13513
+ }
13514
+ });
13515
+ return issues;
13516
+ }
13517
+ function checkTextSize($) {
13087
13518
  const issues = [];
13088
13519
  let smallTextCount = 0;
13089
13520
  $("[style]").each((_, el) => {
@@ -13108,67 +13539,6 @@ function checkTextSizeAndContrast($) {
13108
13539
  }
13109
13540
  }
13110
13541
  }
13111
- const colorValue = getStyleValue(style, "color");
13112
- if (colorValue) {
13113
- const fg = parseColor(colorValue);
13114
- if (fg) {
13115
- let bgR = 255, bgG = 255, bgB = 255;
13116
- let current = $(el);
13117
- let foundBg = false;
13118
- const elements = [current, ...$(el).parents("[style]").toArray().map((p) => $(p))];
13119
- for (const ancestor of elements) {
13120
- const ancestorStyle = (typeof ancestor === "function" ? ancestor : $(ancestor)).attr("style") || "";
13121
- const bgValue = getStyleValue(ancestorStyle, "background-color");
13122
- if (bgValue) {
13123
- const bg = parseColor(bgValue);
13124
- if (bg && bg.a > 0) {
13125
- if (bg.a < 1) {
13126
- [bgR, bgG, bgB] = alphaBlend(bg, 255, 255, 255);
13127
- } else {
13128
- bgR = bg.r;
13129
- bgG = bg.g;
13130
- bgB = bg.b;
13131
- }
13132
- foundBg = true;
13133
- break;
13134
- }
13135
- }
13136
- }
13137
- const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
13138
- const fgLum = relativeLuminance(fR, fG, fB);
13139
- const bgLum = relativeLuminance(bgR, bgG, bgB);
13140
- const ratio = contrastRatio(fgLum, bgLum);
13141
- let isLargeText = false;
13142
- if (fontSizeMatch) {
13143
- const size = parseFloat(fontSizeMatch[1]);
13144
- const unit = fontSizeMatch[2].toLowerCase();
13145
- const pxSize = unit === "pt" ? size * 1.333 : size;
13146
- const fontWeight = getStyleValue(style, "font-weight");
13147
- const isBold = fontWeight === "bold" || fontWeight === "bolder" || fontWeight && parseInt(fontWeight, 10) >= 700;
13148
- isLargeText = pxSize >= 18 || pxSize >= 14 && !!isBold;
13149
- }
13150
- const grade = wcagGrade(ratio);
13151
- if (grade === "Fail") {
13152
- issues.push(__spreadProps(__spreadValues({
13153
- severity: "error",
13154
- rule: "low-contrast",
13155
- message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG minimum`,
13156
- element: describeElement($, el)
13157
- }, styleLoc ? { loc: styleLoc } : {}), {
13158
- details: `Foreground ${colorValue} on background needs at least ${isLargeText ? "3:1" : "4.5:1"} contrast ratio.`
13159
- }));
13160
- } else if (!isLargeText && grade === "AA Large") {
13161
- issues.push(__spreadProps(__spreadValues({
13162
- severity: "warning",
13163
- rule: "low-contrast",
13164
- message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG AA for normal text`,
13165
- element: describeElement($, el)
13166
- }, styleLoc ? { loc: styleLoc } : {}), {
13167
- details: `Foreground ${colorValue} on background needs at least 4.5:1 for normal-sized text.`
13168
- }));
13169
- }
13170
- }
13171
- }
13172
13542
  });
13173
13543
  if (smallTextCount > 3) {
13174
13544
  issues.push({
@@ -13228,7 +13598,8 @@ function checkAccessibilityFromDom($) {
13228
13598
  issues.push(...checkImageAlt($));
13229
13599
  issues.push(...checkLinkAccessibility($));
13230
13600
  issues.push(...checkTableAccessibility($));
13231
- issues.push(...checkTextSizeAndContrast($));
13601
+ issues.push(...checkTextSize($));
13602
+ issues.push(...checkContrast($, computeCascade($, DESKTOP_RENDER)));
13232
13603
  issues.push(...checkSemanticStructure($));
13233
13604
  const charsetIssue = checkCharsetDeclaration($);
13234
13605
  if (charsetIssue) issues.push(charsetIssue);
@@ -13257,6 +13628,76 @@ function checkAccessibilityFromDom($) {
13257
13628
  function checkAccessibility(html, options) {
13258
13629
  return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom, options);
13259
13630
  }
13631
+ function checkDarkStylesContrastFromDom($, lightIssues) {
13632
+ const baseline = lightIssues != null ? lightIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
13633
+ const dark = checkContrast($, computeCascade($, DARK_RENDER));
13634
+ return contrastDelta(
13635
+ dark,
13636
+ baseline,
13637
+ "low-contrast-dark",
13638
+ "Dark mode",
13639
+ "This pairing comes from the email's own @media (prefers-color-scheme: dark) block. Clients that honour it (Apple Mail, Superhuman, Thunderbird) render exactly this, so re-colour the text alongside the background."
13640
+ );
13641
+ }
13642
+ var DARK_CONTRAST_CLIENTS = ["gmail-android", "gmail-ios"];
13643
+ function lowContrastOnly(issues) {
13644
+ return issues.filter((i) => i.rule === "low-contrast");
13645
+ }
13646
+ function contrastDelta(candidates, baseline, rule, prefix, details) {
13647
+ const seen = new Set(lowContrastOnly(baseline).map((i) => i.element));
13648
+ return lowContrastOnly(candidates).filter((issue) => {
13649
+ if (seen.has(issue.element)) return false;
13650
+ seen.add(issue.element);
13651
+ return true;
13652
+ }).map((issue) => __spreadProps(__spreadValues(__spreadValues({
13653
+ severity: issue.severity,
13654
+ rule,
13655
+ message: `${prefix}: ${issue.message}`
13656
+ }, issue.element ? { element: issue.element } : {}), issue.loc ? { loc: issue.loc } : {}), {
13657
+ details
13658
+ }));
13659
+ }
13660
+ function checkMobileContrastFromDom($, desktopIssues) {
13661
+ const baseline = desktopIssues != null ? desktopIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
13662
+ const mobile = checkContrast($, computeCascade($, MOBILE_RENDER));
13663
+ return contrastDelta(
13664
+ mobile,
13665
+ baseline,
13666
+ "low-contrast-mobile",
13667
+ "At mobile width",
13668
+ "This colour pairing only appears below the email's breakpoint. Check the contrast of what the media query restyles, not just the desktop palette."
13669
+ );
13670
+ }
13671
+ function checkMobileContrast(html, options) {
13672
+ return fromHtml(html, [], ($) => checkMobileContrastFromDom($), options);
13673
+ }
13674
+ function checkDarkModeContrast(html, lightIssues) {
13675
+ if (!html || !html.trim()) return [];
13676
+ const inverted = [];
13677
+ for (const client of DARK_CONTRAST_CLIENTS) {
13678
+ let darkHtml;
13679
+ try {
13680
+ darkHtml = simulateDarkMode(html, client).html;
13681
+ } catch (e) {
13682
+ continue;
13683
+ }
13684
+ const $dark = cheerio5.load(darkHtml);
13685
+ for (const issue of checkContrast($dark, computeCascade($dark, DESKTOP_RENDER))) {
13686
+ const _a = issue, { loc, locs, locsTruncated } = _a, rest = __objRest(_a, ["loc", "locs", "locsTruncated"]);
13687
+ void loc;
13688
+ void locs;
13689
+ void locsTruncated;
13690
+ inverted.push(rest);
13691
+ }
13692
+ }
13693
+ return contrastDelta(
13694
+ inverted,
13695
+ lightIssues != null ? lightIssues : checkAccessibility(html).issues,
13696
+ "low-contrast-dark",
13697
+ "Dark mode",
13698
+ "Clients that force dark mode repaint backgrounds without re-colouring every text layer. Set explicit colours inside @media (prefers-color-scheme: dark), or pick text that holds up on both."
13699
+ );
13700
+ }
13260
13701
 
13261
13702
  // src/image-analyzer.ts
13262
13703
  var DATA_URI_WARN_BYTES = 100 * 1024;
@@ -13330,7 +13771,7 @@ function analyzeImagesFromDom($) {
13330
13771
  issues.push(__spreadValues({
13331
13772
  rule: "missing-dimensions",
13332
13773
  severity: "warning",
13333
- message: "Image missing width/height attributes \u2014 causes layout shifts and Outlook rendering issues.",
13774
+ message: "Image missing width/height attributes, causes layout shifts and Outlook rendering issues.",
13334
13775
  src: truncateSrc(src)
13335
13776
  }, elLoc ? { loc: elLoc } : {}));
13336
13777
  }
@@ -13341,7 +13782,7 @@ function analyzeImagesFromDom($) {
13341
13782
  issues.push(__spreadValues({
13342
13783
  rule: "large-data-uri",
13343
13784
  severity: "warning",
13344
- message: `Data URI is ${kb}KB \u2014 consider hosting the image externally to reduce email size.`,
13785
+ message: `Data URI is ${kb}KB; consider hosting the image externally to reduce email size.`,
13345
13786
  src: truncateSrc(src)
13346
13787
  }, srcLoc ? { loc: srcLoc } : {}));
13347
13788
  }
@@ -13350,7 +13791,7 @@ function analyzeImagesFromDom($) {
13350
13791
  issues.push(__spreadValues({
13351
13792
  rule: "missing-alt",
13352
13793
  severity: "warning",
13353
- message: "Image missing alt attribute \u2014 hurts deliverability and accessibility.",
13794
+ message: "Image missing alt attribute, hurts deliverability and accessibility.",
13354
13795
  src: truncateSrc(src)
13355
13796
  }, elLoc ? { loc: elLoc } : {}));
13356
13797
  }
@@ -13359,7 +13800,7 @@ function analyzeImagesFromDom($) {
13359
13800
  issues.push(__spreadValues({
13360
13801
  rule: "webp-format",
13361
13802
  severity: "info",
13362
- message: "WebP format detected \u2014 not supported by all email clients. Consider PNG or JPEG.",
13803
+ message: "WebP format detected, not supported by all email clients. Consider PNG or JPEG.",
13363
13804
  src: truncateSrc(src)
13364
13805
  }, srcLoc ? { loc: srcLoc } : {}));
13365
13806
  }
@@ -13368,7 +13809,7 @@ function analyzeImagesFromDom($) {
13368
13809
  issues.push(__spreadValues({
13369
13810
  rule: "svg-format",
13370
13811
  severity: "info",
13371
- message: "SVG format detected \u2014 not supported by most email clients. Use PNG instead.",
13812
+ message: "SVG format detected, not supported by most email clients. Use PNG instead.",
13372
13813
  src: truncateSrc(src)
13373
13814
  }, srcLoc ? { loc: srcLoc } : {}));
13374
13815
  }
@@ -13377,7 +13818,7 @@ function analyzeImagesFromDom($) {
13377
13818
  issues.push(__spreadValues({
13378
13819
  rule: "missing-display-block",
13379
13820
  severity: "info",
13380
- message: "Image without display:block \u2014 may cause unwanted gaps in Outlook.",
13821
+ message: "Image without display:block, may cause unwanted gaps in Outlook.",
13381
13822
  src: truncateSrc(src)
13382
13823
  }, elLoc ? { loc: elLoc } : {}));
13383
13824
  }
@@ -13396,7 +13837,7 @@ function analyzeImagesFromDom($) {
13396
13837
  issues.push({
13397
13838
  rule: "high-image-count",
13398
13839
  severity: "info",
13399
- message: `Email contains ${nonTrackingImages.length} images \u2014 heavy emails may be clipped or load slowly.`
13840
+ message: `Email contains ${nonTrackingImages.length} images; heavy emails may be clipped or load slowly.`
13400
13841
  });
13401
13842
  }
13402
13843
  const trackingPixels = images.filter((i) => i.isTrackingPixel);
@@ -13412,7 +13853,7 @@ function analyzeImagesFromDom($) {
13412
13853
  issues.push({
13413
13854
  rule: "total-data-uri-size",
13414
13855
  severity: "warning",
13415
- message: `Total data URI size is ${kb}KB \u2014 consider hosting images externally to reduce email size.`
13856
+ message: `Total data URI size is ${kb}KB; consider hosting images externally to reduce email size.`
13416
13857
  });
13417
13858
  }
13418
13859
  return { total: images.length, totalDataUriBytes, issues, images };
@@ -13453,7 +13894,7 @@ function extractInboxPreviewFromDom($) {
13453
13894
  issues.push({
13454
13895
  rule: "subject-too-long",
13455
13896
  severity: "warning",
13456
- message: `Subject is ${subjectLength} characters \u2014 may be truncated in inboxes (recommended: ${MAX_SUBJECT_LENGTH} or fewer).`
13897
+ message: `Subject is ${subjectLength} characters, and may be truncated in inboxes (recommended: ${MAX_SUBJECT_LENGTH} or fewer).`
13457
13898
  });
13458
13899
  }
13459
13900
  const preheader = extractPreheaderText($);
@@ -13468,27 +13909,27 @@ function extractInboxPreviewFromDom($) {
13468
13909
  issues.push({
13469
13910
  rule: "preheader-too-short",
13470
13911
  severity: "warning",
13471
- message: `Preheader is only ${preheaderLength} characters \u2014 email clients may backfill with body text (recommended: ${MIN_PREHEADER_LENGTH}+).`
13912
+ message: `Preheader is only ${preheaderLength} characters, so email clients may backfill with body text (recommended: ${MIN_PREHEADER_LENGTH}+).`
13472
13913
  });
13473
13914
  } else if (preheaderLength > MAX_PREHEADER_LENGTH) {
13474
13915
  issues.push({
13475
13916
  rule: "preheader-too-long",
13476
13917
  severity: "info",
13477
- message: `Preheader is ${preheaderLength} characters \u2014 most clients show 40\u2013100 characters. Text beyond that is hidden.`
13918
+ message: `Preheader is ${preheaderLength} characters; most clients show 40\u2013100 characters. Text beyond that is hidden.`
13478
13919
  });
13479
13920
  }
13480
13921
  if (hasZwnjPadding($)) {
13481
13922
  issues.push({
13482
13923
  rule: "zwnj-padding",
13483
13924
  severity: "info",
13484
- message: "Preheader uses &zwnj;&nbsp; padding hack \u2014 works in most clients but may show garbled text in some."
13925
+ message: "Preheader uses &zwnj;&nbsp; padding hack, works in most clients but may show garbled text in some."
13485
13926
  });
13486
13927
  }
13487
13928
  if (subject && EMOJI_PATTERN.test(subject)) {
13488
13929
  issues.push({
13489
13930
  rule: "emoji-in-subject",
13490
13931
  severity: "info",
13491
- message: "Subject line contains emoji \u2014 renders inconsistently across email clients and may trigger spam filters."
13932
+ message: "Subject line contains emoji, renders inconsistently across email clients and may trigger spam filters."
13492
13933
  });
13493
13934
  }
13494
13935
  const truncation = computeTruncation(subject, preheader);
@@ -13619,14 +14060,14 @@ function checkSizeFromDom(_$, html) {
13619
14060
  issues.push({
13620
14061
  rule: "gmail-clipped",
13621
14062
  severity: "error",
13622
- message: `Email is ${humanSize} \u2014 Gmail will clip it at ~102 KB. Recipients see a "View entire message" link instead of your content.`,
14063
+ message: `Email is ${humanSize}; Gmail will clip it at ~102 KB. Recipients see a "View entire message" link instead of your content.`,
13623
14064
  detail: `${htmlBytes} bytes exceeds the ${GMAIL_CLIP_THRESHOLD} byte threshold.`
13624
14065
  });
13625
14066
  } else if (htmlBytes > GMAIL_CLIP_WARNING_THRESHOLD) {
13626
14067
  issues.push({
13627
14068
  rule: "gmail-clip-warning",
13628
14069
  severity: "warning",
13629
- message: `Email is ${humanSize} \u2014 approaching Gmail's ~102 KB clip threshold. Consider trimming.`,
14070
+ message: `Email is ${humanSize}, approaching Gmail's ~102 KB clip threshold. Consider trimming.`,
13630
14071
  detail: `${htmlBytes} bytes is within ${GMAIL_CLIP_THRESHOLD - htmlBytes} bytes of the clip threshold.`
13631
14072
  });
13632
14073
  }
@@ -13708,22 +14149,22 @@ function checkTemplateVariablesFromDom($, source) {
13708
14149
  for (const sel of attrSelectors) {
13709
14150
  $(sel).each((_, el) => {
13710
14151
  const attrs = ["href", "src", "alt"];
13711
- for (const attr of attrs) {
13712
- const value = $(el).attr(attr);
14152
+ for (const attr2 of attrs) {
14153
+ const value = $(el).attr(attr2);
13713
14154
  if (!value) continue;
13714
14155
  for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
13715
14156
  pattern.lastIndex = 0;
13716
14157
  let match;
13717
14158
  while ((match = pattern.exec(value)) !== null) {
13718
14159
  const variable = match[0];
13719
- const key = `attr:${attr}:${variable}`;
14160
+ const key = `attr:${attr2}:${variable}`;
13720
14161
  if (seen.has(key)) continue;
13721
14162
  seen.add(key);
13722
- const loc = locOfAttr(el, attr);
14163
+ const loc = locOfAttr(el, attr2);
13723
14164
  issues.push(__spreadValues({
13724
14165
  rule: "unresolved-variable",
13725
14166
  severity: "error",
13726
- message: `Unresolved ${label} variable "${variable}" found in ${attr} attribute.`,
14167
+ message: `Unresolved ${label} variable "${variable}" found in ${attr2} attribute.`,
13727
14168
  variable,
13728
14169
  location: "attribute"
13729
14170
  }, loc ? { loc } : {}));
@@ -13744,13 +14185,13 @@ function checkTemplateVariables(html, options) {
13744
14185
  }
13745
14186
 
13746
14187
  // src/overflow-checker.ts
13747
- import * as csstree6 from "css-tree";
14188
+ import * as csstree7 from "css-tree";
13748
14189
  function fixedPxWidth($el) {
13749
14190
  const style = $el.attr("style") || "";
13750
14191
  const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
13751
14192
  if (styleMatch) return parseInt(styleMatch[1], 10);
13752
- const attr = $el.attr("width");
13753
- if (attr && /^\d+$/.test(attr.trim())) return parseInt(attr.trim(), 10);
14193
+ const attr2 = $el.attr("width");
14194
+ if (attr2 && /^\d+$/.test(attr2.trim())) return parseInt(attr2.trim(), 10);
13754
14195
  return null;
13755
14196
  }
13756
14197
  function isFluid(style) {
@@ -13766,7 +14207,7 @@ function addWidthIssue(width, label, issues, seen, loc) {
13766
14207
  const issue = __spreadValues({
13767
14208
  rule: "fixed-width-overflow",
13768
14209
  severity: "warning",
13769
- message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame \u2014 it will force horizontal scrolling, especially on mobile.`,
14210
+ message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame; it will force horizontal scrolling, especially on mobile.`,
13770
14211
  detail: `Use width:100% with max-width:${EMAIL_MAX_WIDTH}px instead of a fixed width beyond the frame.`
13771
14212
  }, loc ? { loc, locs: [loc] } : {});
13772
14213
  seen.set(key, issue);
@@ -13820,11 +14261,11 @@ function checkOverflowFromDom($, source) {
13820
14261
  const anchor = cssBlockAnchor(el, cssText, source);
13821
14262
  let ast;
13822
14263
  try {
13823
- ast = csstree6.parse(cssText, { positions: true });
14264
+ ast = csstree7.parse(cssText, { positions: true });
13824
14265
  } catch (e) {
13825
14266
  return;
13826
14267
  }
13827
- csstree6.walk(ast, {
14268
+ csstree7.walk(ast, {
13828
14269
  visit: "Rule",
13829
14270
  enter(node) {
13830
14271
  if (node.type !== "Rule") return;
@@ -13834,7 +14275,7 @@ function checkOverflowFromDom($, source) {
13834
14275
  node.block.children.forEach((child) => {
13835
14276
  if (child.type !== "Declaration") return;
13836
14277
  const prop = child.property.toLowerCase();
13837
- const val = csstree6.generate(child.value);
14278
+ const val = csstree7.generate(child.value);
13838
14279
  if (prop === "width") {
13839
14280
  const m = val.match(/^(\d+)px$/);
13840
14281
  if (m) {
@@ -13847,7 +14288,7 @@ function checkOverflowFromDom($, source) {
13847
14288
  }
13848
14289
  });
13849
14290
  if (widthPx !== null && widthPx > EMAIL_MAX_WIDTH && !fluid) {
13850
- const selector = csstree6.generate(node.prelude).trim().slice(0, 40);
14291
+ const selector = csstree7.generate(node.prelude).trim().slice(0, 40);
13851
14292
  addWidthIssue(widthPx, selector || "rule", issues, seen, widthLoc);
13852
14293
  }
13853
14294
  }
@@ -13890,8 +14331,195 @@ function checkOverflow(html, options) {
13890
14331
  );
13891
14332
  }
13892
14333
 
14334
+ // src/vml-checker.ts
14335
+ var SHAPE_TAGS = /* @__PURE__ */ new Set([
14336
+ "rect",
14337
+ "roundrect",
14338
+ "oval",
14339
+ "line",
14340
+ "polyline",
14341
+ "curve",
14342
+ "arc",
14343
+ "shape",
14344
+ "image",
14345
+ "background"
14346
+ ]);
14347
+ var GROUP_TAGS = /* @__PURE__ */ new Set(["group"]);
14348
+ function msoBlocks(html) {
14349
+ const blocks = [];
14350
+ const re = /<!--\[if([^\]]*)\]>([\s\S]*?)<!\[endif\]-->/g;
14351
+ let m;
14352
+ while ((m = re.exec(html)) !== null) {
14353
+ const condition = m[1];
14354
+ if (/!\s*(mso|vml)/i.test(condition)) continue;
14355
+ if (!/mso|vml/i.test(condition)) continue;
14356
+ blocks.push({ inner: m[2], offset: m.index + m[0].length - m[2].length - "<![endif]-->".length });
14357
+ }
14358
+ return blocks;
14359
+ }
14360
+ function vmlTags(blocks) {
14361
+ const tags = [];
14362
+ const re = /<(\/?)v:([a-z]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
14363
+ for (const block of blocks) {
14364
+ let m;
14365
+ re.lastIndex = 0;
14366
+ while ((m = re.exec(block.inner)) !== null) {
14367
+ tags.push({
14368
+ name: m[2].toLowerCase(),
14369
+ closing: m[1] === "/",
14370
+ selfClosing: m[4] === "/",
14371
+ attrs: m[3],
14372
+ offset: block.offset + m.index,
14373
+ length: m[0].length
14374
+ });
14375
+ }
14376
+ }
14377
+ return tags;
14378
+ }
14379
+ function locAt(source, offset, length) {
14380
+ if (source === void 0) return void 0;
14381
+ const start = positionOf(source, offset);
14382
+ const end = positionOf(source, offset + length);
14383
+ return {
14384
+ line: start.line,
14385
+ column: start.column,
14386
+ endLine: end.line,
14387
+ endColumn: end.column,
14388
+ offset,
14389
+ length
14390
+ };
14391
+ }
14392
+ function attr(attrs, name) {
14393
+ var _a;
14394
+ const m = attrs.match(
14395
+ new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"|(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, "i")
14396
+ );
14397
+ return m ? (_a = m[1]) != null ? _a : m[2] : void 0;
14398
+ }
14399
+ function add(issues, seen, key, issue) {
14400
+ const existing = seen.get(key);
14401
+ if (existing) {
14402
+ if (!issue.loc || !existing.locs) return;
14403
+ if (existing.locs.some((l) => l.offset === issue.loc.offset)) return;
14404
+ if (existing.locs.length >= MAX_WARNING_LOCATIONS) {
14405
+ existing.locsTruncated = true;
14406
+ return;
14407
+ }
14408
+ existing.locs.push(issue.loc);
14409
+ return;
14410
+ }
14411
+ seen.set(key, issue);
14412
+ issues.push(issue);
14413
+ }
14414
+ function withLoc(issue, loc) {
14415
+ return loc ? __spreadProps(__spreadValues({}, issue), { loc, locs: [loc] }) : issue;
14416
+ }
14417
+ var TEXT_HOSTS = /<\s*(center|v:textbox|div|p|table|h[1-6]|span|font|a)\b/i;
14418
+ function checkLooseText(issues, seen, open, inner, source) {
14419
+ if (TEXT_HOSTS.test(inner)) return;
14420
+ const text = inner.replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]*>/g, "").replace(/&nbsp;|\s/g, "");
14421
+ if (text === "") return;
14422
+ add(issues, seen, `loosetext:${open.name}`, withLoc({
14423
+ rule: "vml-unrendered-text",
14424
+ severity: "error",
14425
+ message: `<v:${open.name}> has label text with no element around it, which Outlook does not draw.`,
14426
+ detail: `Verified in Outlook Classic: the shape renders its fill and shape correctly and shows no text at all, so the reader gets a blank coloured block where the label should be. Nothing errors and the HTML fallback still reads correctly in every other client, so this ships unnoticed. Wrap the text in <center> (what the bulletproof-button pattern uses) or in <v:textbox>.`
14427
+ }, locAt(source, open.offset, open.length)));
14428
+ }
14429
+ function checkVml(html, options) {
14430
+ if (!html || !html.trim()) return EMPTY_VML;
14431
+ const source = (options == null ? void 0 : options.positions) ? html : void 0;
14432
+ const tags = vmlTags(msoBlocks(html));
14433
+ if (tags.length === 0) return EMPTY_VML;
14434
+ const issues = [];
14435
+ const seen = /* @__PURE__ */ new Map();
14436
+ const openShapes = [];
14437
+ let groupDepth = 0;
14438
+ for (const tag of tags) {
14439
+ const { name } = tag;
14440
+ const isShape = SHAPE_TAGS.has(name);
14441
+ const isGroup = GROUP_TAGS.has(name);
14442
+ const loc = locAt(source, tag.offset, tag.length);
14443
+ if (tag.closing) {
14444
+ if (isGroup) groupDepth = Math.max(0, groupDepth - 1);
14445
+ if (isShape) {
14446
+ const idx = openShapes.map((t) => t.name).lastIndexOf(name);
14447
+ if (idx !== -1) {
14448
+ const open = openShapes[idx];
14449
+ const inner = html.slice(open.offset + open.length, tag.offset);
14450
+ checkLooseText(issues, seen, open, inner, source);
14451
+ }
14452
+ if (idx === -1) {
14453
+ add(issues, seen, `stray:${name}`, withLoc({
14454
+ rule: "vml-unbalanced-tag",
14455
+ severity: "error",
14456
+ message: `Closing </v:${name}> has no matching opening tag.`,
14457
+ detail: `Outlook stops rendering the shape when VML tags do not balance. Check that every conditional-comment block opens and closes the tags it is responsible for.`
14458
+ }, loc));
14459
+ } else {
14460
+ openShapes.splice(idx, 1);
14461
+ }
14462
+ }
14463
+ continue;
14464
+ }
14465
+ if (isGroup && !tag.selfClosing) groupDepth++;
14466
+ if (isShape) {
14467
+ const parent = openShapes[openShapes.length - 1];
14468
+ if (parent && groupDepth === 0) {
14469
+ add(issues, seen, `nested:${parent.name}>${name}`, withLoc({
14470
+ rule: "vml-nested-shape",
14471
+ severity: "error",
14472
+ message: `<v:${name}> is nested inside <v:${parent.name}>. Outlook does not support nesting one VML shape inside another.`,
14473
+ detail: `Verified in Outlook Classic, and the damage is not local. Three things happen: the containing <v:${parent.name}> does not render at all (its fill and everything inside it disappear, leaving the inner shape stranded); the table structure around it terminates early, so content *after* the shape falls out of the layout and renders outside the email frame; and every VML shape further down the email stops drawing its text, so buttons and headings after this point ship as blank coloured blocks. Confirmed with byte-identical probes either side of one nested shape: the one before it renders its labels, the one after it does not. Every other client renders the HTML fallback correctly, which is why none of this is visible outside the Word engine. Fix: lift the inner shape out of <v:${parent.name}>, or drop the container shape and keep the inner one (a framed background can degrade to a solid fill colour on the <td> instead).`
14474
+ }, loc));
14475
+ }
14476
+ if (!tag.selfClosing) openShapes.push(tag);
14477
+ }
14478
+ const style = attr(tag.attrs, "style");
14479
+ if (style && (isShape || isGroup)) {
14480
+ for (const prop of ["width", "height"]) {
14481
+ const m = style.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]*)`, "i"));
14482
+ if (!m) continue;
14483
+ const value = m[1].trim();
14484
+ if (value === "" || /^[a-z%]+$/i.test(value)) {
14485
+ add(issues, seen, `dim:${name}:${prop}:${value}`, withLoc({
14486
+ rule: "vml-invalid-dimension",
14487
+ severity: "error",
14488
+ message: `<v:${name}> has an invalid ${prop}: "${prop}:${value}" is missing its number.`,
14489
+ detail: `Verified in Outlook Classic: the shape still draws, but at a size Outlook picks rather than the one you meant, and the content inside is clipped to it \u2014 roughly half the intended height in the case measured, with the text inside cut off. It fails quietly: there is no gap or broken image to notice, just content that is silently missing. Usually a template variable that resolved to an empty string. Set an explicit value in pixels, e.g. ${prop}:400px.`
14490
+ }, loc));
14491
+ }
14492
+ }
14493
+ }
14494
+ if (name === "roundrect") {
14495
+ const arcsize = attr(tag.attrs, "arcsize");
14496
+ if (arcsize !== void 0) {
14497
+ const raw = arcsize.trim();
14498
+ const pct = raw.endsWith("%") ? parseFloat(raw) : parseFloat(raw) * 100;
14499
+ if (Number.isFinite(pct) && (pct < 0 || pct > 100)) {
14500
+ add(issues, seen, `arcsize:${raw}`, withLoc({
14501
+ rule: "vml-arcsize-range",
14502
+ severity: "warning",
14503
+ message: `<v:roundrect> has arcsize="${raw}", outside the valid 0%\u2013100% range.`,
14504
+ detail: `arcsize is a fraction of half the shape's smaller side: 0% is square, 100% is fully circular. Verified in Outlook Classic: out-of-range values are clamped, so 120% draws exactly the same corner as 100%. Nothing visibly breaks today, which is why this is a warning and not an error \u2014 but the radius you get is the renderer's clamp rather than a value you chose, and it is not guaranteed across clients. For a fully rounded button, say arcsize="100%".`
14505
+ }, loc));
14506
+ }
14507
+ }
14508
+ }
14509
+ }
14510
+ for (const tag of openShapes) {
14511
+ add(issues, seen, `unclosed:${tag.name}`, withLoc({
14512
+ rule: "vml-unbalanced-tag",
14513
+ severity: "error",
14514
+ message: `<v:${tag.name}> is never closed.`,
14515
+ detail: `Outlook needs the matching </v:${tag.name}>, usually in a later conditional-comment block. Without it the shape swallows the rest of the email.`
14516
+ }, locAt(source, tag.offset, tag.length)));
14517
+ }
14518
+ return { hasVml: true, issues };
14519
+ }
14520
+
13893
14521
  // src/visual-checker.ts
13894
- import * as csstree7 from "css-tree";
14522
+ import * as csstree8 from "css-tree";
13895
14523
  var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
13896
14524
  var GRADIENT_RE = /(?:linear|radial|conic)-gradient\(/i;
13897
14525
  function isSolidColor(value) {
@@ -13964,7 +14592,7 @@ function inspectDeclarations(style, issues, seen, locs) {
13964
14592
  const issue = __spreadValues({
13965
14593
  rule: "missing-background-fallback",
13966
14594
  severity: "warning",
13967
- message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback \u2014 it renders as a blank area (and can hide overlaid text) in Outlook and other clients that drop image backgrounds.`,
14595
+ message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback; it renders as a blank area (and can hide overlaid text) in Outlook and other clients that drop image backgrounds.`,
13968
14596
  detail: isGradient ? `Add a solid fallback beneath the gradient using its first colour stop.` : `Add a solid background-color so the area still has colour when the image is dropped.`,
13969
14597
  fix
13970
14598
  }, loc ? { loc, locs: [loc] } : {});
@@ -13984,7 +14612,7 @@ function inspectDeclarations(style, issues, seen, locs) {
13984
14612
  const issue = __spreadValues({
13985
14613
  rule: "missing-font-fallback",
13986
14614
  severity: "warning",
13987
- message: `font-family "${font.trim()}" has no web-safe fallback \u2014 clients that strip web fonts (Gmail, Outlook) fall back to Times New Roman.`,
14615
+ message: `font-family "${font.trim()}" has no web-safe fallback; clients that strip web fonts (Gmail, Outlook) fall back to Times New Roman.`,
13988
14616
  detail: `End the stack with a web-safe font and a generic family.`,
13989
14617
  fix
13990
14618
  }, loc ? { loc, locs: [loc] } : {});
@@ -13997,7 +14625,7 @@ function ruleToMap(node) {
13997
14625
  const map = /* @__PURE__ */ new Map();
13998
14626
  node.block.children.forEach((child) => {
13999
14627
  if (child.type === "Declaration") {
14000
- map.set(child.property.toLowerCase(), csstree7.generate(child.value));
14628
+ map.set(child.property.toLowerCase(), csstree8.generate(child.value));
14001
14629
  }
14002
14630
  });
14003
14631
  return map;
@@ -14016,11 +14644,11 @@ function checkVisualFromDom($, source) {
14016
14644
  const anchor = cssBlockAnchor(el, cssText, source);
14017
14645
  let ast;
14018
14646
  try {
14019
- ast = csstree7.parse(cssText, { positions: true });
14647
+ ast = csstree8.parse(cssText, { positions: true });
14020
14648
  } catch (e) {
14021
14649
  return;
14022
14650
  }
14023
- csstree7.walk(ast, {
14651
+ csstree8.walk(ast, {
14024
14652
  visit: "Rule",
14025
14653
  enter(node) {
14026
14654
  if (node.type !== "Rule") return;
@@ -14045,6 +14673,169 @@ function checkVisual(html, options) {
14045
14673
  );
14046
14674
  }
14047
14675
 
14676
+ // src/design-consistency.ts
14677
+ import * as csstree9 from "css-tree";
14678
+ var SAME_COLOUR_DISTANCE = 0.02;
14679
+ var LIMITS = {
14680
+ fontSize: 8,
14681
+ fontFamily: 2,
14682
+ radius: 3
14683
+ };
14684
+ var NON_COLOURS = /* @__PURE__ */ new Set(["transparent", "inherit", "initial", "unset", "currentcolor", "none"]);
14685
+ var TRACKED = {
14686
+ "color": "colors",
14687
+ "background-color": "backgrounds",
14688
+ "background": "backgrounds",
14689
+ "font-size": "fontSizes",
14690
+ "font-family": "fontFamilies",
14691
+ "border-radius": "radii"
14692
+ };
14693
+ function primaryTypeface(value) {
14694
+ var _a;
14695
+ return ((_a = value.split(",")[0]) != null ? _a : "").replace(/['"]/g, "").trim().toLowerCase();
14696
+ }
14697
+ function radiusLengths(value) {
14698
+ const lengths = value.trim().toLowerCase().split(/[\s/]+/).filter(Boolean);
14699
+ return lengths.filter((v) => {
14700
+ if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
14701
+ if (v === "50%") return false;
14702
+ const px = parseFloat(v);
14703
+ return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
14704
+ });
14705
+ }
14706
+ function collect($) {
14707
+ const found = {
14708
+ colors: [],
14709
+ backgrounds: [],
14710
+ fontSizes: [],
14711
+ fontFamilies: [],
14712
+ radii: []
14713
+ };
14714
+ const record = (prop, rawValue) => {
14715
+ const bucket = TRACKED[prop.toLowerCase()];
14716
+ if (!bucket) return;
14717
+ const value = rawValue.trim();
14718
+ if (!value) return;
14719
+ found[bucket].push(value);
14720
+ };
14721
+ $("[style]").each((_, el) => {
14722
+ for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
14723
+ const colon = part.indexOf(":");
14724
+ if (colon === -1) continue;
14725
+ record(part.slice(0, colon), part.slice(colon + 1));
14726
+ }
14727
+ });
14728
+ $("style").each((_, el) => {
14729
+ let ast;
14730
+ try {
14731
+ ast = csstree9.parse($(el).text());
14732
+ } catch (e) {
14733
+ return;
14734
+ }
14735
+ csstree9.walk(ast, {
14736
+ visit: "Declaration",
14737
+ enter(node) {
14738
+ if (node.type !== "Declaration") return;
14739
+ record(node.property, csstree9.generate(node.value));
14740
+ }
14741
+ });
14742
+ });
14743
+ return found;
14744
+ }
14745
+ function driftClusters(values) {
14746
+ const byNormalised = /* @__PURE__ */ new Map();
14747
+ for (const raw of values) {
14748
+ const value = raw.trim();
14749
+ if (NON_COLOURS.has(value.toLowerCase())) continue;
14750
+ const rgb = parseColor(value);
14751
+ if (!rgb || rgb.a === 0) continue;
14752
+ const key = formatRgb(__spreadProps(__spreadValues({}, rgb), { a: 1 }));
14753
+ const entry = byNormalised.get(key);
14754
+ if (entry) entry.spellings.add(value);
14755
+ else byNormalised.set(key, { spellings: /* @__PURE__ */ new Set([value]), rgb });
14756
+ }
14757
+ const distinct = [...byNormalised.entries()];
14758
+ const clusters = [];
14759
+ const claimed = /* @__PURE__ */ new Set();
14760
+ for (let i = 0; i < distinct.length; i++) {
14761
+ const [keyA, a] = distinct[i];
14762
+ if (claimed.has(keyA)) continue;
14763
+ const cluster = [keyA];
14764
+ for (let j = i + 1; j < distinct.length; j++) {
14765
+ const [keyB, b] = distinct[j];
14766
+ if (claimed.has(keyB)) continue;
14767
+ if (!a.rgb || !b.rgb) continue;
14768
+ if (colorDistance(a.rgb, b.rgb) < SAME_COLOUR_DISTANCE) {
14769
+ cluster.push(keyB);
14770
+ claimed.add(keyB);
14771
+ }
14772
+ }
14773
+ claimed.add(keyA);
14774
+ if (cluster.length > 1) clusters.push(cluster);
14775
+ }
14776
+ return clusters;
14777
+ }
14778
+ function distinctValues(values, normalise) {
14779
+ const seen = /* @__PURE__ */ new Map();
14780
+ for (const raw of values) {
14781
+ const key = normalise(raw);
14782
+ if (!key) continue;
14783
+ if (!seen.has(key)) seen.set(key, raw.trim());
14784
+ }
14785
+ return [...seen.values()];
14786
+ }
14787
+ function checkDesignConsistencyFromDom($) {
14788
+ const found = collect($);
14789
+ const issues = [];
14790
+ const colors = distinctValues(found.colors, (v) => {
14791
+ const c = parseColor(v.trim());
14792
+ return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
14793
+ });
14794
+ const backgrounds = distinctValues(found.backgrounds, (v) => {
14795
+ const c = parseColor(v.trim());
14796
+ return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
14797
+ });
14798
+ const fontSizes = distinctValues(found.fontSizes, (v) => v.trim().toLowerCase());
14799
+ const fontFamilies = distinctValues(found.fontFamilies, primaryTypeface);
14800
+ const radii = distinctValues(found.radii.flatMap(radiusLengths), (v) => v);
14801
+ for (const cluster of driftClusters([...found.colors, ...found.backgrounds])) {
14802
+ issues.push({
14803
+ rule: "colour-drift",
14804
+ severity: "info",
14805
+ message: `${cluster.length} near-identical colours are used where one was probably meant: ${cluster.join(", ")}.`,
14806
+ detail: "These render as the same colour to a reader, so the difference is drift rather than a choice. Pick one and use it everywhere.",
14807
+ values: cluster
14808
+ });
14809
+ }
14810
+ const overflowing = [
14811
+ ["font sizes", fontSizes, LIMITS.fontSize, "Settle on a type scale and reuse it; every extra size is one more thing to keep in step."],
14812
+ ["typefaces", fontFamilies, LIMITS.fontFamily, "Email clients strip most web fonts anyway, so each extra typeface mostly adds fallback risk."],
14813
+ ["corner radii", radii, LIMITS.radius, "Corner rounding reads as a single decision; several close values look like an accident."]
14814
+ ];
14815
+ for (const [label, values, limit, detail] of overflowing) {
14816
+ if (values.length <= limit) continue;
14817
+ issues.push({
14818
+ rule: "too-many-values",
14819
+ severity: "info",
14820
+ message: `${values.length} distinct ${label} in one email: ${values.slice(0, 10).join(", ")}${values.length > 10 ? ", \u2026" : ""}.`,
14821
+ detail,
14822
+ values
14823
+ });
14824
+ }
14825
+ return {
14826
+ issues,
14827
+ palette: { colors, backgrounds, fontSizes, fontFamilies, radii }
14828
+ };
14829
+ }
14830
+ function checkDesignConsistency(html, options) {
14831
+ return fromHtml(
14832
+ html,
14833
+ { issues: [], palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] } },
14834
+ checkDesignConsistencyFromDom,
14835
+ options
14836
+ );
14837
+ }
14838
+
14048
14839
  // src/audit.ts
14049
14840
  var EMPTY_AUDIT = {
14050
14841
  compatibility: { warnings: [], scores: {} },
@@ -14056,8 +14847,20 @@ var EMPTY_AUDIT = {
14056
14847
  size: EMPTY_SIZE,
14057
14848
  templateVariables: EMPTY_TEMPLATE,
14058
14849
  overflow: EMPTY_OVERFLOW,
14059
- visual: EMPTY_VISUAL
14850
+ visual: EMPTY_VISUAL,
14851
+ vml: EMPTY_VML,
14852
+ darkContrast: [],
14853
+ mobileContrast: [],
14854
+ design: EMPTY_DESIGN
14060
14855
  };
14856
+ function dedupeByElement(issues) {
14857
+ const seen = /* @__PURE__ */ new Set();
14858
+ return issues.filter((issue) => {
14859
+ if (seen.has(issue.element)) return false;
14860
+ seen.add(issue.element);
14861
+ return true;
14862
+ });
14863
+ }
14061
14864
  function runAudit($, html, framework, options) {
14062
14865
  var _a;
14063
14866
  const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
@@ -14073,16 +14876,23 @@ function runAudit($, html, framework, options) {
14073
14876
  const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
14074
14877
  const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
14075
14878
  const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
14076
- return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual };
14879
+ const vml = skip.has("vml") ? EMPTY_VML : checkVml(html, { positions: !!source });
14880
+ const darkContrast = skip.has("darkContrast") || skip.has("accessibility") ? [] : dedupeByElement([
14881
+ ...checkDarkModeContrast(html, accessibility.issues),
14882
+ ...checkDarkStylesContrastFromDom($, accessibility.issues)
14883
+ ]);
14884
+ const design = skip.has("design") ? EMPTY_DESIGN : checkDesignConsistencyFromDom($);
14885
+ const mobileContrast = skip.has("mobileContrast") || skip.has("accessibility") ? [] : checkMobileContrastFromDom($, accessibility.issues);
14886
+ return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual, vml, darkContrast, mobileContrast, design };
14077
14887
  }
14078
14888
  function auditEmail(html, options) {
14079
14889
  return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
14080
14890
  }
14081
14891
 
14082
14892
  // src/plain-text.ts
14083
- import * as cheerio5 from "cheerio";
14893
+ import * as cheerio6 from "cheerio";
14084
14894
  function toPlainText(html) {
14085
- const $ = cheerio5.load(html);
14895
+ const $ = cheerio6.load(html);
14086
14896
  $("style, script, head").remove();
14087
14897
  $("[data-skip-in-text='true']").remove();
14088
14898
  const lines = [];
@@ -14117,7 +14927,7 @@ function toPlainText(html) {
14117
14927
  if (trimmed) lines.push(trimmed);
14118
14928
  currentLine = "";
14119
14929
  }
14120
- function walk8(node) {
14930
+ function walk9(node) {
14121
14931
  var _a;
14122
14932
  if (node.type === "text") {
14123
14933
  const text = node.data.replace(/\s+/g, " ");
@@ -14166,7 +14976,7 @@ function toPlainText(html) {
14166
14976
  currentLine = "- ";
14167
14977
  }
14168
14978
  for (const child of el.children) {
14169
- walk8(child);
14979
+ walk9(child);
14170
14980
  }
14171
14981
  if (isBlock) flushLine();
14172
14982
  }
@@ -14174,7 +14984,7 @@ function toPlainText(html) {
14174
14984
  const root = body.length ? body[0] : $.root()[0];
14175
14985
  if (root && "children" in root) {
14176
14986
  for (const child of root.children) {
14177
- walk8(child);
14987
+ walk9(child);
14178
14988
  }
14179
14989
  }
14180
14990
  flushLine();
@@ -14288,10 +15098,16 @@ export {
14288
15098
  auditEmail,
14289
15099
  caveatApplies,
14290
15100
  checkAccessibility,
15101
+ checkDarkModeContrast,
15102
+ checkDarkStylesContrastFromDom,
15103
+ checkDesignConsistency,
15104
+ checkMobileContrast,
14291
15105
  checkOverflow,
14292
15106
  checkSize,
14293
15107
  checkTemplateVariables,
14294
15108
  checkVisual,
15109
+ checkVml,
15110
+ colorDistance,
14295
15111
  contrastRatio,
14296
15112
  createSession,
14297
15113
  diffResults,
@@ -14310,6 +15126,7 @@ export {
14310
15126
  heuristicTokenCount,
14311
15127
  parseColor,
14312
15128
  relativeLuminance,
15129
+ rgbToOklab,
14313
15130
  simulateDarkMode,
14314
15131
  structuralWarnings,
14315
15132
  toPlainText,