📬 v0.27.0 — Email Sections & Async Assets
Two big additions. A brand-new Email Sections library — 40+ drop-content-in email components and 6 full transactional templates that render correctly in real mail clients, built as a deliberately separate library that emits inline styles and shares Midwest's design language without touching its stylesheet. And Async Assets graduates from experiment to shipping feature: an opt-in per-component CSS/JS delivery mode that ships a small shared core plus one stylesheet and one ES module per component a page actually rendered — 64.5% smaller raw for a twelve-component page. The single bundle stays the default and stays supported, and it got 5.7% smaller on its own by no longer sweeping the demo site into the build.
What's New
Midwest::Email::* — Email Sections
A parallel library of designed, drop-content-in email sections — headers, heroes, receipts, order summaries, feature grids, footers, and 30+ more — built to render correctly in real mail clients. Transactional-first, with enough marketing sections to cover a newsletter or an announcement.
The constraint that shapes everything: an email cannot use Midwest's stylesheet. There is no cascade to rely on, no custom properties, no @layer, no Tailwind. Outlook renders through Word; Gmail strips <head> in enough contexts that a design resting on <style> blocks fails silently. So these components do not participate in the CSS build at all — they live in their own app/emails/midwest/email/ directory, carry no .css or .ts files, and emit every declaration as an inline style attribute at render time.
What they do share is the design language — the colour scales, the spacing ramp, the type scale — resolved to literal values instead of custom properties. A Midwest::Email::Palette maps your Midwest::Theme to semantic email roles, so a single config.email.theme retints an entire message. State colours (success / warning / error / info) are a fixed scale, independent of the brand theme.
# config/initializers/midwest.rbMidwest.configure do |config| config.email.theme = "#7c3aed" # a named theme or a hex value config.email.font_family = :sans # a token keyword or a raw stack config.email.content_width = 600 config.email.dark_mode = true config.email.logo_url = nil # default for the headerendSections cover the full transactional and marketing surface: header, hero, content, CTA band, alert, verification code, details, receipt, order summary, address, status stepper, data table, stats, user, quote, attachments, social row, signature, and a marketing set — feature grid / row, product grid, article list, testimonial, logo cloud, pricing, FAQ, survey, video poster, app badges, countdown, plus supplemental callout, button group, bullet list, steps, coupon, event, progress, and rating.
Custom web fonts are an opt-in progressive enhancement (config.email.web_font): a Midwest::Email::WebFont value object emits an Outlook-guarded @font-face that loads in supporting clients (Apple Mail, iOS) and falls back to the stack everywhere else, while code and monospace displays keep their font.
This is not an email sending library — no delivery, no ESP integration. It renders HTML; ActionMailer sends it.
Full transactional templates
Six complete emails, each assembled from the section components and rendered as a whole document — receipt, verification code, welcome, order shipped, alerts, and a monthly report — so you can start from a working message rather than a blank layout.
Async Assets — per-component CSS & JS delivery
The v0.26 AssetsComponent solved one narrow problem — a component arriving over Turbo onto a page that never loaded Midwest's CSS — by injecting the entire bundle. This milestone replaces that with per-component delivery, opt in via Midwest.config.assets.async: a small shared core plus one stylesheet and one ES module per component the page actually rendered.
- Per-component builds —
bin/rails midwest:css:asynccompiles a lean 76 KB core plus one stylesheet per component, andmidwest:js:asyncemits one ES module per component that owns a Stimulus controller (Rollup puts@hotwired/stimulusin a single shared 18 KB chunk, so a component module is only its own controller —badgeis 195 bytes). Both are byte-verified against the bundle so async mode can never drift from bundled mode. - Render-time accounting —
midwest_asset_tagsemits exactly the components a page rendered. Nested components register themselves as they render, so no dependency graph is consulted;config.assets.alwaysnames layout chrome, andconfig.assets.preloadprefetches components known to arrive later over Turbo. - Turbo delivery — a fragment's assets are appended to the document head for Streams and inline for Frames; the now async-aware
AssetsComponentinjects only what the named components need. - Serving — artifacts are served by a Rack mount the engine installs (Rollup's relative import specifiers survive), carry content hashes so they're served
immutable, andconfig.assets.hostpoints them at a CDN.
For a page rendering twelve components: 278 KB / 60 KB gzip against the bundle's 784 KB / 133 KB — 64.5% smaller raw, 54.7% gzipped, with the core cached across pages. See Async Assets and Email Sections.
What Changed
Default bundle is smaller
midwest.css no longer ships the demo site's styles. Tailwind's automatic content detection sweeps the whole repo and is a superset of everything declared, so demo/app/previews and demo/app/docs were being scanned alongside the library — 456 of the bundle's 681 utility rules existed only because of demo markup. Turning detection off (now that the demo has its own stylesheet) drops 26 KB (5.7%), or 3.8 KB gzipped, off every consuming app's download: 459 KB → 433 KB. Every disappearing class was traced against library sources first — the only survivors were prose in code comments, which Tailwind doesn't extract from.
@source glob fix & cascade determinism
Five of the seven @source globs in midwest/tailwind.css had resolved to non-existent directories since the v4 migration; automatic content detection quietly covered for them. They're corrected now (the bundle stays byte-identical), and became load-bearing once detection was turned off. Separately, three components opened a @layer no order statement declared — harmless in a single bundle, but load-order dependent as separate <link>s in async mode — so the layer order is now declared explicitly. Both are enforced by CI guards (scripts/check_css_sources.rb, scripts/check_css_layers.rb).
Install inserts midwest_asset_tags
The installer's layout step now inserts midwest_asset_tags rather than a bare stylesheet_link_tag. It emits the single bundle by default — identical behaviour to before — and becomes per-component the moment you flip config.assets.async on.
What Was Removed
Nothing was removed, and there are no breaking API changes this release. The single-bundle asset mode remains the default and fully supported; async delivery is strictly additive and opt-in.
Dev Process
- Email visual regression testing — three phases shipped:
bin/email-vrt --pixelsdrives headless-Chromium screenshots diffed with ImageMagick (light + dark baselines for every section), a coverage test asserts every section has a baseline, andbin/email/scripts/export_emails.rbrenders every demo mailer and section to a self-contained.htmlat release time for manual Litmus / Email on Acid checks. A Litmus API integration stays deliberately out of scope (paid dependency). - Async-assets CI job — a dedicated job builds the artifacts (each component's compiled layer block must be byte-identical to the bundle's, and every utility rule must survive somewhere), runs
scripts/check_async_package.rb, fails if the bundled stylesheet drifted, and runs ten browser-verification tests — the ones that caught controllers registering nowhere, which every Ruby-level test missed. The two CSS guards now also run as static checks in CI, not just in the pre-commit hook. - Browser-verified the runtime — async mode had passed every Ruby test while interactive components did nothing at all:
midwest_asset_tagsexecutes before the app's Stimulus boot, so the runtime queued every controller and never flushed. It now installs a property accessor so registration wires up whichever script runs first, with a regression test that asserts load order stays meaningful. - Release packaging — async artifacts are generated by
bin/buildand collected atgem buildrather than committed, keeping ~160 content-hashed files out of history; the package check runs again inbin/releaseas the last point a bad package can be stopped. - Warning & test cleanup — silenced method-redefinition warnings in
base_chartand the test suite, fixed a Nokogiri 1.17 deprecation, and repaired three coverage-job tests that had gone stale (a changelog-derived migration-guide assertion, a relocated Stimulus-parser fixture, and a confirmation-controller selector). - Gemfile locks — routine appraisal-gemfile regeneration and platform fixes across the Rails 7, Rails 8, and Bridgetown v1/v2 lockfiles.