@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.
@@ -52,43 +52,46 @@ There is no escape hatch: compute the value where the message is sent and add it
52
52
 
53
53
  Import from `@samva/markup/email`.
54
54
 
55
- | Prop | Type | Notes |
56
- | ------------------ | ---------------- | -------------------------------------------------------------------- |
57
- | `className?` | `string` | CSS classes applied to the primitive's content element. |
58
- | `lang?` | `string` | |
59
- | `dir?` | `"ltr" \| "rtl"` | |
60
- | `title?` | `string` | Document title; several clients use it as the fallback preview line. |
61
- | `backgroundColor?` | `string` | |
62
- | `style?` | `EmailStyle` | Applied to the content cell, where Yahoo and AOL keep body styling. |
63
- | `children?` | `EmailChild` | |
55
+ | Prop | Type | Notes |
56
+ | ------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | `className?` | `string` | CSS classes applied to the primitive's content element. |
58
+ | `lang?` | `string` | |
59
+ | `dir?` | `"ltr" \| "rtl"` | |
60
+ | `title?` | `string` | Document title; several clients use it as the fallback preview line. |
61
+ | `backgroundColor?` | `string` | 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. |
62
+ | `gutter?` | `number` | 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. |
63
+ | `style?` | `EmailStyle` | Applied to the content cell, where Yahoo and AOL keep body styling. |
64
+ | `children?` | `EmailChild` | |
64
65
 
65
66
  ### Section
66
67
 
67
68
  Import from `@samva/markup/email`.
68
69
 
69
- | Prop | Type | Notes |
70
- | ------------- | ------------------------------- | -------------------------------------------------------------------- |
71
- | `className?` | `string` | CSS classes applied to the primitive's content element. |
72
- | `align?` | `"left" \| "center" \| "right"` | |
73
- | `width?` | `number \| string` | |
74
- | `bgcolor?` | `string` | |
75
- | `style?` | `EmailStyle` | Applied to the content cell so padding survives Outlook and Klaviyo. |
76
- | `tableStyle?` | `EmailStyle` | |
77
- | `children?` | `EmailChild` | |
70
+ | Prop | Type | Notes |
71
+ | ------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `className?` | `string` | CSS classes applied to the primitive's content element. |
73
+ | `align?` | `"left" \| "center" \| "right"` | |
74
+ | `width?` | `number \| string` | 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. |
75
+ | `bgcolor?` | `string` | |
76
+ | `style?` | `EmailStyle` | Applied to the content cell so padding survives Outlook and Klaviyo. |
77
+ | `tableStyle?` | `EmailStyle` | |
78
+ | `children?` | `EmailChild` | |
78
79
 
79
80
  ### Columns
80
81
 
81
82
  Import from `@samva/markup/email`.
82
83
 
83
- | Prop | Type | Notes |
84
- | ------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
- | `className?` | `string` | CSS classes applied to the primitive's content element. |
86
- | `align?` | `"left" \| "center" \| "right"` | |
87
- | `width?` | `number \| string` | |
88
- | `style?` | `EmailStyle` | |
89
- | `each?` | `readonly Item[]` | Repeat the cells for each item: `children` is then a function from an item (and its index in the row) to a `Column`. Without `per` all items share one row. |
90
- | `per?` | `number` | With `each`, the cells in a row; each chunk of items is its own `Columns` table. |
91
- | `children?` | `EmailChild \| ((item: Item, index: number) => EmailChild)` | `Column` elements, or with `each` a function that returns one. |
84
+ | Prop | Type | Notes |
85
+ | ------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `className?` | `string` | CSS classes applied to the primitive's content element. |
87
+ | `align?` | `"left" \| "center" \| "right"` | |
88
+ | `width?` | `number \| string` | |
89
+ | `style?` | `EmailStyle` | |
90
+ | `each?` | `readonly Item[]` | Repeat the cells for each item: `children` is then a function from an item (and its index in the row) to a `Column`. Without `per` all items share one row. |
91
+ | `per?` | `number` | With `each`, the cells in a row; each chunk of items is its own `Columns` table. |
92
+ | `stack?` | `boolean` | 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. |
93
+ | `gap?` | `number` | 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. |
94
+ | `children?` | `EmailChild \| ((item: Item, index: number) => EmailChild)` | `Column` elements, or with `each` a function that returns one. |
92
95
 
93
96
  ### Column
94
97
 
@@ -191,6 +194,44 @@ Import from `samva:brand`.
191
194
  | `unsubscribeLabel?` | `string` | Text of the unsubscribe link. |
192
195
  | `className?` | `string` | |
193
196
 
197
+ ## Theme
198
+
199
+ Every project compiles over a default theme in this vocabulary. `starter.css`, a brand
200
+ (`@import "samva:brand";`) and the project's own `@theme` in `theme.css` layer over it, each
201
+ setting the same names. A block that reads only these utilities restyles with the theme.
202
+
203
+ | Token | Utilities | Role | Default light | Default dark |
204
+ | ------------------------------ | --------------------------- | ------------------------------------------------------------------------------------ | ------------- | ------------ |
205
+ | `--color-background` | `bg-background` | The page, behind everything. | `#ffffff` | `#0a0a0a` |
206
+ | `--color-foreground` | `text-foreground` | Text on the page. | `#0a0a0a` | `#fafafa` |
207
+ | `--color-card` | `bg-card` | A surface that groups the message's content. | `#ffffff` | `#171717` |
208
+ | `--color-card-foreground` | `text-card-foreground` | Text on a card. | `#0a0a0a` | `#fafafa` |
209
+ | `--color-primary` | `bg-primary` | The main action's fill (a button), and links. | `#171717` | `#fafafa` |
210
+ | `--color-primary-foreground` | `text-primary-foreground` | Text on a primary fill. | `#fafafa` | `#171717` |
211
+ | `--color-secondary` | `bg-secondary` | A quieter action's fill. | `#f5f5f5` | `#262626` |
212
+ | `--color-secondary-foreground` | `text-secondary-foreground` | Text on a secondary fill. | `#171717` | `#fafafa` |
213
+ | `--color-muted` | `bg-muted` | A subdued surface: a code block, a table header. | `#f5f5f5` | `#262626` |
214
+ | `--color-muted-foreground` | `text-muted-foreground` | Secondary text on the page, a card or a muted surface: labels, captions, the footer. | `#737373` | `#a3a3a3` |
215
+ | `--color-accent` | `bg-accent` | A highlighted surface: a callout, a selected row. | `#f5f5f5` | `#262626` |
216
+ | `--color-accent-foreground` | `text-accent-foreground` | Text on an accent surface. | `#171717` | `#fafafa` |
217
+ | `--color-border` | `border-border` | Rules, dividers and outlines. | `#e5e5e5` | `#2e2e2e` |
218
+
219
+ | Token | Utilities | Default |
220
+ | ---------------- | ---------------------------------- | ------------------------------------------------------------------------------------- |
221
+ | `--radius` | `rounded, rounded-sm … rounded-xl` | `8px` |
222
+ | `--font-sans` | `font-sans` | `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif` |
223
+ | `--font-heading` | `font-heading` | `var(--font-sans)` |
224
+
225
+ - Dark mode: 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.
226
+ - Layers: 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.
227
+ - Fills: A layer that sets --color-primary, --color-secondary, --color-accent without its -foreground gets black or white, whichever contrasts more, in each mode.
228
+ - Primitives: Primitive defaults sit beneath utilities: a class on a primitive overrides its default; a prop is an inline style and overrides both. `Email` paints `--color-background` and pads
229
+ its content 16px each side (`gutter`); `Columns` stacks its cells below 600px unless
230
+ `stack={false}`.
231
+ - Contrast: `email-contrast` checks every fixture in light and dark mode. Text below
232
+ 3:1 and a filled button below 1.5:1 against its surroundings are errors;
233
+ text under 24px (18.5px bold) below 4.5:1 and a button below 3:1 are warnings.
234
+
194
235
  ## HTML elements
195
236
 
196
237
  - `html`: flow content; attributes: `xmlns`, `xmlns:o`, `xmlns:v`, `xmlns:w`.
@@ -246,92 +287,106 @@ Global attributes: `id`, `title`, `lang`, `dir`, `role`.
246
287
 
247
288
  ## Diagnostics
248
289
 
249
- | Code | Meaning | Fix |
250
- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
251
- | `brand-import-conflict` | The project imports two different brands. | Import one brand specifier everywhere. |
252
- | `brand-unavailable` | The build has no brand to resolve a brand import. | Create or select the brand the import names. |
253
- | `button-dynamic-label` | A Button label is read at render time, so classic Outlook cannot size it. | Give the button a width: <Button width={200}>. |
254
- | `button-outlook-fallback` | Classic Outlook needs a height and a plain string label to draw the button. | Give <Button> a height and a string label. |
255
- | `caniemail/unknown-coverage` | A feature has no client-support data. | Verify it in the target clients. |
256
- | `css-conditional-variable` | A custom property is redefined inside a conditional at-rule. | Keep the override on a class rule. |
257
- | `css-import-unresolved` | A stylesheet @import cannot be resolved. | Import the stylesheet from the template module, or use the theme.css layers. |
258
- | `css-parse-error` | A stylesheet does not parse. | Fix the CSS at the reported location. |
259
- | `css-unsupported-at-rule` | An at-rule is not applied to email output. | Remove it or move the declarations into a class rule. |
260
- | `dangerous-html` | dangerouslySetInnerHTML; there is no raw-HTML escape hatch. | Write the markup as JSX. |
261
- | `duplicate-attribute` | An attribute is written twice. | Keep one. |
262
- | `dynamic-class` | A class name is built at run time, and Tailwind cannot see it. | Write classes literally, or choose between literal lists: input.vip ? "bg-amber-100" : "bg-white". |
263
- | `dynamic-definition` | A definition field is spread or computed instead of written out. | Write each field as name: value. |
264
- | `dynamic-expression` | An expression outside the static profile: a call other than fmt.* and .map, a computed value, or a spread. | Compute the value where the message is sent and bind it from the payload. |
265
- | `dynamic-numeric-prop` | A prop that sizes layout at compile time is not a number literal. | Write the number literally, for example height={44}. |
266
- | `email-font-fallback` | A font stack names a project font without a generic fallback. | End the stack with sans-serif, serif or monospace. |
267
- | `email-font-family` | @font-face declares no font-family. | Add font-family. |
268
- | `email-font-format` | @font-face uses a format other than WOFF2. | Convert the file to .woff2. |
269
- | `email-font-missing` | @font-face names a file the project does not hold. | Add the file; paths resolve from the stylesheet's directory. |
270
- | `email-font-src` | @font-face declares no usable url() src. | Add a url() pointing at a .woff2 file. |
271
- | `event-handler` | An event handler; email clients strip scripting. | Remove the handler; use a link for interaction. |
272
- | `fixture-invalid` | A fixture does not match the schema. | Change the fixture, or the schema, so every fixture is a payload a send could carry. |
273
- | `fixtures-not-static` | A fixture is computed instead of written as data. | Write each fixture as a literal object; an imported asset is allowed. |
274
- | `formatter-arguments` | A formatter got the wrong number of arguments. | Match the formatter's signature in the contract. |
275
- | `hook-call` | A hook was called; templates have no state, effects or context. | Read the input through the function's parameter and send anything else in the payload. |
276
- | `invalid-asset-base` | The asset base is not an https URL or a path. | Use an https URL, an absolute path, or a relative path. |
277
- | `invalid-attribute-value` | An attribute value is not a string or number, or contains control characters. | Pass plain text. |
278
- | `invalid-binding-type` | A binding has the wrong type for where it is used. | Bind a field of the type the position needs, or send it in that type. |
279
- | `invalid-button-number` | A Button size prop is not a finite number. | Pass a finite number. |
280
- | `invalid-channel` | A channel field is not shaped as the channel requires. | Write email: { subject, preheader?, body } with each a function of the input. |
281
- | `invalid-columns-each` | <Columns each> is not a list from the input. | Write each={input.items} with a callback returning <Column>. |
282
- | `invalid-columns-per` | <Columns per> is not a positive integer literal. | Write per={2}. |
283
- | `invalid-content` | An element holds content its content model does not allow. | Move the child to a parent that accepts it. |
284
- | `invalid-fixture-name` | A fixture key is not a single path segment. | Use letters, digits, '.', '_' and '-', starting with a letter or digit. |
285
- | `invalid-locale` | The template's default locale is not a string literal. | Write locale: "en-US". |
286
- | `invalid-preheader` | The preheader is not a string. | Return a string. |
287
- | `invalid-schema` | The schema uses JSON Schema keywords the compiler cannot validate against. | Use only the JSON Schema keywords the package README lists. |
288
- | `invalid-style` | A style prop is not a static property object. | Write style={{ backgroundColor: "#fff" }} with literal values, or use classes. |
289
- | `invalid-style-value` | A style value is not a string or number. | Pass a string or number. |
290
- | `invalid-subject` | The subject is not one line of text. | Return a single-line string. |
291
- | `invalid-template-id` | The template id is not a lowercase kebab-case string literal. | Write id: "order-shipped". |
292
- | `invalid-text` | The plain-text body is not a string. | Return a string. |
293
- | `legacy-definition` | The entry default-exports something other than defineTemplate. | Import { defineTemplate } from "@samva/markup" and default-export defineTemplate({ ... }). |
294
- | `missing-attribute` | An element is missing a required attribute. | Add the attribute, for example alt on img. |
295
- | `missing-body` | The email channel has no body. | Add body: (input) => (<Email>...</Email>). |
296
- | `missing-fixtures` | The template declares no fixtures. | Add fixtures: { default: { ... } } with at least one valid payload. |
297
- | `missing-head` | Styles that cannot be inlined need a <head> to live in. | Wrap the message in <Email>, or write those declarations inline. |
298
- | `missing-schema` | The template declares no input schema. | Add schema: jsonSchema<Input>({ ... }). |
299
- | `missing-subject` | The email channel has no subject. | Add subject: (input) => .... |
300
- | `moved-import` | The file imports an @samva/markup entry that moved. | Import email components from "@samva/markup/email". |
301
- | `multiple-roots` | The body renders nothing or more than one root. | Wrap the body in a single element such as <Email>. |
302
- | `nesting-limit` | The template nests deeper than the compiler reads. | Flatten the structure. |
303
- | `no-channel` | The template declares no channel body. | Add an email body. |
304
- | `no-template` | The entry does not default-export a template. | Default-export defineTemplate({ ... }) imported from @samva/markup. |
305
- | `non-project-import` | The file imports something other than @samva/markup or a project file. | Remove the import, or copy the code you need into a project file. |
306
- | `not-a-function` | A channel field is not a function of the input. | Write (input) => ... in place or declare the function in the project. |
307
- | `preheader-without-shell` | A preheader is set but the body has no <Email> shell to hold it. | Wrap the body in <Email>. |
308
- | `recursive-partial` | A partial renders itself; partials are inlined at compile time. | Flatten the recursion, or repeat the structure with .map. |
309
- | `reserved-attribute` | An attribute is written only by a brand component. | Render BrandFooter from the brand import instead of marking an element as the footer. |
310
- | `reserved-character` | Text contains a private-use character the compiler reserves. | Remove the characters U+E000 to U+E003. |
311
- | `reserved-url` | A URL is the unsubscribe placeholder only BrandFooter can write. | Render <BrandFooter /> instead. |
312
- | `schema-not-static` | The schema cannot be read without running code. | Write the schema as a literal jsonSchema<Input>({ type: 'object', ... }). |
313
- | `statement-in-body` | A body contains a statement (const, if, a loop); a body is one expression. | Use cond && <X />, cond ? <A /> : <B /> and .map, or send the computed value in the payload. |
314
- | `syntax-error` | A project file does not parse as TSX. | Fix the syntax at the reported location. |
315
- | `tailwind-dynamic-class` | A class list is assembled at run time. | Write the classes literally. |
316
- | `too-many-class-variants` | A class list chooses between more literal lists than the compiler resolves. | Split the choice into fewer conditions, or move part of it to a partial. |
317
- | `unguarded-optional` | An optional field is read outside a guard, so a send without it would render a blank. | Guard it with {input.field && ...}, or make the field required. |
318
- | `unknown-component` | A component the profile cannot read. | Import components from @samva/markup/email, the brand import, or the project. |
319
- | `unknown-field` | A binding names a field the schema does not declare. | Add the field to the schema, or bind a field it declares (the message suggests the nearest). |
320
- | `unknown-formatter` | fmt.<name> is not a formatter. | Use fmt.money, number, date, time, plural or list. |
321
- | `unmatched-class` | A class produced no styles. | Use a class the theme or Tailwind defines, or write the style inline. |
322
- | `unresolved-import` | An import names no file in the project. | Check the path and the file extension. |
323
- | `unsafe-raw-text` | Raw text content contains markup that could end its element. | Remove the offending sequence. |
324
- | `unsafe-style-value` | A style value contains CSS that could break out of its declaration. | Remove the offending characters. |
325
- | `unsafe-url` | A URL uses a scheme email cannot send safely. | Use an https URL, a mailto: or tel: link, or a bound URL. |
326
- | `unsupported-attribute` | An attribute the element does not support. | Use a supported attribute, or data-* and aria-*. |
327
- | `unsupported-element` | An element outside the supported email vocabulary. | Use an element from the contract's element table. |
328
- | `unsupported-pattern` | A parameter pattern or default value the profile cannot bind. | Use plain names or destructuring without defaults; declare the field required or guard it. |
329
- | `unsupported-style-property` | A style property name is not a CSS property. | Write camelCase (backgroundColor) or a custom property (--brand). |
330
- | `void-element-children` | A void element has children. | Remove the children. |
331
- | `caniemail/<feature>` | A CSS property, HTML element or attribute that a target email client ignores or partly supports (from the caniemail.com data), reported as caniemail/<feature>. | Use the alternative the message names, such as an email component instead of display: flex. |
290
+ | Code | Meaning | Fix |
291
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
292
+ | `brand-import-conflict` | The project imports two different brands. | Import one brand specifier everywhere. |
293
+ | `brand-unavailable` | The build has no brand to resolve a brand import. | Create or select the brand the import names. |
294
+ | `button-dynamic-label` | A Button label is read at render time, so classic Outlook cannot size it. | Give the button a width: <Button width={200}>. |
295
+ | `button-outlook-fallback` | Classic Outlook needs a height and a plain string label to draw the button. | Give <Button> a height and a string label. |
296
+ | `caniemail/unknown-coverage` | A feature has no client-support data. | Verify it in the target clients. |
297
+ | `css-conditional-variable` | A custom property is redefined inside a conditional at-rule. | Keep the override on a class rule. |
298
+ | `css-import-unresolved` | A stylesheet @import cannot be resolved. | Import the stylesheet from the template module, or use the theme.css layers. |
299
+ | `css-parse-error` | A stylesheet does not parse. | Fix the CSS at the reported location. |
300
+ | `css-unsupported-at-rule` | An at-rule is not applied to email output. | Remove it or move the declarations into a class rule. |
301
+ | `dangerous-html` | dangerouslySetInnerHTML; there is no raw-HTML escape hatch. | Write the markup as JSX. |
302
+ | `duplicate-attribute` | An attribute is written twice. | Keep one. |
303
+ | `dynamic-class` | A class name is built at run time, and Tailwind cannot see it. | Write classes literally, or choose between literal lists: input.vip ? "bg-amber-100" : "bg-white". |
304
+ | `dynamic-definition` | A definition field is spread or computed instead of written out. | Write each field as name: value. |
305
+ | `dynamic-expression` | An expression outside the static profile: a call other than fmt.* and .map, a computed value, or a spread. | Compute the value where the message is sent and bind it from the payload. |
306
+ | `dynamic-numeric-prop` | A prop that sizes layout at compile time is not a number literal. | Write the number literally, for example height={44}. |
307
+ | `email-contrast` | 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). | 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. |
308
+ | `email-font-fallback` | A font stack names a project font without a generic fallback. | End the stack with sans-serif, serif or monospace. |
309
+ | `email-font-family` | @font-face declares no font-family. | Add font-family. |
310
+ | `email-font-format` | @font-face uses a format other than WOFF2. | Convert the file to .woff2. |
311
+ | `email-font-missing` | @font-face names a file the project does not hold. | Add the file; paths resolve from the stylesheet's directory. |
312
+ | `email-font-src` | @font-face declares no usable url() src. | Add a url() pointing at a .woff2 file. |
313
+ | `event-handler` | An event handler; email clients strip scripting. | Remove the handler; use a link for interaction. |
314
+ | `fixture-invalid` | A fixture does not match the schema. | Change the fixture, or the schema, so every fixture is a payload a send could carry. |
315
+ | `fixtures-not-static` | A fixture is computed instead of written as data. | Write each fixture as a literal object; an imported asset is allowed. |
316
+ | `formatter-arguments` | A formatter got the wrong number of arguments. | Match the formatter's signature in the contract. |
317
+ | `hook-call` | A hook was called; templates have no state, effects or context. | Read the input through the function's parameter and send anything else in the payload. |
318
+ | `invalid-asset-base` | The asset base is not an https URL or a path. | Use an https URL, an absolute path, or a relative path. |
319
+ | `invalid-attribute-value` | An attribute value is not a string or number, or contains control characters. | Pass plain text. |
320
+ | `invalid-binding-type` | A binding has the wrong type for where it is used. | Bind a field of the type the position needs, or send it in that type. |
321
+ | `invalid-button-number` | A Button size prop is not a finite number. | Pass a finite number. |
322
+ | `invalid-channel` | A channel field is not shaped as the channel requires. | Write email: { subject, preheader?, body } with each a function of the input. |
323
+ | `invalid-columns-each` | <Columns each> is not a list from the input. | Write each={input.items} with a callback returning <Column>. |
324
+ | `invalid-columns-gap` | <Columns gap> is set on a Columns whose cells are repeated with each or rendered conditionally. | Write each Column out unconditionally, or pad the cells instead of using gap. |
325
+ | `invalid-columns-per` | <Columns per> is not a positive integer literal. | Write per={2}. |
326
+ | `invalid-columns-stack` | <Columns stack> is not a true or false literal. | Write stack={false}, or leave it out to stack on narrow screens. |
327
+ | `invalid-content` | An element holds content its content model does not allow. | Move the child to a parent that accepts it. |
328
+ | `invalid-fixture-name` | A fixture key is not a single path segment. | Use letters, digits, '.', '_' and '-', starting with a letter or digit. |
329
+ | `invalid-locale` | The template's default locale is not a string literal. | Write locale: "en-US". |
330
+ | `invalid-number-prop` | A primitive's numeric prop, such as <Email gutter>, is not a finite number in range. | Pass a finite number the message allows, for example gutter={16}. |
331
+ | `invalid-preheader` | The preheader is not a string. | Return a string. |
332
+ | `invalid-schema` | The schema uses JSON Schema keywords the compiler cannot validate against. | Use only the JSON Schema keywords the package README lists. |
333
+ | `invalid-style` | A style prop is not a static property object. | Write style={{ backgroundColor: "#fff" }} with literal values, or use classes. |
334
+ | `invalid-style-value` | A style value is not a string or number. | Pass a string or number. |
335
+ | `invalid-subject` | The subject is not one line of text. | Return a single-line string. |
336
+ | `invalid-template-id` | The template id is not a lowercase kebab-case string literal. | Write id: "order-shipped". |
337
+ | `invalid-text` | The plain-text body is not a string. | Return a string. |
338
+ | `legacy-definition` | The entry default-exports something other than defineTemplate. | Import { defineTemplate } from "@samva/markup" and default-export defineTemplate({ ... }). |
339
+ | `missing-attribute` | An element is missing a required attribute. | Add the attribute, for example alt on img. |
340
+ | `missing-body` | The email channel has no body. | Add body: (input) => (<Email>...</Email>). |
341
+ | `missing-fixtures` | The template declares no fixtures. | Add fixtures: { default: { ... } } with at least one valid payload. |
342
+ | `missing-head` | Styles that cannot be inlined need a <head> to live in. | Wrap the message in <Email>, or write those declarations inline. |
343
+ | `missing-schema` | The template declares no input schema. | Add schema: jsonSchema<Input>({ ... }). |
344
+ | `missing-subject` | The email channel has no subject. | Add subject: (input) => .... |
345
+ | `moved-import` | The file imports an @samva/markup entry that moved. | Import email components from "@samva/markup/email". |
346
+ | `multiple-roots` | The body renders nothing or more than one root. | Wrap the body in a single element such as <Email>. |
347
+ | `nesting-limit` | The template nests deeper than the compiler reads. | Flatten the structure. |
348
+ | `no-channel` | The template declares no channel body. | Add an email body. |
349
+ | `no-template` | The entry does not default-export a template. | Default-export defineTemplate({ ... }) imported from @samva/markup. |
350
+ | `non-project-import` | The file imports something other than @samva/markup or a project file. | Remove the import, or copy the code you need into a project file. |
351
+ | `not-a-function` | A channel field is not a function of the input. | Write (input) => ... in place or declare the function in the project. |
352
+ | `preheader-without-shell` | A preheader is set but the body has no <Email> shell to hold it. | Wrap the body in <Email>. |
353
+ | `recursive-partial` | A partial renders itself; partials are inlined at compile time. | Flatten the recursion, or repeat the structure with .map. |
354
+ | `reserved-attribute` | An attribute is written only by a brand component. | Render BrandFooter from the brand import instead of marking an element as the footer. |
355
+ | `reserved-character` | Text contains a private-use character the compiler reserves. | Remove the characters U+E000 to U+E003. |
356
+ | `reserved-url` | A URL is the unsubscribe placeholder only BrandFooter can write. | Render <BrandFooter /> instead. |
357
+ | `schema-not-static` | The schema cannot be read without running code. | Write the schema as a literal jsonSchema<Input>({ type: 'object', ... }). |
358
+ | `statement-in-body` | A body contains a statement (const, if, a loop); a body is one expression. | Use cond && <X />, cond ? <A /> : <B /> and .map, or send the computed value in the payload. |
359
+ | `syntax-error` | A project file does not parse as TSX. | Fix the syntax at the reported location. |
360
+ | `tailwind-dynamic-class` | A class list is assembled at run time. | Write the classes literally. |
361
+ | `too-many-class-variants` | A class list chooses between more literal lists than the compiler resolves. | Split the choice into fewer conditions, or move part of it to a partial. |
362
+ | `unguarded-optional` | An optional field is read outside a guard, so a send without it would render a blank. | Guard it with {input.field && ...}, or make the field required. |
363
+ | `unknown-component` | A component the profile cannot read. | Import components from @samva/markup/email, the brand import, or the project. |
364
+ | `unknown-field` | A binding names a field the schema does not declare. | Add the field to the schema, or bind a field it declares (the message suggests the nearest). |
365
+ | `unknown-formatter` | fmt.<name> is not a formatter. | Use fmt.money, number, date, time, plural or list. |
366
+ | `unmatched-class` | A class produced no styles. | Use a class the theme or Tailwind defines, or write the style inline. |
367
+ | `unresolved-import` | An import names no file in the project. | Check the path and the file extension. |
368
+ | `unsafe-raw-text` | Raw text content contains markup that could end its element. | Remove the offending sequence. |
369
+ | `unsafe-style-value` | A style value contains CSS that could break out of its declaration. | Remove the offending characters. |
370
+ | `unsafe-url` | A URL uses a scheme email cannot send safely. | Use an https URL, a mailto: or tel: link, or a bound URL. |
371
+ | `unsupported-attribute` | An attribute the element does not support. | Use a supported attribute, or data-* and aria-*. |
372
+ | `unsupported-element` | An element outside the supported email vocabulary. | Use an element from the contract's element table. |
373
+ | `unsupported-pattern` | A parameter pattern or default value the profile cannot bind. | Use plain names or destructuring without defaults; declare the field required or guard it. |
374
+ | `unsupported-style-property` | A style property name is not a CSS property. | Write camelCase (backgroundColor) or a custom property (--brand). |
375
+ | `void-element-children` | A void element has children. | Remove the children. |
376
+ | `caniemail/<feature>` | A CSS property, HTML element or attribute that a target email client ignores or partly supports (from the caniemail.com data), reported as caniemail/<feature>. | Use the alternative the message names, such as an email component instead of display: flex. |
332
377
 
333
378
  ## Upgrades
334
379
 
380
+ ### `email-contrast`
381
+
382
+ 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.
383
+
384
+ Move 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.
385
+
386
+ Then 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.
387
+
388
+ Compile 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.
389
+
335
390
  ### `legacy-definition`
336
391
 
337
392
  Read the entry and its imports without executing them. For a default-exported defineEmail definition, import defineTemplate from "@samva/markup" and replace the definition call.
@@ -352,14 +407,6 @@ Replace every import from "@samva/markup/email/components" with "@samva/markup/e
352
407
 
353
408
  Compile each entry and render every declared fixture to confirm the output is unchanged. Resolve errors before requesting one saved version for the whole project, approved by the user; do not publish.
354
409
 
355
- ### `whatsapp-template-name`
356
-
357
- Remove the `name` attribute from every <WhatsApp> element in the project, templates and partials alike. Samva registers each WhatsApp template with Meta under its handle, with hyphens as underscores, so the attribute has no replacement. Change nothing else.
358
-
359
- If an authored name differs from the name the handle produces and the template is already registered with Meta under it, tell the user: keeping that Meta name means renaming the template's handle, which you do not do yourself.
360
-
361
- Compile each entry and render every declared fixture to confirm the WhatsApp output is otherwise unchanged. Resolve errors before requesting one saved version for the whole project, approved by the user; do not publish.
362
-
363
410
  ## Quality bar
364
411
 
365
412
  - Run the check until it reports no errors, then render every fixture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samva/markup",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Type-safe authoring and compilation primitives for Samva templates",
5
5
  "keywords": [
6
6
  "email",
@@ -107,14 +107,72 @@ schema.
107
107
  defaults are inserted.
108
108
  - Locale and time zone are send options; `fmt` reads them, and an explicit locale argument wins.
109
109
 
110
- ## Styling and components
110
+ ## Theme
111
+
112
+ Style with Tailwind classes that read the theme vocabulary, so a theme restyles the message:
113
+
114
+ | Surface | Fill | Text on it |
115
+ | --------------------- | --------------- | --------------------------- |
116
+ | Page | `bg-background` | `text-foreground` |
117
+ | Card (groups content) | `bg-card` | `text-card-foreground` |
118
+ | Main action, links | `bg-primary` | `text-primary-foreground` |
119
+ | Quieter action | `bg-secondary` | `text-secondary-foreground` |
120
+ | Subdued surface | `bg-muted` | `text-muted-foreground` |
121
+ | Highlight, callout | `bg-accent` | `text-accent-foreground` |
122
+ | Rules and outlines | `border-border` | |
123
+
124
+ `text-muted-foreground` is also secondary text on the page or a card. `rounded` (and
125
+ `rounded-sm` to `rounded-xl`) follow `--radius`; `font-sans` and `font-heading` are the body and
126
+ heading families. Pair each fill with its text token; never put a raw color or a Tailwind palette
127
+ color (`bg-indigo-600`) in a block.
128
+
129
+ `theme.css` layers a default theme, then `starter.css`, then `@import "samva:brand";`, then the
130
+ project's own `@theme`. Dark mode is the theme's dark block, never a second set of names:
131
+
132
+ ```css
133
+ @theme {
134
+ --color-card: #ffffff;
135
+ --color-card-foreground: #14232a;
136
+ }
137
+ @media (prefers-color-scheme: dark) {
138
+ @theme {
139
+ --color-card: #131c20;
140
+ --color-card-foreground: #e3ebed;
141
+ }
142
+ }
143
+ ```
111
144
 
112
- Write HTML for content and Tailwind classes for style. Components exist only where an email client
113
- needs something HTML cannot say: `Email`, `Section`, `Columns`/`Column`, `Button`, `Image`,
114
- `Spacer`, `Divider`, and `BrandLogo`/`BrandFooter` from `samva:brand`. `theme.css` layers
115
- `starter.css`, then `@import "samva:brand";`, then the project's own `@theme`. Fonts are `.woff2`
145
+ Write `bg-card` once; it switches in dark mode by itself. Use `dark:` only to pick a different
146
+ token in dark mode. Opacity modifiers (`bg-primary/10`) keep the light value. Fonts are `.woff2`
116
147
  files declared with `@font-face`, and a font stack ends in a generic family.
117
148
 
149
+ ## Components and blocks
150
+
151
+ Components exist only where an email client needs something HTML cannot say: `Email`, `Section`,
152
+ `Columns`/`Column`, `Button`, `Image`, `Link`, `Spacer`, `Divider`, and `BrandLogo`/`BrandFooter`
153
+ from `samva:brand`. Their defaults read the theme; a class on one overrides its default, a prop
154
+ overrides both.
155
+
156
+ - `Email` paints `bg-background` behind the whole message and keeps a 16px gutter each side
157
+ (`gutter={0}` for edge-to-edge bands).
158
+ - `Section width={560}` is a maximum: the band fills a phone screen.
159
+ - `Columns` stacks its cells into one column on screens narrower than 600px; `gap={24}` spaces
160
+ them in both layouts. `stack={false}` keeps a row of small cells side by side.
161
+ - `Button` defaults to `bg-primary text-primary-foreground`. Classic Outlook draws it from its
162
+ props, so give it `height`, a string label and `borderRadius`.
163
+
164
+ Blocks are project partials in `templates/blocks/`: the starter ships `Header`, `Hero`,
165
+ `PrimaryButton`, `KeyValue`/`KeyValueRow`, `Callout` and `Footer`. Compose a message from them,
166
+ copy one to make a variant, and keep every block on the vocabulary above.
167
+
168
+ ## Contrast
169
+
170
+ `email-contrast` checks every fixture in light and dark mode, located at the element. Text too
171
+ faint to read, or a button its surroundings hide, is an error that fails the check; text below the
172
+ WCAG AA ratio is a warning (the ratios are in `reference.md`, under Theme). Fix it in the theme:
173
+ pair the fill with its `-foreground` token and give the dark block a value for the token, rather
174
+ than adding `dark:` overrides element by element.
175
+
118
176
  ## References
119
177
 
120
178
  - `contract.json` (`@samva/markup/contract.json`): components with props, profile forms,