@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.
@@ -1,3 +1,4 @@
1
+ import { r as isDarkSchemeQuery } from "./dark-scheme-CbCwGDWH.js";
1
2
  import { generate, parse, walk } from "css-tree";
2
3
  import { Result } from "better-result";
3
4
  //#region src/email/css-color.ts
@@ -322,14 +323,15 @@ const BRAND_NAMESPACES = [
322
323
  "--text-",
323
324
  "--leading-",
324
325
  "--tracking-",
325
- "--radius-",
326
+ "--radius",
326
327
  "--spacing",
327
328
  "--container-",
328
329
  "--shadow-"
329
330
  ];
331
+ /** `--color-` names a prefix; `--radius` is the variable itself or a `--radius-*` prefix. */
332
+ const inNamespace = (property, namespace) => namespace.endsWith("-") ? property.startsWith(namespace) : property === namespace || property.startsWith(`${namespace}-`);
330
333
  const BRAND_PROPERTY = /^--[a-z][a-z0-9-]*$/;
331
334
  const BRAND_UNSAFE_VALUE = /[{};<>\\@]|url\s*\(|expression\s*\(|\/\*/i;
332
- const BRAND_DARK_MEDIA = /^\(\s*prefers-color-scheme\s*:\s*dark\s*\)$/i;
333
335
  const BRAND_FONT_DESCRIPTORS = /* @__PURE__ */ new Set([
334
336
  "font-family",
335
337
  "src",
@@ -338,8 +340,6 @@ const BRAND_FONT_DESCRIPTORS = /* @__PURE__ */ new Set([
338
340
  "font-display",
339
341
  "unicode-range"
340
342
  ]);
341
- /** The theme variable a dark-mode color lowers to: `--color-brand` → `--color-brand-dark`. */
342
- const darkVariableName = (property) => `${property}-dark`;
343
343
  const spanOfNode = (node) => ({
344
344
  start: node.loc?.start.offset ?? 0,
345
345
  end: node.loc?.end.offset ?? 0
@@ -390,7 +390,7 @@ const parseBrandCss = (css) => {
390
390
  report("brand-css-property", `${property} is not a theme variable; name theme variables --namespace-name.`, span);
391
391
  continue;
392
392
  }
393
- if (!BRAND_NAMESPACES.some((namespace) => property.startsWith(namespace))) {
393
+ if (!BRAND_NAMESPACES.some((namespace) => inNamespace(property, namespace))) {
394
394
  report("brand-css-namespace", `${property} is outside the brand namespaces (${BRAND_NAMESPACES.map((namespace) => namespace.endsWith("-") ? `${namespace}*` : namespace).join(", ")}).`, span);
395
395
  continue;
396
396
  }
@@ -476,7 +476,7 @@ const parseBrandCss = (css) => {
476
476
  readFontFace(node);
477
477
  continue;
478
478
  }
479
- if (name === "media" && node.prelude !== null && BRAND_DARK_MEDIA.test(generate(node.prelude).trim())) {
479
+ if (name === "media" && node.prelude !== null && isDarkSchemeQuery(generate(node.prelude))) {
480
480
  for (const child of node.block.children) if (child.type === "Atrule" && child.name.toLowerCase() === "theme") readTheme(child, dark, "dark");
481
481
  else report("brand-css-unsupported", "The dark-mode block may contain only @theme.", spanOfNode(child));
482
482
  continue;
@@ -484,10 +484,6 @@ const parseBrandCss = (css) => {
484
484
  }
485
485
  report("brand-css-unsupported", "A brand theme contains only @theme, a dark-mode @theme (@media (prefers-color-scheme: dark)), and @font-face.", spanOfNode(node));
486
486
  }
487
- for (const property of Object.keys(dark)) if (darkVariableName(property) in variables) report("brand-css-dark-conflict", `${darkVariableName(property)} and the dark-mode ${property} name the same theme variable; keep one.`, {
488
- start: 0,
489
- end: 0
490
- });
491
487
  return diagnostics.length > 0 ? Result.err(diagnostics) : Result.ok({
492
488
  variables,
493
489
  dark,
@@ -518,9 +514,92 @@ const printBrandCss = (brand) => {
518
514
  ];
519
515
  return blocks.length === 0 ? "" : `${blocks.join("\n\n")}\n`;
520
516
  };
521
- /** The brand color, and the text color that sits on it. */
522
- const BRAND_COLOR_VARIABLE = "--color-brand";
523
- const BRAND_FOREGROUND_VARIABLE = "--color-brand-foreground";
517
+ /**
518
+ * The semantic color tokens, as Tailwind color names (`bg-card`,
519
+ * `text-primary-foreground`, `border-border`), each with the role a block uses
520
+ * it for. A block reads only these, so swapping the theme reskins it.
521
+ */
522
+ const THEME_COLOR_ROLES = {
523
+ background: "The page, behind everything.",
524
+ foreground: "Text on the page.",
525
+ card: "A surface that groups the message's content.",
526
+ "card-foreground": "Text on a card.",
527
+ primary: "The main action's fill (a button), and links.",
528
+ "primary-foreground": "Text on a primary fill.",
529
+ secondary: "A quieter action's fill.",
530
+ "secondary-foreground": "Text on a secondary fill.",
531
+ muted: "A subdued surface: a code block, a table header.",
532
+ "muted-foreground": "Secondary text on the page, a card or a muted surface: labels, captions, the footer.",
533
+ accent: "A highlighted surface: a callout, a selected row.",
534
+ "accent-foreground": "Text on an accent surface.",
535
+ border: "Rules, dividers and outlines."
536
+ };
537
+ const THEME_COLORS = Object.keys(THEME_COLOR_ROLES);
538
+ /**
539
+ * The non-color tokens: the corner radius (`rounded`; `rounded-sm` to `rounded-xl` follow it) and
540
+ * the body and heading families (`font-sans`, `font-heading`).
541
+ */
542
+ const THEME_TOKENS = [
543
+ "--radius",
544
+ "--font-sans",
545
+ "--font-heading"
546
+ ];
547
+ const colorVariables = (colors) => Object.fromEntries(THEME_COLORS.map((name) => [`--color-${name}`, colors[name]]));
548
+ /**
549
+ * The theme every project compiles with, beneath its own: a neutral light and
550
+ * dark palette, an 8px radius and the system sans-serif stack. A project's
551
+ * `theme.css`, its imported stylesheets and its brand layer over it.
552
+ */
553
+ const DEFAULT_THEME = {
554
+ variables: {
555
+ ...colorVariables({
556
+ background: "#ffffff",
557
+ foreground: "#0a0a0a",
558
+ card: "#ffffff",
559
+ "card-foreground": "#0a0a0a",
560
+ primary: "#171717",
561
+ "primary-foreground": "#fafafa",
562
+ secondary: "#f5f5f5",
563
+ "secondary-foreground": "#171717",
564
+ muted: "#f5f5f5",
565
+ "muted-foreground": "#737373",
566
+ accent: "#f5f5f5",
567
+ "accent-foreground": "#171717",
568
+ border: "#e5e5e5"
569
+ }),
570
+ "--radius": "8px",
571
+ "--radius-sm": "calc(var(--radius) - 4px)",
572
+ "--radius-md": "calc(var(--radius) - 2px)",
573
+ "--radius-lg": "var(--radius)",
574
+ "--radius-xl": "calc(var(--radius) + 4px)",
575
+ "--font-sans": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif",
576
+ "--font-heading": "var(--font-sans)"
577
+ },
578
+ dark: colorVariables({
579
+ background: "#0a0a0a",
580
+ foreground: "#fafafa",
581
+ card: "#171717",
582
+ "card-foreground": "#fafafa",
583
+ primary: "#fafafa",
584
+ "primary-foreground": "#171717",
585
+ secondary: "#262626",
586
+ "secondary-foreground": "#fafafa",
587
+ muted: "#262626",
588
+ "muted-foreground": "#a3a3a3",
589
+ accent: "#262626",
590
+ "accent-foreground": "#fafafa",
591
+ border: "#2e2e2e"
592
+ }),
593
+ fontFaces: []
594
+ };
595
+ /** `DEFAULT_THEME` as theme CSS. */
596
+ const DEFAULT_THEME_CSS = printBrandCss(DEFAULT_THEME);
597
+ /** The fills whose text color a brand may leave out: `--color-primary` and `--color-primary-foreground`, and so on. */
598
+ const FILL_COLORS = [
599
+ "primary",
600
+ "secondary",
601
+ "accent"
602
+ ];
524
603
  /** WCAG relative luminance of an sRGB channel triple in 0–1. */
525
604
  const relativeLuminance = (r, g, b) => {
526
605
  const linear = (channel) => {
@@ -545,41 +624,24 @@ const contrastForeground = (color) => {
545
624
  return 1.05 / (luminance + .05) > (luminance + .05) / .05 ? "#ffffff" : "#000000";
546
625
  };
547
626
  /**
548
- * The text-on-brand colors a brand gets when it leaves them out: for the light
549
- * theme and, when the brand sets a dark-mode brand color, for dark mode. Each
550
- * is the `contrastForeground` of the brand color it sits on. A brand that sets
551
- * the foreground, or whose brand color does not evaluate, gets no default.
552
- */
553
- const brandForegroundDefaults = (brand) => {
554
- const light = brand.variables["--color-brand-foreground"] === void 0 && brand.variables["--color-brand"] !== void 0 ? contrastForeground(brand.variables[BRAND_COLOR_VARIABLE]) : void 0;
555
- const darkBrand = brand.dark[BRAND_COLOR_VARIABLE];
556
- return {
557
- light,
558
- dark: darkBrand !== void 0 && brand.dark["--color-brand-foreground"] === void 0 && brand.variables[darkVariableName("--color-brand-foreground")] === void 0 ? contrastForeground(darkBrand) : void 0
559
- };
560
- };
561
- /**
562
- * A brand theme as flat theme variables, the way a build compiles it. Email
563
- * inlining resolves `var()` once, so a dark-mode `@theme` cannot switch a
564
- * value at run time; each dark color lowers to its `-dark` theme variable,
565
- * which `dark:` utilities read (`dark:bg-brand-dark`). A text-on-brand color
566
- * the brand leaves out is filled in by `brandForegroundDefaults`, so every
567
- * build compiles the same default and the stored CSS never carries it.
627
+ * The text colors a theme layer gets for the fills it sets without one: for
628
+ * each of `FILL_COLORS`, the `contrastForeground` of the fill, in light mode
629
+ * and, where the layer's dark-mode block sets the fill, in dark mode. A layer
630
+ * that sets the foreground, or whose fill does not evaluate, gets no default.
631
+ * Every build lowers each theme layer with these, so stored theme CSS never
632
+ * carries them.
568
633
  */
569
- const brandThemeVariables = (brand) => {
570
- const defaults = brandForegroundDefaults(brand);
634
+ const foregroundDefaults = (light, dark) => {
635
+ const defaults = (colors) => Object.fromEntries(FILL_COLORS.flatMap((fill) => {
636
+ const color = colors[`--color-${fill}`];
637
+ const foreground = `--color-${fill}-foreground`;
638
+ const picked = color === void 0 || colors[foreground] !== void 0 ? void 0 : contrastForeground(color);
639
+ return picked === void 0 ? [] : [[foreground, picked]];
640
+ }));
571
641
  return {
572
- ...brand.variables,
573
- ...defaults.light === void 0 ? {} : { [BRAND_FOREGROUND_VARIABLE]: defaults.light },
574
- ...Object.fromEntries(Object.entries(brand.dark).map(([property, value]) => [darkVariableName(property), value])),
575
- ...defaults.dark === void 0 ? {} : { [darkVariableName(BRAND_FOREGROUND_VARIABLE)]: defaults.dark }
642
+ light: defaults(light),
643
+ dark: defaults(dark)
576
644
  };
577
645
  };
578
- /** The stylesheet Tailwind compiles for the theme's brand import: `brandThemeVariables` and the font faces. */
579
- const brandTailwindCss = (brand) => printBrandCss({
580
- fontFaces: brand.fontFaces,
581
- variables: brandThemeVariables(brand),
582
- dark: {}
583
- });
584
646
  //#endregion
585
- export { brandThemeVariables as a, parseBrandCss as c, formatColor as d, parseColor as f, brandTailwindCss as i, printBrandCss as l, BRAND_FOREGROUND_VARIABLE as n, contrastForeground as o, brandForegroundDefaults as r, darkVariableName as s, BRAND_COLOR_VARIABLE as t, downlevelColors as u };
647
+ export { THEME_COLOR_ROLES as a, foregroundDefaults as c, downlevelColors as d, formatColor as f, THEME_COLORS as i, parseBrandCss as l, DEFAULT_THEME_CSS as n, THEME_TOKENS as o, parseColor as p, FILL_COLORS as r, contrastForeground as s, DEFAULT_THEME as t, printBrandCss as u };
@@ -164,7 +164,14 @@
164
164
  {
165
165
  "name": "backgroundColor",
166
166
  "type": "string",
167
- "optional": true
167
+ "optional": true,
168
+ "description": "The page color, painted across the whole body and, as `bgcolor`, on the wrapper table for clients that drop body styles. Defaults to the theme's `--color-background`, which switches in dark mode."
169
+ },
170
+ {
171
+ "name": "gutter",
172
+ "type": "number",
173
+ "optional": true,
174
+ "description": "Pixels of space each side of the content, so it never touches the screen edge on a narrow viewport. Defaults to 16; `0` lets content run edge to edge."
168
175
  },
169
176
  {
170
177
  "name": "style",
@@ -197,7 +204,8 @@
197
204
  {
198
205
  "name": "width",
199
206
  "type": "number | string",
200
- "optional": true
207
+ "optional": true,
208
+ "description": "The band's width. A pixel width (`600`) is a maximum: the band fills a narrower screen, and classic Outlook, which ignores `max-width`, draws it at that width."
201
209
  },
202
210
  {
203
211
  "name": "bgcolor",
@@ -259,6 +267,18 @@
259
267
  "optional": true,
260
268
  "description": "With `each`, the cells in a row; each chunk of items is its own `Columns` table."
261
269
  },
270
+ {
271
+ "name": "stack",
272
+ "type": "boolean",
273
+ "optional": true,
274
+ "description": "Stack the cells into one column, each full width, on a viewport narrower than 600px. On by default; `stack={false}` keeps a row of small cells (icons, a price beside a label) side by side. Clients that ignore media queries keep the row."
275
+ },
276
+ {
277
+ "name": "gap",
278
+ "type": "number",
279
+ "optional": true,
280
+ "description": "Pixels between the cells of a row. A stacked row puts the same space between the stacked cells instead, so a gap needs no padding on the cells that would misalign them when stacked. The cells must be written out and always render."
281
+ },
262
282
  {
263
283
  "name": "children",
264
284
  "type": "EmailChild | ((item: Item, index: number) => EmailChild)",
@@ -1274,10 +1294,143 @@
1274
1294
  "styles": {
1275
1295
  "dialect": "Tailwind classes plus theme.css tokens. Classes must be literal.",
1276
1296
  "themeLayers": [
1297
+ "the default theme",
1277
1298
  "@import \"./starter.css\";",
1278
1299
  "@import \"samva:brand\";",
1279
1300
  "the project's own @theme"
1280
1301
  ],
1302
+ "theme": {
1303
+ "colors": [
1304
+ {
1305
+ "name": "background",
1306
+ "variable": "--color-background",
1307
+ "role": "The page, behind everything.",
1308
+ "light": "#ffffff",
1309
+ "dark": "#0a0a0a"
1310
+ },
1311
+ {
1312
+ "name": "foreground",
1313
+ "variable": "--color-foreground",
1314
+ "role": "Text on the page.",
1315
+ "light": "#0a0a0a",
1316
+ "dark": "#fafafa"
1317
+ },
1318
+ {
1319
+ "name": "card",
1320
+ "variable": "--color-card",
1321
+ "role": "A surface that groups the message's content.",
1322
+ "light": "#ffffff",
1323
+ "dark": "#171717"
1324
+ },
1325
+ {
1326
+ "name": "card-foreground",
1327
+ "variable": "--color-card-foreground",
1328
+ "role": "Text on a card.",
1329
+ "light": "#0a0a0a",
1330
+ "dark": "#fafafa"
1331
+ },
1332
+ {
1333
+ "name": "primary",
1334
+ "variable": "--color-primary",
1335
+ "role": "The main action's fill (a button), and links.",
1336
+ "light": "#171717",
1337
+ "dark": "#fafafa"
1338
+ },
1339
+ {
1340
+ "name": "primary-foreground",
1341
+ "variable": "--color-primary-foreground",
1342
+ "role": "Text on a primary fill.",
1343
+ "light": "#fafafa",
1344
+ "dark": "#171717"
1345
+ },
1346
+ {
1347
+ "name": "secondary",
1348
+ "variable": "--color-secondary",
1349
+ "role": "A quieter action's fill.",
1350
+ "light": "#f5f5f5",
1351
+ "dark": "#262626"
1352
+ },
1353
+ {
1354
+ "name": "secondary-foreground",
1355
+ "variable": "--color-secondary-foreground",
1356
+ "role": "Text on a secondary fill.",
1357
+ "light": "#171717",
1358
+ "dark": "#fafafa"
1359
+ },
1360
+ {
1361
+ "name": "muted",
1362
+ "variable": "--color-muted",
1363
+ "role": "A subdued surface: a code block, a table header.",
1364
+ "light": "#f5f5f5",
1365
+ "dark": "#262626"
1366
+ },
1367
+ {
1368
+ "name": "muted-foreground",
1369
+ "variable": "--color-muted-foreground",
1370
+ "role": "Secondary text on the page, a card or a muted surface: labels, captions, the footer.",
1371
+ "light": "#737373",
1372
+ "dark": "#a3a3a3"
1373
+ },
1374
+ {
1375
+ "name": "accent",
1376
+ "variable": "--color-accent",
1377
+ "role": "A highlighted surface: a callout, a selected row.",
1378
+ "light": "#f5f5f5",
1379
+ "dark": "#262626"
1380
+ },
1381
+ {
1382
+ "name": "accent-foreground",
1383
+ "variable": "--color-accent-foreground",
1384
+ "role": "Text on an accent surface.",
1385
+ "light": "#171717",
1386
+ "dark": "#fafafa"
1387
+ },
1388
+ {
1389
+ "name": "border",
1390
+ "variable": "--color-border",
1391
+ "role": "Rules, dividers and outlines.",
1392
+ "light": "#e5e5e5",
1393
+ "dark": "#2e2e2e"
1394
+ }
1395
+ ],
1396
+ "tokens": [
1397
+ {
1398
+ "variable": "--radius",
1399
+ "default": "8px"
1400
+ },
1401
+ {
1402
+ "variable": "--font-sans",
1403
+ "default": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif"
1404
+ },
1405
+ {
1406
+ "variable": "--font-heading",
1407
+ "default": "var(--font-sans)"
1408
+ }
1409
+ ],
1410
+ "darkMode": "One mechanism: the theme's @media (prefers-color-scheme: dark) { @theme { --color-*: … } } block. Write each token's utility once (bg-card); every declaration that reads a color the dark block sets switches in dark mode. Use dark: only to pick a different token in dark mode. Opacity modifiers (bg-primary/10) keep the light value.",
1411
+ "layering": "Each layer owns the colors it sets: a color a layer sets in light mode and not in its dark block keeps that value in dark mode.",
1412
+ "fillForegrounds": "A layer that sets --color-primary, --color-secondary, --color-accent without its -foreground gets black or white, whichever contrasts more, in each mode."
1413
+ },
1414
+ "contrast": {
1415
+ "textError": 3,
1416
+ "textWarning": 4.5,
1417
+ "largePx": 24,
1418
+ "largeBoldPx": 18.5,
1419
+ "shapeError": 1.5,
1420
+ "shapeWarning": 3,
1421
+ "border": 3
1422
+ },
1423
+ "primitives": {
1424
+ "classes": {
1425
+ "email": "sml-email",
1426
+ "button": "sml-button",
1427
+ "link": "sml-link",
1428
+ "divider": "sml-divider",
1429
+ "stack": "sml-stack"
1430
+ },
1431
+ "stackBelowPx": 600,
1432
+ "defaults": "Primitive defaults sit beneath utilities: a class on a primitive overrides its default; a prop is an inline style and overrides both."
1433
+ },
1281
1434
  "fonts": "WOFF2 files in the project through @font-face; a stack must end in a generic family."
1282
1435
  },
1283
1436
  "channels": {
@@ -1367,6 +1520,12 @@
1367
1520
  "summary": "A prop that sizes layout at compile time is not a number literal.",
1368
1521
  "fix": "Write the number literally, for example height={44}."
1369
1522
  },
1523
+ {
1524
+ "code": "email-contrast",
1525
+ "summary": "Text, or a filled button against its surroundings, falls below the contrast ratio in light or dark mode (below 1.5:1 a button is indistinguishable from its surface; WCAG 1.4.11 asks 3:1).",
1526
+ "fix": "Pair background and text tokens (bg-primary with text-primary-foreground), give the theme's dark-mode @theme a value for the token, or choose colors that reach the required ratio.",
1527
+ "upgrade": "Move the project's theme onto the theme vocabulary with one dark-mode block. In starter.css and theme.css, rename each color token to the vocabulary name for its role (reference.md, Theme): a page color to --color-background, the main text to --color-foreground, a surface to --color-card with --color-card-foreground, the brand or action color to --color-primary with --color-primary-foreground, subdued text to --color-muted-foreground, a highlight to --color-accent with --color-accent-foreground, rules to --color-border, body and heading fonts to --font-sans and --font-heading, and a corner radius to --radius. Keep every value.\n\nMove each --color-<name>-dark value into @media (prefers-color-scheme: dark) { @theme { --color-<name>: <value>; } } under the renamed token, and delete the -dark tokens. In every template and partial, rename the utilities to match (bg-surface to bg-card, text-muted to text-muted-foreground, and so on) and delete each dark:…-dark class whose token now has a dark value; keep a dark: class only where it picks a different token in dark mode.\n\nThen fix what email-contrast still reports at its location: pair each fill with its -foreground token, or give the dark block a value that reaches the ratio. Do not weaken the design to pass: keep the brand's colors and adjust the paired token.\n\nCompile every entry and render every fixture in light and dark mode; confirm the light render is unchanged apart from fixes. Resolve errors before requesting one saved version for the whole project, approved by the user; do not publish."
1528
+ },
1370
1529
  {
1371
1530
  "code": "email-font-fallback",
1372
1531
  "summary": "A font stack names a project font without a generic fallback.",
@@ -1453,11 +1612,21 @@
1453
1612
  "summary": "<Columns each> is not a list from the input.",
1454
1613
  "fix": "Write each={input.items} with a callback returning <Column>."
1455
1614
  },
1615
+ {
1616
+ "code": "invalid-columns-gap",
1617
+ "summary": "<Columns gap> is set on a Columns whose cells are repeated with each or rendered conditionally.",
1618
+ "fix": "Write each Column out unconditionally, or pad the cells instead of using gap."
1619
+ },
1456
1620
  {
1457
1621
  "code": "invalid-columns-per",
1458
1622
  "summary": "<Columns per> is not a positive integer literal.",
1459
1623
  "fix": "Write per={2}."
1460
1624
  },
1625
+ {
1626
+ "code": "invalid-columns-stack",
1627
+ "summary": "<Columns stack> is not a true or false literal.",
1628
+ "fix": "Write stack={false}, or leave it out to stack on narrow screens."
1629
+ },
1461
1630
  {
1462
1631
  "code": "invalid-content",
1463
1632
  "summary": "An element holds content its content model does not allow.",
@@ -1473,6 +1642,11 @@
1473
1642
  "summary": "The template's default locale is not a string literal.",
1474
1643
  "fix": "Write locale: \"en-US\"."
1475
1644
  },
1645
+ {
1646
+ "code": "invalid-number-prop",
1647
+ "summary": "A primitive's numeric prop, such as <Email gutter>, is not a finite number in range.",
1648
+ "fix": "Pass a finite number the message allows, for example gutter={16}."
1649
+ },
1476
1650
  {
1477
1651
  "code": "invalid-preheader",
1478
1652
  "summary": "The preheader is not a string.",