@samva/markup 0.13.0 → 0.14.0

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/compiler.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { n as isObject } from "./guards-eV1qLbf2.js";
2
2
  import { n as TEMPLATE_NAME_PATTERN } from "./template-zLUjcwfF.js";
3
3
  import { D as samvaBrandPlugin, T as parseBrandSpecifier, _ as renderIr, w as brandSpecifierPattern } from "./html-eOZfDwVf.js";
4
- import { c as parseBrandCss, d as formatColor, f as parseColor, i as brandTailwindCss, u as downlevelColors } from "./theme-css-CEUrDpqq.js";
5
- import { a as downlevelMediaQuery, c as renderHeadCss, i as matchesSelector, l as resolveVariables, n as applyStylesheet, o as finalizeValue, r as classListOf, s as foldCalc, u as unquote } from "./ir-C-ubz6N3.js";
6
- import { i as buildLineMap, s as lineColumn } from "./diagnostic-codes-DdZ4tka5.js";
7
- import { a as lowerProject, d as resolveFromDirectory, f as resolveThemeImports, i as completed, l as Findings, n as introducedProfileErrors, r as loadProject, t as checkStaticProfile, u as projectDirectory } from "./profile-CXnkfEMi.js";
8
- import { c as checkAssetBase, i as withoutUnsubscribeRow, l as isAssetPath, n as brandComponents, o as assetEntry, s as assetUrl, t as BRAND_COMPONENT_CLASSES } from "./brand-C45QBKX9.js";
4
+ import { m as PRIMITIVE_CSS } from "./components-CwWSoG5N.js";
5
+ import { c as foregroundDefaults, d as downlevelColors, f as formatColor, l as parseBrandCss, n as DEFAULT_THEME_CSS, p as parseColor, u as printBrandCss } from "./theme-css-s9QwD5Ko.js";
6
+ import { n as isDarkSchemeCondition, r as isDarkSchemeQuery, t as DARK_SCHEME_MEDIA } from "./dark-scheme-CbCwGDWH.js";
7
+ import { a as downlevelMediaQuery, c as renderHeadCss, d as matchesSelector, f as unquote, i as renderDarkRules, l as resolveVariables, o as finalizeValue, r as applyStylesheet, s as foldCalc, u as classListOf } from "./ir-D-gTGquU.js";
8
+ import { i as buildLineMap, s as lineColumn } from "./diagnostic-codes-UXwOO3A-.js";
9
+ import { a as lowerProject, d as resolveFromDirectory, f as resolveThemeImports, i as completed, l as Findings, n as introducedProfileErrors, r as loadProject, t as checkStaticProfile, u as projectDirectory } from "./profile-Cc8VxOKA.js";
10
+ import { c as checkAssetBase, i as withoutUnsubscribeRow, l as isAssetPath, n as brandComponents, o as assetEntry, s as assetUrl, t as BRAND_COMPONENT_CLASSES } from "./brand-B-t62NJB.js";
9
11
  import { t as EmailCompileError } from "./diagnostics-C0Tr8-eQ.js";
10
- import { n as compileEmail, r as renderEmailContent } from "./render-Cja448i0.js";
12
+ import { n as compileEmail, r as renderEmailContent } from "./render-6gLei61p.js";
11
13
  import { caniemail, parseHtml, rawData } from "caniemail";
12
14
  import { generate, ident, parse, walk } from "css-tree";
13
15
  import { parse as parse$1 } from "@babel/parser";
@@ -224,173 +226,6 @@ const compatibilityContext = (html, css) => {
224
226
  };
225
227
  };
226
228
  //#endregion
227
- //#region src/email/compatibility.ts
228
- /**
229
- * The pinned matrix. Every finding is reported per client, and a client absent
230
- * from this list is a client Samva makes no claim about.
231
- */
232
- const EMAIL_CLIENT_MATRIX = [
233
- {
234
- id: "gmail.desktop-webmail",
235
- label: "Gmail web"
236
- },
237
- {
238
- id: "gmail.ios",
239
- label: "Gmail iOS"
240
- },
241
- {
242
- id: "gmail.android",
243
- label: "Gmail Android"
244
- },
245
- {
246
- id: "apple-mail.macos",
247
- label: "Apple Mail macOS"
248
- },
249
- {
250
- id: "apple-mail.ios",
251
- label: "Apple Mail iOS"
252
- },
253
- {
254
- id: "yahoo.desktop-webmail",
255
- label: "Yahoo web"
256
- },
257
- {
258
- id: "outlook.outlook-com",
259
- label: "Outlook web and new Windows"
260
- },
261
- {
262
- id: "outlook.windows",
263
- label: "Outlook classic Windows"
264
- }
265
- ];
266
- const LABELS = new Map(EMAIL_CLIENT_MATRIX.map((target) => [target.id, target.label]));
267
- const COMPATIBILITY_PROVENANCE = {
268
- rules: "caniemail@2.0.2",
269
- apiVersion: rawData.api_version,
270
- lastUpdate: rawData.last_update_date
271
- };
272
- /**
273
- * The constructs whose loss actually breaks a message: the recipient reads
274
- * something different, in a different order, follows a link that does not work,
275
- * or sees a layout that has collapsed. A target client dropping one of these
276
- * blocks publication.
277
- *
278
- * The list is deliberately short and enumerated, because the contract is to
279
- * block *known* failures. Nonessential differences require an assessment of
280
- * emitted values: a concrete risk warns, expected differences and unverified
281
- * behavior remain compatibility observations.
282
- */
283
- const ESSENTIAL_CONSTRUCTS = /* @__PURE__ */ new Set([
284
- "<table> element",
285
- "<div> element",
286
- "<p> element",
287
- "<span> element",
288
- "<ul>, <ol> and <dl>",
289
- "<h1> to <h6> elements",
290
- "HTML5 doctype",
291
- "width attribute",
292
- "height attribute",
293
- "align attribute",
294
- "valign attribute",
295
- "<img> element",
296
- "display",
297
- "mailto: links",
298
- "Local anchors"
299
- ]);
300
- const KNOWN_TITLES = new Set(rawData.data.map((feature) => feature.title));
301
- /** The innermost recorded node containing an offset, so a finding names its element. */
302
- const originsAt = (positions, offset) => {
303
- let best;
304
- for (const position of positions) {
305
- if (offset < position.start || offset >= position.end) continue;
306
- if (best === void 0 || position.end - position.start < best.end - best.start) best = position;
307
- }
308
- return best?.origins ?? [];
309
- };
310
- const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
311
- /**
312
- * Check the emitted document against the pinned matrix.
313
- *
314
- * Findings are grouped by construct rather than repeated per client, so one
315
- * unsupported property is one diagnostic naming every client that drops it.
316
- */
317
- const checkEmailCompatibility = (input) => {
318
- const targets = input.clients ?? EMAIL_CLIENT_MATRIX;
319
- const context = compatibilityContext(input.html, input.css);
320
- const targetClients = targets.map((target) => target.id);
321
- const reports = [caniemail({
322
- clients: targetClients,
323
- html: input.html
324
- }), ...input.css === void 0 ? [] : [caniemail({
325
- clients: targetClients,
326
- css: input.css
327
- })]];
328
- const grouped = /* @__PURE__ */ new Map();
329
- for (const bucket of reports.flatMap((report) => [report.issues.errors, report.issues.warnings])) for (const [client, issues] of bucket) {
330
- const label = LABELS.get(client) ?? client;
331
- for (const raw of issues) {
332
- const hiddenContentFailure = client === "outlook.windows" && raw.support === "partial" && (raw.title === "display" || raw.title === "display:none") && context.hiddenContentOffsets.length > 0;
333
- const blocking = hiddenContentFailure || raw.support !== "partial" && ESSENTIAL_CONSTRUCTS.has(raw.title);
334
- const { explanation, ...compatibility } = assessCompatibility(raw.title, client, raw.support, context, blocking);
335
- const key = JSON.stringify([
336
- raw.title,
337
- raw.support,
338
- compatibility,
339
- explanation
340
- ]);
341
- const offsets = hiddenContentFailure ? context.hiddenContentOffsets : context.offsets(raw.title);
342
- const entry = grouped.get(key);
343
- if (entry === void 0) grouped.set(key, {
344
- title: raw.title,
345
- support: raw.support,
346
- clients: /* @__PURE__ */ new Set([label]),
347
- notes: new Set(raw.notes),
348
- explanation,
349
- compatibility,
350
- blocking,
351
- offsets
352
- });
353
- else {
354
- entry.clients.add(label);
355
- for (const note of raw.notes) entry.notes.add(note);
356
- }
357
- }
358
- }
359
- const findings = [];
360
- for (const entry of grouped.values()) {
361
- const severity = entry.blocking ? "error" : entry.compatibility.assessment === "risk" ? "warning" : "info";
362
- const occurrences = entry.offsets.map((offset) => originsAt(input.positions ?? [], offset)).filter((origins) => origins.length > 0);
363
- const clients = [...entry.clients].sort();
364
- const dropped = entry.support === "partial" ? clients.length === 1 ? "partially supports" : "partially support" : clients.length === 1 ? "does not support" : "do not support";
365
- findings.push({
366
- code: `caniemail/${slug(entry.title)}`,
367
- severity,
368
- message: `${clients.join(", ")} ${dropped} ${entry.title}. ${entry.explanation}`,
369
- compatibility: entry.compatibility,
370
- origins: occurrences[0] ?? [],
371
- ...occurrences.length === 0 ? {} : { occurrences },
372
- ...input.fixture === void 0 ? {} : { fixtures: [input.fixture] },
373
- clients,
374
- ...entry.notes.size === 0 ? {} : { notes: [...entry.notes].join(" ") },
375
- provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
376
- });
377
- }
378
- return findings.sort((left, right) => left.code.localeCompare(right.code));
379
- };
380
- /**
381
- * Constructs the pinned data says nothing about for a target client. These are
382
- * reported so "we did not check that" is never mistaken for "that works".
383
- */
384
- const unknownCoverage = (titles) => [...titles].filter((title) => !KNOWN_TITLES.has(title)).sort().map((title) => ({
385
- code: "caniemail/unknown-coverage",
386
- severity: "info",
387
- message: `The pinned rules say nothing about ${title}, so no client claim is made about it. Verify it in a real client before relying on it.`,
388
- origins: [],
389
- provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
390
- }));
391
- /** A publication carrying one of these is refused; the message would arrive broken. */
392
- const hasBlockingCompatibility = (findings) => findings.some((finding) => finding.severity === "error");
393
- //#endregion
394
229
  //#region src/email/fonts.ts
395
230
  const FONT_FILE = /\.woff2$/i;
396
231
  const WOFF2_FORMAT = /^woff2(?:-variations)?$/i;
@@ -611,6 +446,7 @@ const parseStylesheet = (source, options = {}) => {
611
446
  const headAtRules = [];
612
447
  const fontFaces = [];
613
448
  const variables = {};
449
+ const darkVariables = {};
614
450
  const conditionalVariables = /* @__PURE__ */ new Set();
615
451
  const diagnostics = [];
616
452
  const layers = /* @__PURE__ */ new Map();
@@ -667,6 +503,7 @@ const parseStylesheet = (source, options = {}) => {
667
503
  headAtRules: [],
668
504
  fontFaces: [],
669
505
  variables: {},
506
+ darkVariables: {},
670
507
  diagnostics: [{
671
508
  code: "css-parse-error",
672
509
  severity: "error",
@@ -694,6 +531,7 @@ const parseStylesheet = (source, options = {}) => {
694
531
  if (ROOT_SELECTORS.has(selector.trim())) for (const declaration of declarations) {
695
532
  if (!declaration.property.startsWith("--")) continue;
696
533
  if (context.conditions.length === 0) variables[declaration.property] = declaration.value;
534
+ else if (isDarkSchemeCondition(context.conditions)) darkVariables[declaration.property] = declaration.value;
697
535
  else conditionalVariables.add(declaration.property);
698
536
  }
699
537
  rules.push({
@@ -750,7 +588,11 @@ const parseStylesheet = (source, options = {}) => {
750
588
  return;
751
589
  }
752
590
  if (name === "theme" || name === "scope") {
753
- for (const declaration of declarationsOf(node.block)) if (declaration.property.startsWith("--")) variables[declaration.property] = declaration.value;
591
+ const into = context.conditions.length === 0 ? variables : isDarkSchemeCondition(context.conditions) ? darkVariables : void 0;
592
+ for (const declaration of declarationsOf(node.block)) if (declaration.property.startsWith("--")) {
593
+ if (into === void 0) conditionalVariables.add(declaration.property);
594
+ else into[declaration.property] = declaration.value;
595
+ }
754
596
  for (const child of node.block?.children ?? []) if (child.type !== "Declaration") visitNode(child, context);
755
597
  return;
756
598
  }
@@ -810,10 +652,561 @@ const parseStylesheet = (source, options = {}) => {
810
652
  headAtRules,
811
653
  fontFaces,
812
654
  variables,
655
+ darkVariables,
813
656
  diagnostics
814
657
  };
815
658
  };
816
659
  //#endregion
660
+ //#region src/email/contrast.ts
661
+ const CANVAS = {
662
+ light: {
663
+ r: 1,
664
+ g: 1,
665
+ b: 1,
666
+ a: 1
667
+ },
668
+ dark: {
669
+ r: 18 / 255,
670
+ g: 18 / 255,
671
+ b: 18 / 255,
672
+ a: 1
673
+ }
674
+ };
675
+ /** The ratios `email-contrast` holds a message to, and what counts as large text. */
676
+ const CONTRAST_THRESHOLDS = {
677
+ /** Text below this is an error at any size. */
678
+ textError: 3,
679
+ /** Normal-size text below this is a warning (WCAG AA). */
680
+ textWarning: 4.5,
681
+ /** Text at least this many pixels is large, as is bold text at `largeBoldPx`. */
682
+ largePx: 24,
683
+ largeBoldPx: 18.5,
684
+ /** A filled button below this against its surroundings is an error. */
685
+ shapeError: 1.5,
686
+ /** A filled button below this is a warning (WCAG 1.4.11). */
687
+ shapeWarning: 3,
688
+ /** A button border at least this against the surroundings keeps the shape. */
689
+ border: 3
690
+ };
691
+ const TEXT_ERROR = CONTRAST_THRESHOLDS.textError;
692
+ const TEXT_WARNING = CONTRAST_THRESHOLDS.textWarning;
693
+ const LARGE_PX = CONTRAST_THRESHOLDS.largePx;
694
+ const LARGE_BOLD_PX = CONTRAST_THRESHOLDS.largeBoldPx;
695
+ const SHAPE_ERROR = CONTRAST_THRESHOLDS.shapeError;
696
+ const SHAPE_WARNING = CONTRAST_THRESHOLDS.shapeWarning;
697
+ const INSET_BORDER = CONTRAST_THRESHOLDS.border;
698
+ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
699
+ "head",
700
+ "style",
701
+ "title",
702
+ "script"
703
+ ]);
704
+ const INVISIBLE = /[\s\u200b-\u200f\ufeff\u00a0\u00ad]|\u034f/g;
705
+ const DARK_QUERY = /^@media\b[^{]*\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
706
+ const clamp = (value) => value < 0 ? 0 : value > 1 ? 1 : value;
707
+ const channel = (value) => {
708
+ const v = clamp(value);
709
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
710
+ };
711
+ const luminance = (color) => .2126 * channel(color.r) + .7152 * channel(color.g) + .0722 * channel(color.b);
712
+ const ratioOf = (left, right) => {
713
+ const a = luminance(left);
714
+ const b = luminance(right);
715
+ return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
716
+ };
717
+ const hex = (color) => `#${[
718
+ color.r,
719
+ color.g,
720
+ color.b
721
+ ].map((value) => Math.round(clamp(value) * 255).toString(16).padStart(2, "0")).join("")}`;
722
+ const over = (top, below) => ({
723
+ r: top.r * top.a + below.r * (1 - top.a),
724
+ g: top.g * top.a + below.g * (1 - top.a),
725
+ b: top.b * top.a + below.b * (1 - top.a),
726
+ a: 1
727
+ });
728
+ const declarationsOf = (source) => {
729
+ const found = [];
730
+ try {
731
+ walk(parse(source, {
732
+ context: "declarationList",
733
+ positions: false
734
+ }), (node) => {
735
+ if (node.type === "Declaration") found.push({
736
+ property: node.property.toLowerCase(),
737
+ value: generate(node.value).trim()
738
+ });
739
+ });
740
+ } catch {
741
+ return [];
742
+ }
743
+ return found;
744
+ };
745
+ /** Split on whitespace that is not inside parentheses. */
746
+ const tokens = (value) => {
747
+ const found = [];
748
+ let depth = 0;
749
+ let current = "";
750
+ for (const char of value) {
751
+ if (char === "(") depth += 1;
752
+ if (char === ")") depth -= 1;
753
+ if (/\s/.test(char) && depth === 0) {
754
+ if (current !== "") found.push(current);
755
+ current = "";
756
+ } else current += char;
757
+ }
758
+ if (current !== "") found.push(current);
759
+ return found;
760
+ };
761
+ const declaredColor = (value) => {
762
+ const color = parseColor(value);
763
+ return color === void 0 ? "unknown" : color;
764
+ };
765
+ const pixels = (value) => {
766
+ const trimmed = value.trim();
767
+ if (/^[+-]?0+(?:\.0+)?$/.test(trimmed)) return 0;
768
+ const match = /^(\d+(?:\.\d+)?|\.\d+)px$/i.exec(trimmed);
769
+ return match === null ? void 0 : Number(match[1]);
770
+ };
771
+ const BORDER_STYLES = /^(?:solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)$/i;
772
+ const WIDTH_KEYWORDS = {
773
+ thin: 1,
774
+ medium: 3,
775
+ thick: 5
776
+ };
777
+ const resolve = (declarations) => {
778
+ let background;
779
+ let image = false;
780
+ let color;
781
+ let display;
782
+ let fontSize;
783
+ let bold;
784
+ const border = {
785
+ width: void 0,
786
+ visible: void 0,
787
+ color: void 0
788
+ };
789
+ for (const { property, value } of declarations) {
790
+ const lower = value.toLowerCase();
791
+ switch (property) {
792
+ case "background":
793
+ image = /url\(|gradient\(/.test(lower);
794
+ background = tokens(value).filter((part) => !/^(?:url|[\w-]*gradient)\(/i.test(part)).map(parseColor).find((entry) => entry !== void 0) ?? (/var\(|currentcolor|inherit|initial|unset/.test(lower) ? "unknown" : void 0);
795
+ break;
796
+ case "background-color":
797
+ background = declaredColor(value);
798
+ break;
799
+ case "background-image":
800
+ image = lower !== "none";
801
+ break;
802
+ case "color":
803
+ color = declaredColor(value);
804
+ break;
805
+ case "display":
806
+ display = lower.replace(/\s*!important$/, "");
807
+ break;
808
+ case "font-size":
809
+ fontSize = pixels(value);
810
+ break;
811
+ case "font-weight": {
812
+ const weight = Number(lower);
813
+ if (lower === "bold" || lower === "bolder") bold = true;
814
+ else if (lower === "normal" || lower === "lighter") bold = false;
815
+ else if (!Number.isNaN(weight)) bold = weight >= 700;
816
+ break;
817
+ }
818
+ case "border":
819
+ case "border-width":
820
+ case "border-color":
821
+ case "border-style": {
822
+ const parts = tokens(value);
823
+ if (property === "border") {
824
+ border.width = WIDTH_KEYWORDS.medium;
825
+ border.visible = false;
826
+ border.color = "unknown";
827
+ }
828
+ const styles = parts.filter((part) => BORDER_STYLES.test(part));
829
+ if (styles.length > 0) border.visible = styles.every((part) => !/^(?:none|hidden)$/i.test(part));
830
+ const sizes = parts.map((part) => pixels(part) ?? WIDTH_KEYWORDS[part.toLowerCase()]).filter((entry) => entry !== void 0);
831
+ if (sizes.length > 0) border.width = Math.min(...sizes);
832
+ else if (property === "border-width") border.width = void 0;
833
+ const named = parts.filter((part) => pixels(part) === void 0 && WIDTH_KEYWORDS[part.toLowerCase()] === void 0 && !BORDER_STYLES.test(part));
834
+ if (property !== "border-width" && property !== "border-style" && named.length > 0) {
835
+ const colors = named.map(parseColor);
836
+ border.color = colors.every((entry) => entry !== void 0) ? colors[0] : "unknown";
837
+ }
838
+ break;
839
+ }
840
+ }
841
+ }
842
+ return {
843
+ background,
844
+ image,
845
+ color,
846
+ display,
847
+ fontSize,
848
+ bold,
849
+ border
850
+ };
851
+ };
852
+ /** Properties a declaration list sets, for deciding whether an unimportant dark rule can apply. */
853
+ const has = (declarations, property) => declarations.some((entry) => entry.property === property);
854
+ const advance = (parent, style, tag) => {
855
+ let background = parent.background;
856
+ let ownBackground;
857
+ if (style.image || style.background === "unknown") background = "unknown";
858
+ else if (style.background !== void 0 && style.background.a > 0) {
859
+ if (style.background.a >= 1) background = style.background;
860
+ else if (parent.background !== "unknown") background = over(style.background, parent.background);
861
+ else background = "unknown";
862
+ if (background !== "unknown") ownBackground = background;
863
+ }
864
+ let color = tag === "a" ? void 0 : parent.color;
865
+ if (style.color === "unknown") color = "unknown";
866
+ else if (style.color !== void 0) color = style.color;
867
+ return {
868
+ ownBackground,
869
+ state: {
870
+ background,
871
+ color,
872
+ fontSize: style.fontSize ?? parent.fontSize,
873
+ bold: style.bold ?? parent.bold,
874
+ hidden: parent.hidden || style.display === "none"
875
+ }
876
+ };
877
+ };
878
+ const originsAt$1 = (positions, offset) => {
879
+ if (offset === void 0) return [];
880
+ let best;
881
+ for (const position of positions) {
882
+ if (offset < position.start || offset >= position.end) continue;
883
+ if (best === void 0 || position.end - position.start < best.end - best.start) best = position;
884
+ }
885
+ return best?.origins ?? [];
886
+ };
887
+ const compareRules = (left, right) => {
888
+ const important = (rule) => rule.declarations.some((d) => d.important) ? 1 : 0;
889
+ return important(left) - important(right) || left.specificity[0] - right.specificity[0] || left.specificity[1] - right.specificity[1] || left.specificity[2] - right.specificity[2] || left.order - right.order;
890
+ };
891
+ const textOf = (node) => node.children.map((child) => "data" in child ? String(child.data) : "").join("");
892
+ const MODE_LABEL = {
893
+ light: "Light mode",
894
+ dark: "Dark mode"
895
+ };
896
+ const FIX = "Pair background and text tokens (bg-primary with text-primary-foreground) and give the theme's dark-mode @theme a value for the token, or choose colors that reach";
897
+ /**
898
+ * Check text and filled-control contrast in the emitted document, in light and
899
+ * dark presentation.
900
+ */
901
+ const checkEmailContrast = (input) => {
902
+ const { document } = parseHtml(input.html);
903
+ const sheets = input.css === void 0 ? [] : [input.css];
904
+ const collect = (nodes) => {
905
+ for (const node of nodes) {
906
+ if (!("attribs" in node)) continue;
907
+ if (node.name === "style") sheets.push(textOf(node));
908
+ else collect(node.children);
909
+ }
910
+ };
911
+ collect(document.children);
912
+ const darkRules = sheets.flatMap((sheet) => parseStylesheet(sheet).rules).filter((rule) => rule.conditions.length === 1 && DARK_QUERY.test(rule.conditions[0])).toSorted(compareRules);
913
+ const findings = /* @__PURE__ */ new Map();
914
+ const report = (mode, kind, foreground, background, ratio, required, severity, offset) => {
915
+ const origins = originsAt$1(input.positions ?? [], offset);
916
+ const measured = ratio.toFixed(2);
917
+ const colors = `${hex(foreground)} on ${hex(background)}`;
918
+ const message = kind === "text" ? `${MODE_LABEL[mode]}: text ${colors} has a contrast ratio of ${measured}:1; ${required}:1 is required. ${FIX} ${required}:1.` : `${MODE_LABEL[mode]}: the button background ${hex(foreground)} against its surroundings ${hex(background)} has a contrast ratio of ${measured}:1; ${SHAPE_WARNING}:1 is required for the shape to stay visible (below ${SHAPE_ERROR}:1 it disappears). ${FIX} ${SHAPE_WARNING}:1, or give the button a border that does.`;
919
+ const key = JSON.stringify([
920
+ mode,
921
+ kind,
922
+ hex(foreground),
923
+ hex(background),
924
+ measured,
925
+ origins
926
+ ]);
927
+ const existing = findings.get(key);
928
+ if (existing !== void 0) {
929
+ if (origins.length > 0) existing.occurrences.push(origins);
930
+ return;
931
+ }
932
+ findings.set(key, {
933
+ key,
934
+ occurrences: origins.length > 0 ? [origins] : [],
935
+ diagnostic: {
936
+ code: "email-contrast",
937
+ severity,
938
+ message,
939
+ origins,
940
+ ...input.fixture === void 0 ? {} : { fixtures: [input.fixture] }
941
+ }
942
+ });
943
+ };
944
+ const visit = (node, parents, parentAncestry, previous) => {
945
+ const inline = declarationsOf(node.attribs.style ?? "");
946
+ const attribute = node.attribs.bgcolor === void 0 ? [] : [{
947
+ property: "background-color",
948
+ value: node.attribs.bgcolor
949
+ }];
950
+ const shape = {
951
+ type: "Element",
952
+ tag: node.name,
953
+ props: node.attribs,
954
+ children: [],
955
+ origins: [],
956
+ authored: false
957
+ };
958
+ const ancestry = {
959
+ element: shape,
960
+ parent: parentAncestry,
961
+ previous
962
+ };
963
+ const base = [...attribute, ...inline];
964
+ const dark = [...base];
965
+ for (const rule of darkRules) {
966
+ if (!matchesSelector(rule.matcher, ancestry)) continue;
967
+ for (const declaration of rule.declarations) {
968
+ const property = declaration.property.toLowerCase();
969
+ if (declaration.important || !has(inline, property)) dark.push({
970
+ property,
971
+ value: declaration.value.replace(/\s*!important$/i, "")
972
+ });
973
+ }
974
+ }
975
+ const styles = {
976
+ light: resolve(base),
977
+ dark: resolve(dark)
978
+ };
979
+ const direct = node.children.some((child) => child.type === "text" && String(child.data).replace(INVISIBLE, "") !== "");
980
+ const states = {
981
+ light: parents.light,
982
+ dark: parents.dark
983
+ };
984
+ for (const mode of ["light", "dark"]) {
985
+ const parent = parents[mode];
986
+ const style = styles[mode];
987
+ const { state, ownBackground } = advance(parent, style, node.name);
988
+ states[mode] = state;
989
+ if (state.hidden) continue;
990
+ if (node.name === "a" && ownBackground !== void 0 && parent.background !== "unknown" && !style.image) {
991
+ const ratio = ratioOf(ownBackground, parent.background);
992
+ if (ratio < SHAPE_WARNING) {
993
+ const { width, visible, color } = style.border;
994
+ const bordered = visible === true && width !== void 0 && width > 0 && color !== void 0;
995
+ if (!(bordered && color !== "unknown" && ratioOf(over(color, parent.background), parent.background) >= INSET_BORDER) && !(bordered && color === "unknown")) report(mode, "button", ownBackground, parent.background, ratio, SHAPE_WARNING, ratio < SHAPE_ERROR ? "error" : "warning", node.startIndex ?? void 0);
996
+ }
997
+ }
998
+ if (direct && state.color !== void 0 && state.color !== "unknown" && state.background !== "unknown") {
999
+ const ink = over(state.color, state.background);
1000
+ const ratio = ratioOf(ink, state.background);
1001
+ const required = state.fontSize !== void 0 && (state.fontSize >= LARGE_PX || state.bold && state.fontSize >= LARGE_BOLD_PX) ? TEXT_ERROR : TEXT_WARNING;
1002
+ if (ratio < required) report(mode, "text", ink, state.background, ratio, required, ratio < TEXT_ERROR ? "error" : "warning", node.startIndex ?? void 0);
1003
+ }
1004
+ }
1005
+ if (!SKIPPED_TAGS.has(node.name) && (!states.light.hidden || !states.dark.hidden)) {
1006
+ let siblings = [];
1007
+ for (const child of node.children) {
1008
+ if (!("attribs" in child) || SKIPPED_TAGS.has(child.name)) continue;
1009
+ const done = visit(child, states, ancestry, siblings);
1010
+ siblings = [...siblings, done];
1011
+ }
1012
+ }
1013
+ return shape;
1014
+ };
1015
+ const root = {
1016
+ light: {
1017
+ background: CANVAS.light,
1018
+ color: void 0,
1019
+ fontSize: void 0,
1020
+ bold: false,
1021
+ hidden: false
1022
+ },
1023
+ dark: {
1024
+ background: CANVAS.dark,
1025
+ color: void 0,
1026
+ fontSize: void 0,
1027
+ bold: false,
1028
+ hidden: false
1029
+ }
1030
+ };
1031
+ let siblings = [];
1032
+ for (const node of document.children) {
1033
+ if (!("attribs" in node) || SKIPPED_TAGS.has(node.name)) continue;
1034
+ siblings = [...siblings, visit(node, root, void 0, siblings)];
1035
+ }
1036
+ return [...findings.values()].map(({ diagnostic, occurrences }) => ({
1037
+ ...diagnostic,
1038
+ ...occurrences.length > 1 ? { occurrences } : {}
1039
+ }));
1040
+ };
1041
+ //#endregion
1042
+ //#region src/email/compatibility.ts
1043
+ /**
1044
+ * The pinned matrix. Every finding is reported per client, and a client absent
1045
+ * from this list is a client Samva makes no claim about.
1046
+ */
1047
+ const EMAIL_CLIENT_MATRIX = [
1048
+ {
1049
+ id: "gmail.desktop-webmail",
1050
+ label: "Gmail web"
1051
+ },
1052
+ {
1053
+ id: "gmail.ios",
1054
+ label: "Gmail iOS"
1055
+ },
1056
+ {
1057
+ id: "gmail.android",
1058
+ label: "Gmail Android"
1059
+ },
1060
+ {
1061
+ id: "apple-mail.macos",
1062
+ label: "Apple Mail macOS"
1063
+ },
1064
+ {
1065
+ id: "apple-mail.ios",
1066
+ label: "Apple Mail iOS"
1067
+ },
1068
+ {
1069
+ id: "yahoo.desktop-webmail",
1070
+ label: "Yahoo web"
1071
+ },
1072
+ {
1073
+ id: "outlook.outlook-com",
1074
+ label: "Outlook web and new Windows"
1075
+ },
1076
+ {
1077
+ id: "outlook.windows",
1078
+ label: "Outlook classic Windows"
1079
+ }
1080
+ ];
1081
+ const LABELS = new Map(EMAIL_CLIENT_MATRIX.map((target) => [target.id, target.label]));
1082
+ const COMPATIBILITY_PROVENANCE = {
1083
+ rules: "caniemail@2.0.2",
1084
+ apiVersion: rawData.api_version,
1085
+ lastUpdate: rawData.last_update_date
1086
+ };
1087
+ /**
1088
+ * The constructs whose loss actually breaks a message: the recipient reads
1089
+ * something different, in a different order, follows a link that does not work,
1090
+ * or sees a layout that has collapsed. A target client dropping one of these
1091
+ * blocks publication.
1092
+ *
1093
+ * The list is deliberately short and enumerated, because the contract is to
1094
+ * block *known* failures. Nonessential differences require an assessment of
1095
+ * emitted values: a concrete risk warns, expected differences and unverified
1096
+ * behavior remain compatibility observations.
1097
+ */
1098
+ const ESSENTIAL_CONSTRUCTS = /* @__PURE__ */ new Set([
1099
+ "<table> element",
1100
+ "<div> element",
1101
+ "<p> element",
1102
+ "<span> element",
1103
+ "<ul>, <ol> and <dl>",
1104
+ "<h1> to <h6> elements",
1105
+ "HTML5 doctype",
1106
+ "width attribute",
1107
+ "height attribute",
1108
+ "align attribute",
1109
+ "valign attribute",
1110
+ "<img> element",
1111
+ "display",
1112
+ "mailto: links",
1113
+ "Local anchors"
1114
+ ]);
1115
+ const KNOWN_TITLES = new Set(rawData.data.map((feature) => feature.title));
1116
+ /** The innermost recorded node containing an offset, so a finding names its element. */
1117
+ const originsAt = (positions, offset) => {
1118
+ let best;
1119
+ for (const position of positions) {
1120
+ if (offset < position.start || offset >= position.end) continue;
1121
+ if (best === void 0 || position.end - position.start < best.end - best.start) best = position;
1122
+ }
1123
+ return best?.origins ?? [];
1124
+ };
1125
+ const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1126
+ /**
1127
+ * Check the emitted document against the pinned matrix.
1128
+ *
1129
+ * Findings are grouped by construct rather than repeated per client, so one
1130
+ * unsupported property is one diagnostic naming every client that drops it.
1131
+ */
1132
+ const checkEmailCompatibility = (input) => {
1133
+ const targets = input.clients ?? EMAIL_CLIENT_MATRIX;
1134
+ const context = compatibilityContext(input.html, input.css);
1135
+ const targetClients = targets.map((target) => target.id);
1136
+ const reports = [caniemail({
1137
+ clients: targetClients,
1138
+ html: input.html
1139
+ }), ...input.css === void 0 ? [] : [caniemail({
1140
+ clients: targetClients,
1141
+ css: input.css
1142
+ })]];
1143
+ const grouped = /* @__PURE__ */ new Map();
1144
+ for (const bucket of reports.flatMap((report) => [report.issues.errors, report.issues.warnings])) for (const [client, issues] of bucket) {
1145
+ const label = LABELS.get(client) ?? client;
1146
+ for (const raw of issues) {
1147
+ const hiddenContentFailure = client === "outlook.windows" && raw.support === "partial" && (raw.title === "display" || raw.title === "display:none") && context.hiddenContentOffsets.length > 0;
1148
+ const blocking = hiddenContentFailure || raw.support !== "partial" && ESSENTIAL_CONSTRUCTS.has(raw.title);
1149
+ const { explanation, ...compatibility } = assessCompatibility(raw.title, client, raw.support, context, blocking);
1150
+ const key = JSON.stringify([
1151
+ raw.title,
1152
+ raw.support,
1153
+ compatibility,
1154
+ explanation
1155
+ ]);
1156
+ const offsets = hiddenContentFailure ? context.hiddenContentOffsets : context.offsets(raw.title);
1157
+ const entry = grouped.get(key);
1158
+ if (entry === void 0) grouped.set(key, {
1159
+ title: raw.title,
1160
+ support: raw.support,
1161
+ clients: /* @__PURE__ */ new Set([label]),
1162
+ notes: new Set(raw.notes),
1163
+ explanation,
1164
+ compatibility,
1165
+ blocking,
1166
+ offsets
1167
+ });
1168
+ else {
1169
+ entry.clients.add(label);
1170
+ for (const note of raw.notes) entry.notes.add(note);
1171
+ }
1172
+ }
1173
+ }
1174
+ const findings = [];
1175
+ for (const entry of grouped.values()) {
1176
+ const severity = entry.blocking ? "error" : entry.compatibility.assessment === "risk" ? "warning" : "info";
1177
+ const occurrences = entry.offsets.map((offset) => originsAt(input.positions ?? [], offset)).filter((origins) => origins.length > 0);
1178
+ const clients = [...entry.clients].sort();
1179
+ const dropped = entry.support === "partial" ? clients.length === 1 ? "partially supports" : "partially support" : clients.length === 1 ? "does not support" : "do not support";
1180
+ findings.push({
1181
+ code: `caniemail/${slug(entry.title)}`,
1182
+ severity,
1183
+ message: `${clients.join(", ")} ${dropped} ${entry.title}. ${entry.explanation}`,
1184
+ compatibility: entry.compatibility,
1185
+ origins: occurrences[0] ?? [],
1186
+ ...occurrences.length === 0 ? {} : { occurrences },
1187
+ ...input.fixture === void 0 ? {} : { fixtures: [input.fixture] },
1188
+ clients,
1189
+ ...entry.notes.size === 0 ? {} : { notes: [...entry.notes].join(" ") },
1190
+ provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
1191
+ });
1192
+ }
1193
+ findings.push(...checkEmailContrast(input));
1194
+ return findings.sort((left, right) => left.code.localeCompare(right.code));
1195
+ };
1196
+ /**
1197
+ * Constructs the pinned data says nothing about for a target client. These are
1198
+ * reported so "we did not check that" is never mistaken for "that works".
1199
+ */
1200
+ const unknownCoverage = (titles) => [...titles].filter((title) => !KNOWN_TITLES.has(title)).sort().map((title) => ({
1201
+ code: "caniemail/unknown-coverage",
1202
+ severity: "info",
1203
+ message: `The pinned rules say nothing about ${title}, so no client claim is made about it. Verify it in a real client before relying on it.`,
1204
+ origins: [],
1205
+ provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
1206
+ }));
1207
+ /** A publication carrying one of these is refused; the message would arrive broken. */
1208
+ const hasBlockingCompatibility = (findings) => findings.some((finding) => finding.severity === "error");
1209
+ //#endregion
817
1210
  //#region src/email/tailwind-stylesheets.gen.ts
818
1211
  /** The exact Tailwind release the compiler runs, recorded in every build manifest. */
819
1212
  const TAILWIND_VERSION = "4.3.3";
@@ -826,31 +1219,91 @@ const STYLESHEETS = {
826
1219
  "tailwindcss/preflight.css": "/*\n 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\n 2. Remove default margins and padding\n 3. Reset all borders.\n*/\n\n*,\n::after,\n::before,\n::backdrop,\n::file-selector-button {\n box-sizing: border-box; /* 1 */\n margin: 0; /* 2 */\n padding: 0; /* 2 */\n border: 0 solid; /* 3 */\n}\n\n/*\n 1. Use a consistent sensible line-height in all browsers.\n 2. Prevent adjustments of font size after orientation changes in iOS.\n 3. Use a more readable tab size.\n 4. Use the user's configured `sans` font-family by default.\n 5. Use the user's configured `sans` font-feature-settings by default.\n 6. Use the user's configured `sans` font-variation-settings by default.\n 7. Disable tap highlights on iOS.\n*/\n\nhtml,\n:host {\n line-height: 1.5; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n tab-size: 4; /* 3 */\n font-family: --theme(\n --default-font-family,\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n 'Helvetica Neue',\n 'Noto Sans',\n Arial,\n sans-serif,\n 'Apple Color Emoji',\n 'Segoe UI Emoji',\n 'Segoe UI Symbol',\n 'Noto Color Emoji'\n ); /* 4 */\n font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */\n font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */\n -webkit-tap-highlight-color: transparent; /* 7 */\n}\n\n/*\n 1. Add the correct height in Firefox.\n 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n 3. Reset the default border style to a 1px solid border.\n*/\n\nhr {\n height: 0; /* 1 */\n color: inherit; /* 2 */\n border-top-width: 1px; /* 3 */\n}\n\n/*\n Add the correct text decoration in Chrome, Edge, and Safari.\n*/\n\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\n\n/*\n Remove the default font size and weight for headings.\n*/\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-size: inherit;\n font-weight: inherit;\n}\n\n/*\n Reset links to optimize for opt-in styling instead of opt-out.\n*/\n\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\n\n/*\n Add the correct font weight in Edge and Safari.\n*/\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/*\n 1. Use the user's configured `mono` font-family by default.\n 2. Use the user's configured `mono` font-feature-settings by default.\n 3. Use the user's configured `mono` font-variation-settings by default.\n 4. Correct the odd `em` font sizing in all browsers.\n*/\n\ncode,\nkbd,\nsamp,\npre {\n font-family: --theme(\n --default-mono-font-family,\n ui-monospace,\n SFMono-Regular,\n Menlo,\n Monaco,\n Consolas,\n 'Liberation Mono',\n 'Courier New',\n monospace\n ); /* 1 */\n font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */\n font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */\n font-size: 1em; /* 4 */\n}\n\n/*\n Add the correct font size in all browsers.\n*/\n\nsmall {\n font-size: 80%;\n}\n\n/*\n Prevent `sub` and `sup` elements from affecting the line height in all browsers.\n*/\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/*\n 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\n 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\n 3. Remove gaps between table borders by default.\n*/\n\ntable {\n text-indent: 0; /* 1 */\n border-color: inherit; /* 2 */\n border-collapse: collapse; /* 3 */\n}\n\n/*\n Use the modern Firefox focus style for all focusable elements.\n*/\n\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\n\n/*\n Add the correct vertical alignment in Chrome and Firefox.\n*/\n\nprogress {\n vertical-align: baseline;\n}\n\n/*\n Add the correct display in Chrome and Safari.\n*/\n\nsummary {\n display: list-item;\n}\n\n/*\n Make lists unstyled by default.\n*/\n\nol,\nul,\nmenu {\n list-style: none;\n}\n\n/*\n 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\n 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n This can trigger a poorly considered lint error in some tools but is included by design.\n*/\n\nimg,\nsvg,\nvideo,\ncanvas,\naudio,\niframe,\nembed,\nobject {\n display: block; /* 1 */\n vertical-align: middle; /* 2 */\n}\n\n/*\n Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n*/\n\nimg,\nvideo {\n max-width: 100%;\n height: auto;\n}\n\n/*\n 1. Inherit font styles in all browsers.\n 2. Remove border radius in all browsers.\n 3. Remove background color in all browsers.\n 4. Ensure consistent opacity for disabled states in all browsers.\n*/\n\nbutton,\ninput,\nselect,\noptgroup,\ntextarea,\n::file-selector-button {\n font: inherit; /* 1 */\n font-feature-settings: inherit; /* 1 */\n font-variation-settings: inherit; /* 1 */\n letter-spacing: inherit; /* 1 */\n color: inherit; /* 1 */\n border-radius: 0; /* 2 */\n background-color: transparent; /* 3 */\n opacity: 1; /* 4 */\n}\n\n/*\n Restore default font weight.\n*/\n\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n\n/*\n Restore indentation.\n*/\n\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n\n/*\n Restore space after button.\n*/\n\n::file-selector-button {\n margin-inline-end: 4px;\n}\n\n/*\n Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n*/\n\n::placeholder {\n opacity: 1;\n}\n\n/*\n Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not\n crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)\n*/\n\n@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or\n (contain-intrinsic-size: 1px) /* Safari 17+ */ {\n ::placeholder {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n}\n\n/*\n Prevent resizing textareas horizontally by default.\n*/\n\ntextarea {\n resize: vertical;\n}\n\n/*\n Remove the inner padding in Chrome and Safari on macOS.\n*/\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/*\n 1. Ensure date/time inputs have the same height when empty in iOS Safari.\n 2. Ensure text alignment can be changed on date/time inputs in iOS Safari.\n*/\n\n::-webkit-date-and-time-value {\n min-height: 1lh; /* 1 */\n text-align: inherit; /* 2 */\n}\n\n/*\n Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.\n*/\n\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n\n/*\n Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.\n*/\n\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n\n::-webkit-datetime-edit,\n::-webkit-datetime-edit-year-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-minute-field,\n::-webkit-datetime-edit-second-field,\n::-webkit-datetime-edit-millisecond-field,\n::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n\n/*\n Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)\n*/\n\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n\n/*\n Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n*/\n\n:-moz-ui-invalid {\n box-shadow: none;\n}\n\n/*\n Correct the inability to style the border radius in iOS Safari.\n*/\n\nbutton,\ninput:where([type='button'], [type='reset'], [type='submit']),\n::file-selector-button {\n appearance: button;\n}\n\n/*\n Correct the cursor style of increment and decrement buttons in Safari.\n*/\n\n::-webkit-inner-spin-button,\n::-webkit-outer-spin-button {\n height: auto;\n}\n\n/*\n Make elements with the HTML hidden attribute stay hidden by default.\n*/\n\n[hidden]:where(:not([hidden='until-found'])) {\n display: none !important;\n}\n"
827
1220
  };
828
1221
  /**
829
- * Mark every `@theme` in project and brand CSS `static`. Tailwind emits a
830
- * theme variable only when a utility it generated reads it, but a project's
831
- * own stylesheets read theme variables too (`color: var(--color-brand)`), and
832
- * Tailwind never sees those rules. A later layer that redefines a variable
833
- * replaces its options, so every layer has to carry `static` for the value
834
- * the cascade settles on to reach the inliner.
1222
+ * Lower one theme stylesheet for Tailwind and the email cascade.
1223
+ *
1224
+ * Every `@theme` becomes `static`. Tailwind emits a theme variable only when a
1225
+ * utility it generated reads it, but a project's own stylesheets read theme
1226
+ * variables too (`color: var(--color-primary)`), and Tailwind never sees those
1227
+ * rules. A later layer that redefines a variable replaces its options, so every
1228
+ * layer has to carry `static` for the value the cascade settles on to reach the
1229
+ * inliner.
1230
+ *
1231
+ * A dark-mode block, `@media (prefers-color-scheme: dark) { @theme { … } }`,
1232
+ * becomes a `:root` rule in that query, which the cascade reads as the dark
1233
+ * values of those variables (Tailwind would hoist a nested `@theme` into the
1234
+ * light theme). A stylesheet owns each color it sets in both modes: a color its
1235
+ * light `@theme` sets and its dark-mode block does not keeps that value in dark
1236
+ * mode too, rather than a dark value from a layer beneath it. A fill it sets
1237
+ * without a text color gets `foregroundDefaults`.
835
1238
  */
836
- const staticTheme = (css) => {
1239
+ const lowerThemeCss = (css) => {
837
1240
  const ast = parse(css, {
838
1241
  positions: true,
839
1242
  parseValue: false,
840
1243
  parseRulePrelude: false,
841
1244
  parseCustomProperty: false
842
1245
  });
843
- const insertions = [];
844
- walk(ast, (node) => {
845
- if (node.type !== "Atrule" || node.name.toLowerCase() !== "theme" || node.loc == null) return;
846
- const prelude = node.prelude === null ? "" : css.slice(node.prelude.loc?.start.offset ?? 0, node.prelude.loc?.end.offset ?? 0);
847
- if (/(?:^|\s)static(?:\s|$)/i.test(prelude)) return;
848
- insertions.push(node.loc.start.offset + 6);
849
- });
1246
+ const edits = [];
1247
+ const light = {};
1248
+ const dark = {};
1249
+ const declarations = (node, visit) => {
1250
+ if (node.type !== "Atrule" || node.block === null) return;
1251
+ for (const child of node.block.children) if (child.type === "Declaration" && child.loc != null && child.value.loc != null) visit(child.property, css.slice(child.value.loc.start.offset, child.value.loc.end.offset));
1252
+ };
1253
+ const preludeOf = (node) => node.type !== "Atrule" || node.prelude === null || node.prelude.loc == null ? "" : css.slice(node.prelude.loc.start.offset, node.prelude.loc.end.offset);
1254
+ const isTheme = (node) => node.type === "Atrule" && node.name.toLowerCase() === "theme" && node.loc != null;
1255
+ if (ast.type === "StyleSheet") for (const node of ast.children) {
1256
+ if (isTheme(node)) {
1257
+ if (!/(?:^|\s)static(?:\s|$)/i.test(preludeOf(node))) edits.push({
1258
+ start: node.loc.start.offset + 6,
1259
+ end: node.loc.start.offset + 6,
1260
+ text: " static"
1261
+ });
1262
+ declarations(node, (property, value) => {
1263
+ if (property.startsWith("--color-")) light[property] = value.trim();
1264
+ });
1265
+ continue;
1266
+ }
1267
+ if (node.type !== "Atrule" || node.name.toLowerCase() !== "media" || node.block === null || !isDarkSchemeQuery(preludeOf(node))) continue;
1268
+ for (const child of node.block.children) {
1269
+ if (!isTheme(child) || child.type !== "Atrule" || child.block?.loc == null) continue;
1270
+ edits.push({
1271
+ start: child.loc.start.offset,
1272
+ end: child.block.loc.start.offset,
1273
+ text: ":root"
1274
+ });
1275
+ declarations(child, (property, value) => {
1276
+ dark[property] = value.trim();
1277
+ });
1278
+ }
1279
+ }
1280
+ const defaults = foregroundDefaults(light, dark);
1281
+ const lightAdded = Object.entries(defaults.light);
1282
+ const owned = {
1283
+ ...light,
1284
+ ...defaults.light
1285
+ };
1286
+ const darkAdded = [...Object.entries(defaults.dark), ...Object.entries(owned).filter(([property]) => !(property in dark) && !(property in defaults.dark))];
850
1287
  let output = css;
851
- for (const offset of insertions.reverse()) output = `${output.slice(0, offset)} static${output.slice(offset)}`;
852
- return output;
1288
+ for (const edit of edits.sort((left, right) => right.start - left.start)) output = `${output.slice(0, edit.start)}${edit.text}${output.slice(edit.end)}`;
1289
+ const block = (entries) => entries.map(([property, value]) => ` ${property}: ${value};`).join("\n");
1290
+ return [
1291
+ output,
1292
+ ...lightAdded.length === 0 ? [] : [`@theme static {\n${block(lightAdded)}\n}`],
1293
+ ...darkAdded.length === 0 ? [] : [`${DARK_SCHEME_MEDIA} {\n :root {\n${block(darkAdded)}\n }\n}`]
1294
+ ].join("\n");
853
1295
  };
1296
+ /** The default theme, lowered once: the first layer of every project's theme. */
1297
+ const DEFAULT_THEME_TAILWIND_CSS = lowerThemeCss(DEFAULT_THEME_CSS);
1298
+ /**
1299
+ * What every project compiles beneath its own CSS, for a build that compiles no
1300
+ * Tailwind: the default theme and the primitives' rules, in Tailwind's layers.
1301
+ */
1302
+ const BASE_STYLESHEET_CSS = [
1303
+ "@layer theme, base, components, utilities;",
1304
+ DEFAULT_THEME_TAILWIND_CSS,
1305
+ PRIMITIVE_CSS
1306
+ ].join("\n");
854
1307
  /** Compile the discovered class set into CSS with the pinned Tailwind release. */
855
1308
  const compileTailwind = async (candidates, options = {}, imports = { stylesheets: /* @__PURE__ */ new Map() }) => {
856
1309
  const entry = [
@@ -858,7 +1311,9 @@ const compileTailwind = async (candidates, options = {}, imports = { stylesheets
858
1311
  "@import \"tailwindcss/theme.css\" layer(theme);",
859
1312
  ...options.preflight === true ? ["@import \"tailwindcss/preflight.css\" layer(base);"] : [],
860
1313
  "@import \"tailwindcss/utilities.css\" layer(utilities);",
861
- staticTheme(options.css ?? "")
1314
+ DEFAULT_THEME_TAILWIND_CSS,
1315
+ PRIMITIVE_CSS,
1316
+ lowerThemeCss(options.css ?? "")
862
1317
  ].join("\n");
863
1318
  const compiler = await compile(entry, {
864
1319
  base: projectDirectory(options.cssPath ?? "theme.css"),
@@ -874,7 +1329,7 @@ const compileTailwind = async (candidates, options = {}, imports = { stylesheets
874
1329
  return Promise.resolve({
875
1330
  path: id,
876
1331
  base,
877
- content: staticTheme(imports.brandCss)
1332
+ content: lowerThemeCss(imports.brandCss)
878
1333
  });
879
1334
  }
880
1335
  const path = resolveFromDirectory(base, id.replace(/[?#].*$/, ""));
@@ -883,7 +1338,7 @@ const compileTailwind = async (candidates, options = {}, imports = { stylesheets
883
1338
  return Promise.resolve({
884
1339
  path,
885
1340
  base: projectDirectory(path),
886
- content: staticTheme(content)
1341
+ content: lowerThemeCss(content)
887
1342
  });
888
1343
  }
889
1344
  });
@@ -1018,6 +1473,7 @@ const readStylesheet = (id, source, options, assets = /* @__PURE__ */ new Map(),
1018
1473
  headAtRules: sheet.headAtRules,
1019
1474
  fontFaces: sheet.fontFaces,
1020
1475
  variables: sheet.variables,
1476
+ darkVariables: sheet.darkVariables,
1021
1477
  diagnostics: []
1022
1478
  },
1023
1479
  diagnostics: sheet.diagnostics
@@ -1127,7 +1583,7 @@ const resolveTheme = (files, tailwind, brand, brandPlugin) => {
1127
1583
  let brandCss;
1128
1584
  if (refusals.length === 0 && brand !== void 0 && cssRequests.length > 0) {
1129
1585
  const parsed = parseBrandCss(brand.css);
1130
- if (parsed.isOk()) brandCss = brandTailwindCss(parsed.value);
1586
+ if (parsed.isOk()) brandCss = printBrandCss(parsed.value);
1131
1587
  else {
1132
1588
  const lines = buildLineMap(brand.css);
1133
1589
  const specifier = cssRequests[0].specifier;
@@ -1174,9 +1630,9 @@ const transformProject = async (files, options) => {
1174
1630
  for (const name of reading.classes ?? []) classes.add(name);
1175
1631
  diagnostics.push(...reading.diagnostics);
1176
1632
  }
1177
- if (wanted === void 0 || classes.size === 0 && (wanted.safelist?.length ?? 0) === 0 && wanted.css === void 0) return {
1633
+ if (wanted === void 0) return {
1178
1634
  assets,
1179
- tailwindSheet: void 0,
1635
+ tailwindSheet: readStylesheet(TAILWIND_ORIGIN, BASE_STYLESHEET_CSS, options).sheet,
1180
1636
  stylesheets,
1181
1637
  diagnostics
1182
1638
  };
@@ -1203,7 +1659,7 @@ const brandTheme = (brand, plugin) => {
1203
1659
  message: `${plugin.specifier}: ${finding.message}`,
1204
1660
  origins: []
1205
1661
  })) });
1206
- return brandTailwindCss(parsed.value);
1662
+ return printBrandCss(parsed.value);
1207
1663
  };
1208
1664
  /**
1209
1665
  * Render `BrandFooter` against a brand on its own, for the send path to
@@ -1218,7 +1674,8 @@ const renderBrandFooter = async (brand, options) => {
1218
1674
  const node = brandComponents(brand, plugin).BrandFooter({});
1219
1675
  if (node === null) return {
1220
1676
  html: "",
1221
- text: ""
1677
+ text: "",
1678
+ headCss: ""
1222
1679
  };
1223
1680
  const styled = applyStylesheet(node, parseStylesheet(tailwind.css, { origin: plugin.specifier }));
1224
1681
  const compiled = compileEmail(styled.tree, { brandPlugin: plugin });
@@ -1229,7 +1686,8 @@ const renderBrandFooter = async (brand, options) => {
1229
1686
  const text = (rendered.text ?? "").trim();
1230
1687
  return {
1231
1688
  html: rendered.html ?? "",
1232
- text: text === "" ? "" : `\n\n---\n${text}`
1689
+ text: text === "" ? "" : `\n\n---\n${text}`,
1690
+ headCss: `${renderDarkRules(styled.darkRules)}${styled.headCss}`
1233
1691
  };
1234
1692
  };
1235
1693
  /**