Converting Fifty Views in a Week

rubyrailsbuilding-in-public

Journey had been running on plain ERB since its founding. Shared partials for the sidebar, topbar, modals, toasts, the usual Rails approach. It worked fine when the app was small, but as we built out more features, the partials accumulated parameters, the locals got harder to track, and changing a layout meant hunting through multiple files to find all the places that rendered a particular partial.

Meanwhile, Sirius2 had matured into a proper component library. We’d been using it in newer projects and the difference was clear: typed attributes with defaults, named content slots, encapsulated rendering logic. The ERB partial approach felt increasingly creaky by comparison.

So this week we migrated. Fourteen commits over seven days.

What actually changed

The first commit was the big one. It introduced Sirius2 as a dependency and created about twenty new component classes in one shot. Layout components (ApplicationLayout, ApplicationBar, Sidemenu), interactive components (Modal, Dialog, Popover, ContextMenu), form components (TextField, Checkbox, Select). Each one replacing a shared ERB partial or a chunk of duplicated markup.

The difference isn’t just syntax. An ERB partial takes locals:, an untyped hash that blows up at render time if you forget a key. A Sirius2 component has typed attributes with defaults and constraints:

attribute :size, :symbol, default: :medium, only: %i[small medium large]
attribute :bold, :boolean, default: false

You can’t pass an invalid size. You can’t forget a required attribute. The component tells you what it accepts, and it’s enforced. Going from “partials with locals” to “components with contracts” felt like going from a dynamic language to a typed one. The same code, but with a safety net.

The syntax

ERB:

<%= render partial: "shared/sidebar", locals: { current_project: @project } %>
<% if @maps.empty? %>
  <div class="EmptyState">No story maps yet</div>
<% end %>

HERB:

<Sidemenu project={@project} />
{#if @maps.empty?}
  <EmptyState>No story maps yet</EmptyState>
{/if}

Components render as HTML-like tags. Slots use colon syntax, <Page:Title> instead of a separate content_for call. Expressions use {{ }} for output and {% %} for logic. It reads like the markup it produces, not like Ruby code that happens to generate markup.

What we kept in ERB

Not everything moved, and the reasons were deliberate:

Turbo Stream responses. About twenty files. These are three-to-ten-line response snippets. Componentizing them would add overhead for no benefit.

Root layout files. application.html.erb is the outermost shell that bootstraps the Sirius2 component tree. The HERB world lives inside the ERB shell. Converting the layout would mean changing how Rails boots the view, and there’s no upside.

The story map grid rendering. The most complex, performance-sensitive part of the app. The backbone, release lanes, and story cells use ViewComponent’s with_collection for batch rendering. These components inherit from ViewComponent::Base directly, not from Sirius2’s base class. Migrating them would mean reworking the nested rendering pipeline, and the current approach is fast and correct. No reason to touch it.

Knowing what to leave alone was as important as knowing what to convert. If we’d tried to migrate everything, we’d still be migrating.

The tipping point

Somewhere around day four, after I’d converted the wizard pages, the settings views, the project pages, the story map management views, I realized I couldn’t go back. Not because of sunk cost, but because the HERB views were genuinely better. The component tags made the page structure visible at a glance. The typed attributes caught mistakes at compile time instead of runtime. The Sirius2 components enforced consistency that the ERB partials only suggested.

When I opened an unconverted ERB file after spending days in HERB, it looked like noise. All those <%= render partial: calls and <% end %> closers and locals: hashes. The same information is there, but it’s buried in syntax.

By the end of the week we had about 110 HERB files and 50 remaining ERB files. The ERB files are the ones that should stay: turbo streams, layouts, the grid renderer. Everything else is HERB and Sirius2.

The migration wasn’t just a syntax change. It was the moment Journey went from “a Rails app that uses some shared components” to “a Rails app built on a design system.”