ποΈ v0.27.4 β Email Branding & Localized Copy
The Email Sections library learns your brand. This release adds a heading-only display font, brand type treatment (uppercase, tracking), a complement colour scale and per-role palette overrides for brands whose surfaces aren't Midwest's grays, an opt-in permanently-dark colour scheme, and localized copy so the handful of words a component emits itself (a receipt's "Total", a footer's "Unsubscribe") translate through I18n. A v0.27.0-era gotcha that made config.email.web_font unusable from an initializer is also fixed.
What's New
Display fonts β a heading face separate from body copy
config.email.display_font sets a web font for headings only, leaving body copy, receipts, and tables on font_family / web_font. A brand's display face is usually a poor body face, so the two are configured independently. One setting reaches every heading in the library because every heading β the standalone helper and the ones nested inside content, hero, CTA, alert, event, and the rest β renders through the single HeadingComponent. It takes the same WebFont shape and the same Outlook guard as web_font; left nil, headings use the body font exactly as before.
Midwest.configure do |config| # A single-weight display face β declare each weight the type scale uses # (600 for levels 2-3, 700 for level 1) so no client fakes a bold. config.email.display_font = { family: "Scouts Hand", sources: [ { url: "https://example.com/scouts.woff2", weight: 600 }, { url: "https://example.com/scouts.woff2", weight: 700 } ] }endOverride per email with midwest_email(display_font: β¦), exactly like web_font.
Brand type treatment for headings
config.email.heading_transform and config.email.heading_tracking apply a house type style to every heading: a text-transform (:uppercase and friends) and a tracking token (:tight / :normal / :wide / :wider) or a raw CSS length. Uppercase headings usually want tracking with them, since caps set at body tracking read cramped. Both are set once app-wide and inherited by every section, or overridden per heading via midwest_email_heading(transform:, tracking:).
Complement scale and per-role palette overrides
Two ways to move an email off Midwest's default grays:
config.email.complementβ an optional second colour scale, addressable as:complementin roles, for brands whose headings or calls to action sit on a different hue from their primary (a green ground with a gold headline). Nothing uses it until a role names it, and naming it with no complement configured raises rather than silently falling back to gray.config.email.rolesβ folds per-role overrides over the palette for brands whose surfaces aren't gray. Each value is a{ light:, dark: }pair (partial is fine) or a bare[scale, shade]applied to both schemes, wherescalemay be:theme,:complement, a named scale, or:white/:black.
Midwest.configure do |config| config.email.complement = "#d4a017" # gold, for a green primary config.email.roles = { page_background: { dark: [:theme, 11] }, # one schemeβ¦ heading: { dark: [:complement, 6] }, border: [:theme, 9] # β¦or a bare pair for both }endThe override is applied per palette instance, never to the shared ROLES table β so the drift guard that holds that table to dark-mode.css is untouched by an app repainting its surfaces.
Permanently-dark emails via color_scheme
config.email.color_scheme = :dark renders the email itself dark for every recipient β the way a permanently-dark marketing site is dark. This is a different knob from dark_mode: dark_mode layers a prefers-color-scheme block on a light email so the recipient's setting decides, whereas color_scheme decides the base. When an email is already dark there is nothing to override, so the redundant media block is skipped entirely.
Localized email copy
Almost every string in an email comes from the caller. The exceptions are the few a component must emit to be structurally complete β a receipt needs the word "Total"; the caller only supplies the number. Those now live in app/emails/midwest/email/locales/en.yml under midwest.email.* and are picked up through the engine's app/emails/**/*.yml i18n load path, so a consuming app overrides them from its own locale files without touching the gem. Covered: receipt (subtotal / discount / shipping / tax / total), footer (unsubscribe / preferences), article list (read more), signature (closing), and video (play alt text).
Two rules keep this safe for a library:
- Every lookup passes an English
default:, so a host app that doesn't pick up the gem's locale path β or that runsraise_on_missing_translationsβ renders English rather than blowing up mid-send. - Anything a domain might reword is also a per-call option.
midwest_email_receipt(total_label: "Amount paid")beats the translation, for the case where the word is wrong rather than the language.
# Translate the whole library from your app's own locale file:# config/locales/emails.fr.ymlfr: midwest: email: receipt: total: "Total Γ payer" footer: unsubscribe: "Se dΓ©sabonner"What Changed
web_font / display_font are coerced on read, not on assignment
WebFont lives under app/emails, so it's a reloadable, autoloaded constant β and the config initializer that assigns a font runs before the autoloaders are ready. Coercing eagerly raised uninitialized constant Midwest::Email at boot, which made the documented config.email.web_font = {β¦} usage impossible from an initializer (the same hazard the email helpers already work around with a to_prepare hook). Both fonts are now coerced the first time they're read instead. The one tradeoff: a malformed Hash surfaces at first render rather than at boot.
Receipt labels are translatable and per-call overridable
ReceiptComponent's row labels (Subtotal, Discount, Shipping, Tax, Total) were hard-coded English. They now resolve through midwest.email.receipt.* with English fallbacks, and each is additionally overridable per call β total_label:, subtotal_label:, and so on β for the cases where a domain has its own word (total_label: "Amount paid" on a ticket).
The dark-mode media query is skipped when it would say nothing
LayoutComponent#dark_mode? now also asks the palette whether any role actually changes between schemes. An already-dark email (or one whose overrides leave nothing scheme-dependent) no longer emits an empty prefers-color-scheme: dark block.
What Was Removed
Nothing was removed. No component options, slots, or helpers were dropped, and there are no breaking API changes β every new setting defaults to its previous behaviour (complement, roles, display_font, heading_transform, and heading_tracking all default to nil; color_scheme defaults to :light), so an app that upgrades and changes nothing renders exactly as it did on v0.27.3.
Dev Process
- Roadmap β email branding and localization weren't scoped roadmap items; like the Email Sections library and the
Form::RichTextComponentbefore them, they land in the v0.27 line as net-new additions. The v0.27 milestone note records that v0.27.4 delivered display fonts, brand type treatment, the complement scale and per-role palette overrides, the:darkcolour scheme, and I18n-backed component copy. - Docs β EMAILS.md gains sections on the two-indirection palette (Β§4.3), per-app role overrides, the
web_fontvsdisplay_fontsplit, and component-supplied copy translation (Β§6.4). - Tests β new palette coverage for the complement scale, role folding, and the already-dark short-circuit; web-font tests for the read-time coercion and the independent display-font scope; and section tests for the localized labels.
- Version bumps β the gem, the
@unabridged/midwestnpm package, and the@unabridged/midwest-language-serverLSP package are all bumped to0.27.4.