Skip to content

[Feature] RubyUI 2.0 — Phase 2.1: the hard components first - #555

Open
cirdes wants to merge 13 commits into
v2/fixturesfrom
v2/hard-components
Open

cirdes wants to merge 13 commits into
v2/fixturesfrom
v2/hard-components

Conversation

@cirdes

@cirdes cirdes commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

What

The first components on the 2.0 layer — Dialog (8 classes), Toggle and ToggleGroup (3), Select (8), Data Table (14 + 3 adapters untouched) and ThemeToggle (1) — each a plain Ruby class inheriting RubyUI::Component beside an .html.erb sidecar. Their 22 golden scenarios are byte-identical to the frozen 1.6 snapshots in both the canonical and the strict form, through the ERB fixtures Phase 2.0b wrote; the scenarios' Phlex blocks are gone (the Kit methods they called no longer exist). No snapshot, fixture or Stimulus controller changed.

Before any of that, docs/ is pinned to the gem at main's commit 92f2619 (a git source with glob:), so the site keeps building unchanged while the gem migrates (Phase 3.3 points it back). Not RubyGems 1.6.0, which is 21 files behind main (decision 15).

Three layer changes the first sidecars needed, each measured before it was written (plan §"What this plan measured"): mixed_attrs, the nested hash a component forwards to a neighbour (decision 11); render_in drops the sidecar file's final newline, which was reaching the page as a text node inside every parent — the check is read once per compiled Template, not per render (decision 12); a class without a sidecar renders its nearest ancestor's, so a host's MyButton < RubyUI::Button keeps working, and a class directly under a component root finds its own (decision 13). Plus ComponentTest#erb, an inline template compiled through Herb for unit tests, and #descriptor.

Every unit test of the five families is ported to the ERB helper, none deleted; the enumerated arguments (DialogContent#size, Toggle/ToggleGroup variant and size, ToggleGroup type and orientation) accept their String form and refuse unknown values, with tests; SelectValue keeps whitespace-only content as Phlex did; DataTableSortHead's href keeps Phlex's javascript: guard; every caller-supplied attribute value goes through Attributes.flat, so nil omits it and a Symbol dasherizes as in 1.6.

Decisions 11–15 recorded; spec §4.3, §6 Phase 2.0, §6.2.1, §6.2.2, §9.2 amended; follow-up 1's Herb note corrected; follow-up 12 (the MCP registry's description fallback now reads a sidecar) filed for 2.2. The plan was reviewed by Codex before execution (ten findings, verified and folded in — plan Appendix B), executed task by task with a review after each, and given a whole-branch review whose two findings are the last three commits.

Stacked on #554 (Phase 2.0b), which is stacked on #548 (2.0a) and #536 (the ruler).

Why

Spec §6 Phase 2.1: the hard components first, so a design error in the layer surfaces now rather than at component 55. It did surface three (decisions 11–13), each a one-place fix, and the review found a fourth (the per-render source read), fixed the same way.

Test steps

From gem/: bundle exec rake → 779 runs, 0 failures, 0 skips; bundle exec rake golden → 388 runs (166 Phlex-lane, 188 ERB-lane, 7 coverage, 27 of the ruler's own); bundle exec standardrb → 427 files, no offenses. git diff --stat v2/fixtures -- gem/test/golden/snapshots gem/test/golden/strict gem/test/golden/views 'gem/lib/ruby_ui/**/*.js' → empty. cd mcp && bundle exec exe/ruby-ui-mcp-build && git diff --exit-code data/registry.json → clean. The CI Docs job builds docs/ against the gem at main's 92f2619.

Read first: the four composite sidecars — dialog_content.html.erb, data_table_column_toggle.html.erb, data_table_sort_head.html.erb, data_table_pagination.html.erb — they are decision 12's readability test.

Plan: design/plans/2026-09-20-phase-2-1-hard-components-implementation.md.

🤖 Generated with Claude Code


Summary by cubic

Migrates Dialog, Toggle/ToggleGroup, Select, DataTable, and ThemeToggle from Phlex to the RubyUI 2.0 layer: each is now a plain Ruby class inheriting RubyUI::Component with an .html.erb sidecar, rendering byte-identical to the frozen 1.6 snapshots in all 22 golden scenarios.

Layer changes

  • Adds mixed_attrs, the nested Symbol-keyed hash a component forwards to a neighbor component, so data-action concatenates as it did in 1.6 instead of producing two attributes.
  • render_in now drops the sidecar file's final newline, read once per compiled template rather than per render.
  • A class without a sidecar renders its nearest ancestor's, so host subclasses like MyButton < RubyUI::Button keep working.
  • Adds ComponentTest#erb, an inline template compiled through Herb for unit tests, and #descriptor for Stimulus matching.

Behavior details

  • Enumerated arguments (DialogContent#size, Toggle/ToggleGroup variant and size, ToggleGroup type and orientation) accept String form and raise on unknown values instead of silently falling back to defaults.
  • SelectValue keeps whitespace-only content; DataTableSortHead keeps Phlex's javascript: href guard; nil attributes are omitted and Symbol keys dasherize through Attributes.flat.
  • docs/ is pinned to the gem at main's commit since RubyGems 1.6.0 is behind main; it points back at path: in Phase 3.3.
  • No snapshots, fixtures, or Stimulus controllers changed; all unit tests are ported to the ERB helper, none deleted.

Written for commit 345c689. Summary will update on new commits.

Review in cubic

cirdes and others added 13 commits September 20, 2026 20:57
Dialog, Toggle and ToggleGroup, Select, Data Table and ThemeToggle from
Phlex to the 2.0 layer, with docs/ pinned to the published 1.6.0 first.
Written from spec §6.2.1, decisions 1–10 and plan 2.0b's exclusions;
every shape it relies on was measured through the harness before it was
written (its "What this plan measured" and Appendix A). It takes four
decisions of its own — mixed_attrs, the whitespace-tight sidecar layout
with the layer dropping the file's final newline, inherited sidecars,
and ThemeToggle joining 2.1 — and writes out the four composite sidecars
so they can be judged before execution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ten findings, each verified against the code before the plan changed
(Appendix B). The docs pin becomes a git ref of main — the published
1.6.0 is 21 files behind it (decision 15); DataTableSortHead's href,
DataTable's frame and every hidden input go through Attributes.flat so
nil, Symbols and javascript: URLs behave as they did in Phlex; the 24
existing Toggle and ToggleGroup tests are ported rather than replaced;
own_template resolves a sidecar directly under a root; SelectValue
keeps whitespace-only content as Phlex did; render_in drops the final
newline only when the sidecar source has one, and sidecars carry no
trim marker; the String-form tests cover every enumerated argument;
the probe 8a/8b explanation is corrected; every count and test filter
is recomputed (777 / 388 / 426).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rates

The site's Gemfile pointed at ../gem. From Phase 2.1 on that gem is
mid-migration to the 2.0 layer, and the first migrated component would
break the site (its Phlex Kit method stops existing). Pin docs/ to the
gem at main's commit 92f2619 — a git source with glob: gem/*.gemspec —
so the CI Docs job stays green through Phase 2; Phase 3.3 points it
back at the path.

Not the published 1.6.0, as spec §6 Phase 2.0 said: that gem is 21
files behind main (#506's overlay exit animations, #530's HoverCard),
and pinning to it would have regressed the site and paired its markup
with the checkout's newer controllers (decision 15). The pinned
sources differ from the branch's only in context_menu_label.rb, the
Phase 1 fix main does not have yet.

The initializer reads Gem.loaded_specs["ruby_ui"].gem_dir and resolves
to the git checkout unchanged; Tailwind scans the repository's
gem/lib/ruby_ui directory and the Stimulus controller symlinks reach
../gem by relative path — a migration keeps every class string and
touches no controller.

Environment: bundle install under 3.4.7 failed to build nokogiri (1.19.4)'s native extension — gumbo.c:32:10: fatal error: 'nokogiri_gumbo.h' file not found; make: *** [gumbo.o] Error 1 — unrelated to the pin (the git checkout of ruby_ui itself resolved and matched Step 4's diff -rq check); the CI Docs job installs clean and will verify this lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, inherited sidecars, an inline ERB test helper

Three things the first migrations need from the layer, measured before
they were written (plan 2.1, "What this plan measured"):

- mixed_attrs: the nested, Symbol-keyed hash after mix and the class
  merge — 1.6's attrs — frozen. A component that builds another
  component in Ruby forwards this one; forwarding the flat attrs puts
  "data-action" beside the neighbour's data: {action:} and two
  attributes reach the page where 1.6 concatenated them (decision 11).
- render_in drops the sidecar file's final newline when the source
  ends with one. Every file ends with one, and rendered inside a parent
  it was a text node after the component — whitespace Phlex never
  emitted (decision 12). The content's own newline survives.
- template walks the superclass chain to the first class with a
  sidecar beside its own file, so a host's MyButton < RubyUI::Button
  keeps rendering as it did when it inherited view_template; a class
  with its own sidecar uses it; a class directly under a root finds
  its own (the lookup prefix is empty there, not "."); a chain with
  none raises naming every path tried (decision 13).

ComponentTest#erb compiles an inline template with an identifier under
the gem, which is what ReActionView checks, so unit tests render a
composition through Herb without a fixture file; #descriptor matches a
Stimulus descriptor whether tag.attributes escaped its -> or not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Eight classes from Phlex to RubyUI::Component with a sidecar each. The
six golden scenarios render identically to the frozen 1.6 snapshots in
both forms through their ERB fixtures; their Phlex blocks are gone, since
RubyUI.Dialog no longer exists (a Kit defines no method for a class that
is not Phlex::SGML). DialogContent's size goes through enum, so size:
"lg" selects max-w-2xl where 1.6 dropped the class; an unknown size
raises naming the six. The controller is unedited.

The DialogContent sidecar is the first with static markup after the
content: attributes one per line inside the start tags, nothing between
a > and the next < (decision 12).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oggle

The block-parameter family. ToggleGroup's yield(self) is render_in's
capture with the component as the block argument, and
group.ToggleGroupItem renders through helpers.render from inside that
block. ToggleGroupItem < Toggle keeps its own sidecar (decision 13);
Toggle's wrapper is mixed without a Tailwind merge, as 1.6 did.

ThemeToggle is here because it rendered RubyUI.Toggle through the Kit,
which stops existing when Toggle migrates, and a Phlex component can
render a 2.0 one only with a view context the golden Phlex lane does
not have (decision 14). One class, one sidecar, one scenario.

variant and size go through enum in Toggle and ToggleGroup: a String
selects the same entry; an unknown value raises where 1.6 fell back to
the default silently. The 27 existing unit tests are ported, none
deleted; seven scenarios identical in both forms; the two controllers
unedited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Eight classes and sidecars. SelectContent mints its id in initialize
exactly as 1.6 did, so the golden pin still yields content00000001 and
the cross-references line up; SelectValue writes the placeholder when
the block emitted nothing and keeps whitespace-only content, as Phlex
did.
SelectItem's class string loses a stray tab the canonical form could
never see. Two scenarios identical in both forms; both controllers
unedited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nine of the fourteen DataTable classes: the <turbo-frame> root, the form
that reads the CSRF token through helpers (the placeholder where the
view context has none, as the snapshots recorded), the two checkboxes
that forward mixed_attrs into the still-Phlex Checkbox so their
data-action concatenates as 1.6's did (decision 11), the expand toggle
whose caller attributes merge over the button's own, and four wrappers.

full_frame renders these beside the five classes Task 7 migrates and is
identical to its snapshot in both forms; the three controllers are
unedited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gle, sort head and pagination

The five composites: sidecars that render Phlex neighbours (Input,
NativeSelect, DropdownMenu, Button, TableHead, Pagination) with the
arguments 1.6 passed, forward mixed_attrs where 1.6 forwarded attrs, and
keep the loops and branches in ERB with breaks only inside tags
(decision 12). DataTablePagination renders nothing for a single page.
DataTablePerPageSelect keeps its inline onchange through Phlex's
SafeValue while NativeSelect is Phlex; the comment says when it goes.

The seven DataTable scenarios are identical to their snapshots in both
forms, nested forms and all (#537 is ported, not fixed); the three
controllers and the three adapters are unedited. With this commit the
golden Phlex lane has 166 scenarios left, none from Phase 2.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Decisions 11–15 in design/v2/decisions.md: attrs stays flat and
mixed_attrs is what a component forwards; sidecars break lines only
inside tags, carry no trim marker, and the layer drops the file's final
newline; a class without a sidecar renders its nearest ancestor's;
ThemeToggle joined 2.1, a migrated scenario keeps no Phlex block, and
the String form of every enumerated argument is a unit test; docs/ pins
a git ref of main because RubyGems 1.6.0 is behind it. Spec §4.3, §6
Phase 2.0, §6.2.1, §6.2.2 and §9.2 say the same; the catalog header
explains a block-less scenario; follow-up 1's Herb note is corrected —
a sidecar compiles alone, so Herb cannot see a form nested across
components.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r template

Whole-branch review of plan 2.1, findings 1, 5 and 6.

- render_in called Template#source on every render (it rereads the sidecar
  file, uncached, per ActionView); cache the final-newline flag on the
  Template object itself, in an ObjectSpace::WeakMap keyed so a reloader
  rebuild is never stale.
- component_root's `||=` never memoized nil, so a class under no component
  root re-scanned every root on every call; memoize with `defined?` instead.
- DataTableForm#csrf_token is called only from token_input_attrs; move it
  back under `private`. Rebuild the MCP registry, which only changes in
  data_table_form.rb's embedded content.

New probe (Unterminated) and two LayerTest tests cover the read count and
the no-final-newline branch of render_in. component_test.rb's overlapping-
roots test now clears the `defined?` memo with remove_instance_variable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Whole-branch review of plan 2.1, findings 2, 3, 4, 7 and 8.

- Design §4.3 and decisions.md entry 14: a Ruby block's return value is what
  ActionView's capture keeps when the block outputs nothing — ToggleGroupItem
  called twice from Ruby and returned renders only the last one; ERB's <%= %>
  always outputs, so fixtures and tests are unaffected.
- decisions.md entry 12: the two raw-byte differences the canonical and
  strict lanes do not see (Herb's start-tag newline, SelectItem's tab-vs-
  space), and why the contract is the parsed form.
- docs/Dockerfile: the stale COPY comment now says why the gem still needs
  copying (Tailwind's @source scan) now that docs/Gemfile pins a git ref
  instead of path: "../gem" (decision 15).
- follow-up-issues.md: item 12, RegistryBuilder's description fallback going
  empty now that a sidecar sorts ahead of its .rb file.
- The plan's Task 9 grep tightened to Phlex:: so it stops matching prose
  ("as Phlex did").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…handler

ComponentTest#erb looked the :erb handler up before it built the view.
ReActionView registers its handler in the :action_view load hook, which
fires when ActionView::Base loads, so in a process where no earlier test
had touched a view the helper compiled through Erubi and
test_erb_compiles_an_inline_template_through_herb failed in isolation
(the full run passed by order). The view is built first now; the test
passes alone.

The read-count test warms the template before stubbing Template#source
and asserts zero reads: ActionView reads the source once to compile and
the newline flag is read once on the first render, neither of which is
the per-render read the test guards against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cirdes
cirdes added this pull request to stack #549 September 21, 2026 11:08

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 106 files

Not reviewed (too large): design/plans/2026-09-20-phase-2-1-hard-components-implementation.md (~4,033 lines) - if these are generated or fixture files, add them to ignored paths to exclude them from future reviews.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="gem/lib/ruby_ui/data_table/data_table_row_checkbox.rb">

<violation number="1" location="gem/lib/ruby_ui/data_table/data_table_row_checkbox.rb:4">
P1: In an installed host application, rendering this class now raises because `Component#template` requires `RubyUI.component_roots`, but the generated RubyUI initializer leaves that list empty. Register the host component directory (for example, `Rails.root.join("app/components/ruby_ui")`) when installing the 2.0 layer, or otherwise make the root discovery automatic.</violation>
</file>

<file name="gem/lib/ruby_ui/dialog/dialog_footer.rb">

<violation number="1" location="gem/lib/ruby_ui/dialog/dialog_footer.rb:4">
P0: When a consumer generates the Dialog components, this new superclass is unavailable and its required sidecar is not copied: the generated class fails at load time, or at render time if `Component` was loaded manually. Update the installer/generator to load the 2.0 layer and copy each component's `.html.erb` sidecar.</violation>
</file>

<file name="gem/lib/ruby_ui/dialog/dialog_content.html.erb">

<violation number="1" location="gem/lib/ruby_ui/dialog/dialog_content.html.erb:8">
P3: The close-button `<svg>` uses `viewbox=` while the frozen 1.6 snapshot and the data_table sidecars added in this same PR use `viewBox`. Golden parity only holds because Nokogiri::HTML5 case-adjusts SVG attribute names during canonicalization; the raw ERB output the gem emits keeps literal `viewbox`, which is not a valid SVG attribute in XML/XHTML consumption contexts (case-sensitive there) and fails strict SVG validation. Change `viewbox` to `viewBox` to match the baseline and the sibling sidecars.</violation>
</file>

<file name="docs/Gemfile">

<violation number="1" location="docs/Gemfile:79">
P2: For the entire migration window this pin runs the docs app and its test suite against the external 1.6 gem snapshot (commit 92f2619), not the 2.0 gem code in this repo. Two consequences follow from the changed line: (1) the docs app's own render tests no longer exercise any 2.0 rendering code, so the migration's "byte-identical output" claim for doc-rendered components is validated only by the gem's golden suite, not by docs; (2) when the 2.0 line fixes the known 1.6 defects scheduled for correction (issues #540/#541), the published docs site will continue demonstrating the old, defective output the shipped gem no longer produces, until Phase 3.3 reverts. The decoupling is intentional and documented, but the Phase 3.3 revert is the only termination condition — nothing enforces it. Consider tracking the revert explicitly (a CI check or TODO keyed on the 2.0 version bump is enough) so the pin cannot silently survive the migration and permanently freeze docs to stale gem output.</violation>
</file>

<file name="gem/lib/ruby_ui/theme_toggle/theme_toggle.html.erb">

<violation number="1" location="gem/lib/ruby_ui/theme_toggle/theme_toggle.html.erb:1">
P3: Caller-supplied `variant`, `size`, `aria`, and `wrapper` on `RubyUI::ThemeToggle` are accepted without error but silently discarded: the explicit keywords in this sidecar always win over the same keys in `**component.mixed_attrs` (Ruby's explicit-keyword-over-splat precedence), while `class:`, `data:`, and other kwargs do pass through. Merge the fixed defaults into `mixed_attrs` instead, so caller overrides apply, or drop the keys you intend to hardwire and raise on them so the failure is loud.</violation>
</file>

<file name="design/v2/follow-up-issues.md">

<violation number="1" location="design/v2/follow-up-issues.md:161">
P3: Issue 12's diagnosis of `data_table` is incomplete: its directory does contain a `Docs::Header` (in `data_table_docs.rb`, `Docs::Header.new(title: ..., description: "A Hotwire-first data table. Every interaction (sort, search, pagination) ...")`), so the "with no `Docs::Header`" framing does not apply to it. The description is really lost because `extract_description`'s regex `Docs::Header\.new\(([^)]*)\)` stops at the first `)`, truncating the args at the parenthesis inside "(sort, search, pagination)", so `extract_kwarg` never finds the closing quote and the branch falls through to the fallback. The proposed fix (skip non-`.rb` files and the `frozen_string_literal` comment in the fallback) would therefore still not restore `data_table`'s intended description — it would just yield `""`. Correct the note to also fix the `Docs::Header` extraction (match balanced parentheses or tolerate parens inside the quoted description), or the "Before 2.2" fix will not actually fix `data_table`.</violation>
</file>

<file name="gem/test/ruby_ui/component_test.rb">

<violation number="1" location="gem/test/ruby_ui/component_test.rb:186">
P3: `test_the_sidecar_source_is_read_once_per_template_not_per_render` permanently replaces `source` on the process-wide cached Div template via `define_singleton_method` and never restores it. The template object comes from the shared lookup cache (`RubyUI::Probes::Div.template` → `lookup.find`), so every later test in the process that touches this template sees the overridden method returning the captured snapshot instead of the real file-backed `Template#source`, and the only cleanup is the test ending. Add an `ensure` that removes the singleton method from `template.singleton_class` after the assertion, or build the counter without mutating the shared object (e.g. a fresh template).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

div(**attrs, &)
end

class DialogFooter < Component

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: When a consumer generates the Dialog components, this new superclass is unavailable and its required sidecar is not copied: the generated class fails at load time, or at render time if Component was loaded manually. Update the installer/generator to load the 2.0 layer and copy each component's .html.erb sidecar.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/dialog/dialog_footer.rb, line 4:

<comment>When a consumer generates the Dialog components, this new superclass is unavailable and its required sidecar is not copied: the generated class fails at load time, or at render time if `Component` was loaded manually. Update the installer/generator to load the 2.0 layer and copy each component's `.html.erb` sidecar.</comment>

<file context>
@@ -1,11 +1,7 @@
-      div(**attrs, &)
-    end
-
+  class DialogFooter < Component
     private
 
</file context>


module RubyUI
class DataTableRowCheckbox < Base
class DataTableRowCheckbox < Component

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: In an installed host application, rendering this class now raises because Component#template requires RubyUI.component_roots, but the generated RubyUI initializer leaves that list empty. Register the host component directory (for example, Rails.root.join("app/components/ruby_ui")) when installing the 2.0 layer, or otherwise make the root discovery automatic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/data_table/data_table_row_checkbox.rb, line 4:

<comment>In an installed host application, rendering this class now raises because `Component#template` requires `RubyUI.component_roots`, but the generated RubyUI initializer leaves that list empty. Register the host component directory (for example, `Rails.root.join("app/components/ruby_ui")`) when installing the 2.0 layer, or otherwise make the root discovery automatic.</comment>

<file context>
@@ -1,18 +1,14 @@
 
 module RubyUI
-  class DataTableRowCheckbox < Base
+  class DataTableRowCheckbox < Component
     def initialize(value:, name: "ids[]", label: nil, **attrs)
       @value = value
</file context>

Comment thread docs/Gemfile
# Pinned to main's commit while ../gem migrates to 2.0 (spec §6 Phase 2.0,
# last bullet; decision 15 — RubyGems 1.6.0 is behind main). Phase 3.3 points
# it back at path: "../gem".
gem "ruby_ui", github: "ruby-ui/ruby_ui", ref: "92f261931eb78bcc4682f4afb72b139553aba957", glob: "gem/*.gemspec", require: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For the entire migration window this pin runs the docs app and its test suite against the external 1.6 gem snapshot (commit 92f2619), not the 2.0 gem code in this repo. Two consequences follow from the changed line: (1) the docs app's own render tests no longer exercise any 2.0 rendering code, so the migration's "byte-identical output" claim for doc-rendered components is validated only by the gem's golden suite, not by docs; (2) when the 2.0 line fixes the known 1.6 defects scheduled for correction (issues #540/#541), the published docs site will continue demonstrating the old, defective output the shipped gem no longer produces, until Phase 3.3 reverts. The decoupling is intentional and documented, but the Phase 3.3 revert is the only termination condition — nothing enforces it. Consider tracking the revert explicitly (a CI check or TODO keyed on the 2.0 version bump is enough) so the pin cannot silently survive the migration and permanently freeze docs to stale gem output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/Gemfile, line 79:

<comment>For the entire migration window this pin runs the docs app and its test suite against the external 1.6 gem snapshot (commit 92f2619), not the 2.0 gem code in this repo. Two consequences follow from the changed line: (1) the docs app's own render tests no longer exercise any 2.0 rendering code, so the migration's "byte-identical output" claim for doc-rendered components is validated only by the gem's golden suite, not by docs; (2) when the 2.0 line fixes the known 1.6 defects scheduled for correction (issues #540/#541), the published docs site will continue demonstrating the old, defective output the shipped gem no longer produces, until Phase 3.3 reverts. The decoupling is intentional and documented, but the Phase 3.3 revert is the only termination condition — nothing enforces it. Consider tracking the revert explicitly (a CI check or TODO keyed on the 2.0 version bump is enough) so the pin cannot silently survive the migration and permanently freeze docs to stale gem output.</comment>

<file context>
@@ -73,7 +73,10 @@ end
+# Pinned to main's commit while ../gem migrates to 2.0 (spec §6 Phase 2.0,
+# last bullet; decision 15 — RubyGems 1.6.0 is behind main). Phase 3.3 points
+# it back at path: "../gem".
+gem "ruby_ui", github: "ruby-ui/ruby_ui", ref: "92f261931eb78bcc4682f4afb72b139553aba957", glob: "gem/*.gemspec", require: false
 
 gem "pry", "0.16.0"
</file context>

><svg
width="15"
height="15"
viewbox="0 0 15 15"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The close-button <svg> uses viewbox= while the frozen 1.6 snapshot and the data_table sidecars added in this same PR use viewBox. Golden parity only holds because Nokogiri::HTML5 case-adjusts SVG attribute names during canonicalization; the raw ERB output the gem emits keeps literal viewbox, which is not a valid SVG attribute in XML/XHTML consumption contexts (case-sensitive there) and fails strict SVG validation. Change viewbox to viewBox to match the baseline and the sibling sidecars.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/dialog/dialog_content.html.erb, line 8:

<comment>The close-button `<svg>` uses `viewbox=` while the frozen 1.6 snapshot and the data_table sidecars added in this same PR use `viewBox`. Golden parity only holds because Nokogiri::HTML5 case-adjusts SVG attribute names during canonicalization; the raw ERB output the gem emits keeps literal `viewbox`, which is not a valid SVG attribute in XML/XHTML consumption contexts (case-sensitive there) and fails strict SVG validation. Change `viewbox` to `viewBox` to match the baseline and the sibling sidecars.</comment>

<file context>
@@ -0,0 +1,17 @@
+><svg
+  width="15"
+  height="15"
+  viewbox="0 0 15 15"
+  fill="none"
+  xmlns="http://www.w3.org/2000/svg"
</file context>
Suggested change
viewbox="0 0 15 15"
viewBox="0 0 15 15"

@@ -0,0 +1 @@
<%= render RubyUI::Toggle.new(variant: :default, size: :default, aria: {label: "Toggle theme"}, wrapper: {data: {controller: "ruby-ui--theme-toggle", action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply"}}, **component.mixed_attrs) do %><%= component.content %><% end %>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Caller-supplied variant, size, aria, and wrapper on RubyUI::ThemeToggle are accepted without error but silently discarded: the explicit keywords in this sidecar always win over the same keys in **component.mixed_attrs (Ruby's explicit-keyword-over-splat precedence), while class:, data:, and other kwargs do pass through. Merge the fixed defaults into mixed_attrs instead, so caller overrides apply, or drop the keys you intend to hardwire and raise on them so the failure is loud.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/theme_toggle/theme_toggle.html.erb, line 1:

<comment>Caller-supplied `variant`, `size`, `aria`, and `wrapper` on `RubyUI::ThemeToggle` are accepted without error but silently discarded: the explicit keywords in this sidecar always win over the same keys in `**component.mixed_attrs` (Ruby's explicit-keyword-over-splat precedence), while `class:`, `data:`, and other kwargs do pass through. Merge the fixed defaults into `mixed_attrs` instead, so caller overrides apply, or drop the keys you intend to hardwire and raise on them so the failure is loud.</comment>

<file context>
@@ -0,0 +1 @@
+<%= render RubyUI::Toggle.new(variant: :default, size: :default, aria: {label: "Toggle theme"}, wrapper: {data: {controller: "ruby-ui--theme-toggle", action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply"}}, **component.mixed_attrs) do %><%= component.content %><% end %>
</file context>
Suggested change
<%= render RubyUI::Toggle.new(variant: :default, size: :default, aria: {label: "Toggle theme"}, wrapper: {data: {controller: "ruby-ui--theme-toggle", action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply"}}, **component.mixed_attrs) do %><%= component.content %><% end %>
<%= render RubyUI::Toggle.new(**RubyUI::Attributes.mix({variant: :default, size: :default, aria: {label: "Toggle theme"}, wrapper: {data: {controller: "ruby-ui--theme-toggle", action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply"}}}, component.mixed_attrs)) do %><%= component.content %><% end %>

## 12. `RegistryBuilder` reads a component's description from its first file — now a sidecar

- **Where:** `mcp/lib/ruby_ui/mcp/builders/registry_builder.rb`,
`extract_description`: with no `Docs::Header` and no markdown heading it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Issue 12's diagnosis of data_table is incomplete: its directory does contain a Docs::Header (in data_table_docs.rb, Docs::Header.new(title: ..., description: "A Hotwire-first data table. Every interaction (sort, search, pagination) ...")), so the "with no Docs::Header" framing does not apply to it. The description is really lost because extract_description's regex Docs::Header\.new\(([^)]*)\) stops at the first ), truncating the args at the parenthesis inside "(sort, search, pagination)", so extract_kwarg never finds the closing quote and the branch falls through to the fallback. The proposed fix (skip non-.rb files and the frozen_string_literal comment in the fallback) would therefore still not restore data_table's intended description — it would just yield "". Correct the note to also fix the Docs::Header extraction (match balanced parentheses or tolerate parens inside the quoted description), or the "Before 2.2" fix will not actually fix data_table.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At design/v2/follow-up-issues.md, line 161:

<comment>Issue 12's diagnosis of `data_table` is incomplete: its directory does contain a `Docs::Header` (in `data_table_docs.rb`, `Docs::Header.new(title: ..., description: "A Hotwire-first data table. Every interaction (sort, search, pagination) ...")`), so the "with no `Docs::Header`" framing does not apply to it. The description is really lost because `extract_description`'s regex `Docs::Header\.new\(([^)]*)\)` stops at the first `)`, truncating the args at the parenthesis inside "(sort, search, pagination)", so `extract_kwarg` never finds the closing quote and the branch falls through to the fallback. The proposed fix (skip non-`.rb` files and the `frozen_string_literal` comment in the fallback) would therefore still not restore `data_table`'s intended description — it would just yield `""`. Correct the note to also fix the `Docs::Header` extraction (match balanced parentheses or tolerate parens inside the quoted description), or the "Before 2.2" fix will not actually fix `data_table`.</comment>

<file context>
@@ -154,6 +155,19 @@ Ordered by user impact.
+## 12. `RegistryBuilder` reads a component's description from its first file — now a sidecar
+
+- **Where:** `mcp/lib/ruby_ui/mcp/builders/registry_builder.rb`,
+  `extract_description`: with no `Docs::Header` and no markdown heading it
+  takes the first comment line of `files.first`. Files are sorted, so
+  `x.html.erb` now precedes `x.rb` and the fallback finds no `#` line;
</file context>

template = RubyUI::Probes::Div.template
reads = 0
source = template.source
template.define_singleton_method(:source) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: test_the_sidecar_source_is_read_once_per_template_not_per_render permanently replaces source on the process-wide cached Div template via define_singleton_method and never restores it. The template object comes from the shared lookup cache (RubyUI::Probes::Div.templatelookup.find), so every later test in the process that touches this template sees the overridden method returning the captured snapshot instead of the real file-backed Template#source, and the only cleanup is the test ending. Add an ensure that removes the singleton method from template.singleton_class after the assertion, or build the counter without mutating the shared object (e.g. a fresh template).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/test/ruby_ui/component_test.rb, line 186:

<comment>`test_the_sidecar_source_is_read_once_per_template_not_per_render` permanently replaces `source` on the process-wide cached Div template via `define_singleton_method` and never restores it. The template object comes from the shared lookup cache (`RubyUI::Probes::Div.template` → `lookup.find`), so every later test in the process that touches this template sees the overridden method returning the captured snapshot instead of the real file-backed `Template#source`, and the only cleanup is the test ending. Add an `ensure` that removes the singleton method from `template.singleton_class` after the assertion, or build the counter without mutating the shared object (e.g. a fresh template).</comment>

<file context>
@@ -121,4 +121,84 @@ def test_a_class_outside_every_component_root_is_refused
+    template = RubyUI::Probes::Div.template
+    reads = 0
+    source = template.source
+    template.define_singleton_method(:source) do
+      reads += 1
+      source
</file context>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant