Conversation
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>
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
| # 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 |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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>
| 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 %> | |||
There was a problem hiding this comment.
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>
| <%= 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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).
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>
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::Componentbeside an.html.erbsidecar. 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 atmain's commit92f2619(a git source withglob:), 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 behindmain(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_indrops the sidecar file's final newline, which was reaching the page as a text node inside every parent — the check is read once per compiledTemplate, not per render (decision 12); a class without a sidecar renders its nearest ancestor's, so a host'sMyButton < RubyUI::Buttonkeeps working, and a class directly under a component root finds its own (decision 13). PlusComponentTest#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/ToggleGroupvariantandsize,ToggleGrouptypeandorientation) accept their String form and refuse unknown values, with tests;SelectValuekeeps whitespace-only content as Phlex did;DataTableSortHead's href keeps Phlex'sjavascript:guard; every caller-supplied attribute value goes throughAttributes.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 buildsdocs/against the gem atmain's92f2619.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::Componentwith an.html.erbsidecar, rendering byte-identical to the frozen 1.6 snapshots in all 22 golden scenarios.Layer changes
mixed_attrs, the nested Symbol-keyed hash a component forwards to a neighbor component, sodata-actionconcatenates as it did in 1.6 instead of producing two attributes.render_innow drops the sidecar file's final newline, read once per compiled template rather than per render.MyButton < RubyUI::Buttonkeep working.ComponentTest#erb, an inline template compiled through Herb for unit tests, and#descriptorfor Stimulus matching.Behavior details
DialogContent#size,Toggle/ToggleGroupvariant and size,ToggleGrouptype and orientation) accept String form and raise on unknown values instead of silently falling back to defaults.SelectValuekeeps whitespace-only content;DataTableSortHeadkeeps Phlex'sjavascript:href guard; nil attributes are omitted and Symbol keys dasherize throughAttributes.flat.docs/is pinned to the gem atmain's commit since RubyGems 1.6.0 is behind main; it points back atpath:in Phase 3.3.Written for commit 345c689. Summary will update on new commits.