diff --git a/_posts/2026-06-25-the-design-system-you-can-actually-call.markdown b/_posts/2026-06-25-the-design-system-you-can-actually-call.markdown
new file mode 100644
index 0000000..0ff36cf
--- /dev/null
+++ b/_posts/2026-06-25-the-design-system-you-can-actually-call.markdown
@@ -0,0 +1,189 @@
+---
+layout: post
+title: "The Design System You Can Actually Call"
+date: 2026-06-25
+series: engineers-notebook
+series_order: 10
+description: "We had twelve chapters of design-system HTML — every button drawn, every state documented — and it still wasn't a system, because a manual describes a button, it doesn't give you one. This is the story of turning that manual into an invokable Rails component library: why strict-locals partials beat ViewComponent for this codebase, how a preview-driven registry auto-wires every helper, and why the docs can't drift from the code when the docs *are* the code rendered."
+image: /img/modular-rails-cover.png
+---
+
+We had a beautiful design system. Twelve chapters of HTML — foundations, components, patterns, accessibility — every button and dialog drawn, every state documented, every rule written down. It looked finished.
+
+It wasn't, and the reason is the most important idea in this post:
+
+> A manual *describes* a button. It does not *give you one.*
+
+Every screen that wanted a button still had to hand-write the markup — `` — and hope it remembered the right classes, the right `type`, the right `aria-*`. The manual is a picture of the component; the app re-keys it by hand every time. The first time someone forgets a class or an attribute, the system has **drifted**, and nothing catches it.
+
+So "sign-off" never meant "the manual is pretty." It meant something stricter: the documented components must become **real, callable units** — one source of truth each, invoked by name with parameters, so that fixing a component once fixes every screen. That is the difference between a *style guide* and a *component library*, and it is the whole job. This is the story of building the second one — the decision, the architecture, and the conventions that keep it honest.
+
+## The decision: how, not just what
+
+The instinct in 2025–2026 Rails is to reach for **ViewComponent**. We didn't — and the reasoning matters more than the answer, because it is specific to *this* codebase rather than a general verdict on the gem.
+
+The field has three live answers, all current and all battle-tested:
+
+| Approach | What it is |
+|---|---|
+| **Strict-locals partials + helpers** | Rails-native; a `<%# locals: (…) %>` partial plus a thin helper |
+| **ViewComponent** | GitHub's gem; a Ruby class plus an ERB template, precompiled |
+| **Phlex** | Pure-Ruby views, no templates at all |
+
+What the research changed in our thinking was the realisation that the substrate is moving, and you want to bet *with* it:
+
+- **ERB is being reinvigorated, not replaced.** [Herb](https://herb-tools.dev) — an HTML-aware ERB parser, linter, formatter and language server — has been adopted by GitHub off the now-archived `erb_lint`, the Rails core team wants it to replace Erubi, and a "ReActionView" engine is on the table. I wrote about what that buys you [last week](/2026/06/24/the-view-layer-rails-couldnt-see.html); the short version is that betting on ERB is betting with the ecosystem.
+- **"ERB + Herb" welcomes ViewComponent** — its templates *are* ERB — and **excludes Phlex**, because there is no ERB for Herb to see. So Phlex was out for us on the first cut.
+- The real contest, then, was **partials versus ViewComponent** — both ERB, both Herb-native, both proven.
+
+**Why partials won here** comes down to three constraints specific to this codebase:
+
+1. **Portability.** The app is built on `seams`, a generator framework whose engines depend on `rails + thor` *only* and must stay portable. ViewComponent would push a UI-framework dependency into every engine that wanted to invoke a component. Partials add nothing to anyone's dependency surface.
+2. **The Herb + RuboCop gate.** Plain ERB partials and helpers keep the *entire* component surface inside the quality gate the team already runs, with no new toolchain to learn.
+3. **Onboarding.** A new Rails engineer is productive on day one — they already know partials and helpers. There is no new mental model to absorb before they can ship.
+
+ViewComponent's genuine wins — isolated `render_inline` tests, first-class slots, Lookbook previews — are real, but none of them is load-bearing for us, and [Rails 7.1 strict locals](https://guides.rubyonrails.org/action_view_overview.html) close much of the safety gap they used to own: required and typed parameters, a single compile. And the escape hatch is clean. Partials → ViewComponent is an incremental, per-component migration over the *same* CSS and the *same* class contract, so we are never locked in.
+
+**The verdict:** strict-locals partials and helpers, packaged as a first-party `compositor` engine — the same model GOV.UK runs on internally with `govuk_publishing_components`. Good company for a boring decision.
+
+## The architecture
+
+```
+engines/compositor/
+ lib/compositor.rb # the component registry (derived from previews)
+ lib/compositor/engine.rb # non-isolated engine; wires the helper app-wide
+ lib/generators/compositor/… # rails g compositor:component
+ app/helpers/compositor_helper.rb # compositor_icon + the auto-wirer
+ app/views/compositor/_*.html.erb # the components (strict-locals partials)
+ app/views/compositor/previews/_*.html.erb # one preview per public component
+ app/views/compositor/guide/index.html.erb # the living gallery (auto-iterates)
+ app/controllers/compositor/guide_controller.rb
+ app/assets/stylesheets/compositor.css # the shipped stylesheet (propshaft)
+```
+
+A few of those decisions are worth understanding, because they are what make the rest cheap.
+
+**It's a non-isolated engine.** Most Rails engines call `isolate_namespace`. Compositor deliberately does *not* — its partials and its helper must be available in the host, in `quire_rails`, and in the `seams` engines, so that a component can be invoked anywhere. The engine is picked up automatically by `config/seams_engines.rb` (which loads every `engines/*`), and the helper is mixed in app-wide via a `to_prepare` hook:
+
+```ruby
+# lib/compositor/engine.rb
+initializer "compositor.helpers" do |app|
+ app.config.to_prepare do
+ Compositor.reset_component_names!
+ CompositorHelper.define_component_helpers! # see below
+ ActionController::Base.helper(CompositorHelper)
+ end
+end
+```
+
+**The partial is the source of truth; the helper is sugar.** A component is a strict-locals ERB partial, and that is the whole of it:
+
+```erb
+<%# app/views/compositor/_button.html.erb %>
+<%# locals: (variant: :default, size: nil, type: "button", content: nil, **attrs) -%>
+<%= tag.button type: type,
+ class: class_names("btn", "btn-#{variant}" => variant.to_s != "default",
+ "btn-#{size}" => size.present?),
+ **attrs do %><%= content %><% end %>
+```
+
+Three conventions do a lot of work in those four lines:
+
+- **Strict locals** (`<%# locals: (…) %>`) make the parameters required and typed and compile the partial once. A missing required local raises, instead of rendering something subtly wrong and shipping it.
+- **`**attrs` splatting** onto the root element lets any caller add `id`, `data-*` or `aria-*` without forking the partial.
+- **Accessibility is baked into the partial**, not the call site. A caller *can't* forget the `aria-invalid` / `aria-describedby` wiring on a field, because the partial always emits it. The safe thing is the default thing.
+
+**CSS rides along.** `compositor.css` lives in the engine's `app/assets` and is served by propshaft (`stylesheet_link_tag "compositor"`). Because the markup is nothing more than class names, it renders identically in host views (which use Tailwind) and in framework-free engine views. One stylesheet, one contract, no per-context branching.
+
+**Behaviour reaches for the platform first.** The dialog is a native `