From 8e735ccdc06c087238039790483339be3d4ce288 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 10:09:32 -0300 Subject: [PATCH 01/20] [Documentation] RubyUI 2.0: Phase 2.0a implementation plan Eight tasks with bite-sized steps: the inline Rails harness, the attribute layer with Phlex's guards, RubyUI::Component with a scoped sidecar lookup, the enum helper, the golden suite's ERB lane proved on Button's 15 fixtures, the strict lane with 36 recorded snapshots, the decision log, and the PR. Every mechanism was spiked before being planned: the scoped lookup ignores a host template at the same virtual path, phlex-rails renders a still-Phlex component from ERB with a captured block, Herb rejects a malformed sidecar, DetailsKey.clear picks up an edit, and the existing suite is unchanged under the new harness with RAILS_ENV=test. Co-Authored-By: Claude Fable 5.1 --- ...20-phase-2-0a-foundation-implementation.md | 2030 +++++++++++++++++ 1 file changed, 2030 insertions(+) create mode 100644 design/plans/2026-09-20-phase-2-0a-foundation-implementation.md diff --git a/design/plans/2026-09-20-phase-2-0a-foundation-implementation.md b/design/plans/2026-09-20-phase-2-0a-foundation-implementation.md new file mode 100644 index 00000000..1f4604b0 --- /dev/null +++ b/design/plans/2026-09-20-phase-2-0a-foundation-implementation.md @@ -0,0 +1,2030 @@ +# Phase 2.0a — Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put the 2.0 component layer, the test harness it needs, and the golden suite's ERB and strict lanes into the gem — with every component still Phlex — so that the fixtures plan (2.0b) and the migrations (2.1+) change only what they say they change. + +**Architecture:** A plain-Ruby `RubyUI::Component` (the 2.0 layer; it takes the name `Base` only when the last Phlex component is gone) renders an ERB sidecar found under the `RubyUI.component_roots` entry that contains its class file — never through the host's view paths. The gem's tests run inside a minimal inline `Rails::Application` so ReActionView's handler compiles every template through Herb exactly as a host app will. The golden suite gains an ERB lane (fixtures under `test/golden/views/`, rendered against the frozen snapshots — still-Phlex components render through `phlex-rails`) and a strict lane (the canonical form in preserve mode, for text-bearing components). Nothing under `gem/lib/ruby_ui//` changes. + +**Tech Stack:** Ruby 3.3 and 3.4, Minitest, ActionView / Railties 8.1, ReActionView 0.4, Herb 0.10, phlex-rails 2.4 (development only, for the transition), Nokogiri, tailwind_merge. + +**Spec:** `design/2026-09-19-rubyui-2-0-design.md` — this plan implements §6 "Phase 2.0 Foundation" with the mechanisms it left open now decided (Task 7 records them). Read §4, §6 "Phase 2", §9.1 and `design/v2/decisions.md` entries 1–4 before starting. + +## Global Constraints + +- Branch `v2/foundation`, created from `feat/golden-suite` (PR #536, unmerged); the PR for this plan targets `feat/golden-suite`. Never branch from or push to `main`. +- Work in `gem/`. Run every command from `gem/`, never from the repo root. `ENV["RAILS_ENV"]` is `test` for every test run (the helper sets it). +- Ruby 3.2+ syntax, 2-space indent, `snake_case` files, `CamelCase` classes. StandardRB is enforced and `bundle exec rake` runs it. +- **No file under `gem/lib/ruby_ui//` changes.** No component migrates in this plan. The only new files under `gem/lib/ruby_ui/` are `component.rb` and `attributes.rb`, at the top level. +- **No runtime dependency is added to `ruby_ui.gemspec`.** Every new dependency is `add_development_dependency`. The runtime dependencies arrive in Phase 2.4. +- Never hand-edit a file under `gem/test/golden/snapshots/` or `gem/test/golden/strict/`. The 188 golden snapshots do not change in this plan (`git status --porcelain gem/test/golden/snapshots` is empty at the end of every task). Strict snapshots are created only by `bundle exec rake golden:update`, in Task 6. +- `mcp/data/registry.json` embeds the source of every file under `gem/lib/ruby_ui//`. This plan adds no such file, so `cd mcp && bundle exec exe/ruby-ui-mcp-build && git diff --exit-code data/registry.json` must pass at the end of every task. Do not touch `docs/`. +- Never commit with `bundle exec rake` failing or skipping. +- Every commit message ends with a `Co-Authored-By:` line naming the model that wrote it. +- Expected lint counts follow StandardRB's rule of one per Ruby file: 410 on the branch today, and each task states its new total. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `gem/ruby_ui.gemspec` | Gains four development dependencies. Runtime dependencies stay empty. | +| `gem/test/test_helper.rb` | Boots the inline `RubyUI::TestApp`, configures ReActionView, sets `RubyUI.component_roots`, loads the probe components, exposes `RubyUI::TestApp.view(*paths)` and `ComponentTest#render_erb`. | +| `gem/test/erb_harness_test.rb` | Proves the harness: ReActionView's handler is registered under `Rails.root`, and a malformed template is refused at compile time. | +| `gem/lib/ruby_ui/attributes.rb` | The attribute layer: `mix`, `merge_classes`, `flat`, with Phlex 2.4.1's guards. Pure Ruby, no view context. | +| `gem/lib/ruby_ui/component.rb` | `RubyUI::Component` (`initialize`, `render_in`, `helpers`, `enum`), `RubyUI.component_roots`, the scoped template lookup. | +| `gem/test/ruby_ui/attributes_test.rb` | Unit tests for the guards. | +| `gem/test/ruby_ui/attributes_differential_test.rb` | `Attributes` against Phlex 2.4.1, canonical form to canonical form. | +| `gem/test/ruby_ui/component_test.rb` | The layer's behaviour, through probe components. | +| `gem/test/ruby_ui/enum_test.rb` | The `enum` helper. | +| `gem/test/probes/ruby_ui/probes/*.rb` + `.html.erb` | Test-only components under a second component root. Never shipped. | +| `gem/test/probes/views/**` | Test-only ERB views: the differential probe, the layer probes, a host template that must **not** shadow a sidecar. | +| `gem/test/golden/catalog.rb` | Scenarios gain a fixture path and a list of lanes; `component_classes` covers both bases. | +| `gem/test/golden/harness.rb` | `render_erb`; the pins and the coverage hook on `RubyUI::Component`. | +| `gem/test/golden_test.rb` | One test per scenario per lane; strict comparison where a strict snapshot exists; two more coverage tests. | +| `gem/test/golden/views//.html.erb` | The ERB lane's fixtures. This plan writes Button's 15; 2.0b writes the other 173. | +| `gem/test/golden/canonical_html.rb` | `strict:` mode. | +| `gem/test/golden/strict//.html` | Strict snapshots, 36 files, recorded from the Phlex lane. | +| `design/v2/decisions.md` | Entries 5–9. | +| `design/2026-09-19-rubyui-2-0-design.md` | §4.4 and §6 Phase 2.0 amended to what this plan built. | + +--- + +## Task 1: The harness — an inline Rails application + +ReActionView's handler reads `Rails.root` without a guard (`local_template?`, `project_path`), and a template outside `Rails.root` is "external": when Herb rejects it, `external_template_mode: :fallback` **silently recompiles it with Erubi**. So the gem's tests need a `Rails`, and its root must be the gem directory. The smallest honest thing is an inline `Rails::Application` — no `app/` directory, no routes, no database — booted from `test_helper.rb`. Measured on this branch: with it in place the existing suite stays at 500 runs, 0 failures, 0 snapshot changes, and `ActionView::Template.handler_for_extension(:erb)` is `ReActionView::Template::Handlers::ERB`. + +One trap, also measured: without `RAILS_ENV=test`, `Rails.env` is `development` and 1.6's `Base#before_template` emits ``, which fails six unit tests. The helper sets the env first. + +**Files:** +- Modify: `gem/ruby_ui.gemspec` +- Modify: `gem/Gemfile.lock` (by `bundle install`) +- Modify: `gem/test/test_helper.rb` +- Create: `gem/test/erb_harness_test.rb` +- Create: `gem/test/probes/views/probe/malformed.html.erb` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `RubyUI::TestApp` — the inline application; `Rails.root` is the gem directory, `Rails.env` is `test`. + - `RubyUI::TestApp.view(*paths)` → an `ActionView::Base` instance whose view paths are `paths` (default: `gem/test/probes/views`), sharing one compiled-template cache per process. `view.render(template: "probe/x")` renders `test/probes/views/probe/x.html.erb`; `view.render(object)` goes through `object.render_in`. + - `ComponentTest#render_erb(template)` → `RubyUI::TestApp.view.render(template:)`, a String. + - `ReActionView.config` set to `intercept_erb = true`, `validation_mode = :raise`, `debug_mode = false`. + +- [ ] **Step 1: Add the development dependencies** + +In `gem/ruby_ui.gemspec`, after the `nokogiri` line, add: + +```ruby + # 2.0 harness: the inline Rails application the tests boot, ReActionView's + # handler (so ERB compiles through Herb as it will in a host app), and + # phlex-rails so ERB fixtures can render components that are still Phlex + # during the migration. phlex-rails leaves with the last Phlex component. + s.add_development_dependency "railties", "~> 8.1" + s.add_development_dependency "actionview", "~> 8.1" + s.add_development_dependency "reactionview", "~> 0.4" + s.add_development_dependency "phlex-rails", "~> 2.4" +``` + +```bash +cd gem +bundle install +grep -E "^ (railties|actionview|reactionview|phlex-rails|herb) \(" Gemfile.lock +``` + +Expected: `railties (8.1.3.1)`, `actionview (8.1.3.1)`, `reactionview (0.4.1)`, `phlex-rails (2.4.0)`, `herb (0.10.4)` (plus herb's platform lines). If `reactionview` resolves to anything other than 0.4.x or `herb` to anything other than 0.10.x, STOP — the plan was measured against those. + +- [ ] **Step 2: Write the failing harness test** + +Create `gem/test/probes/views/probe/malformed.html.erb` with exactly: + +```erb +
+``` + +Create `gem/test/erb_harness_test.rb`: + +```ruby +# frozen_string_literal: true + +require "test_helper" + +# The gem's tests compile ERB the way a host application will: through +# ReActionView's handler, with Herb validating, under Rails.root. Everything +# the ERB lane and the component layer do later stands on these two facts. +class ErbHarnessTest < Minitest::Test + def test_erb_is_compiled_by_reactionview_under_rails_root + ActionView::Base # the handler is swapped when ActionView::Base loads + + assert_equal ReActionView::Template::Handlers::ERB, ActionView::Template.handler_for_extension(:erb) + assert_equal File.expand_path("..", __dir__), Rails.root.to_s + assert_equal "test", Rails.env + end + + def test_a_malformed_template_is_refused_at_compile_time + error = assert_raises(ActionView::SyntaxErrorInTemplate) do + RubyUI::TestApp.view.render(template: "probe/malformed") + end + + assert_match(/closing tag/i, error.cause.message) + end +end +``` + +- [ ] **Step 3: Run it and confirm it fails** + +```bash +cd gem +bundle exec rake test N=/ErbHarnessTest/ +``` + +Expected: 2 runs, 2 errors — `RubyUI::TestApp` is undefined (NameError) and `Rails` is not loaded. + +- [ ] **Step 4: Rewrite `test_helper.rb`** + +Replace `gem/test/test_helper.rb` with exactly: + +```ruby +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "ruby_ui" +require "phlex" +require "json" +require "securerandom" +require "rails" +require "action_controller/railtie" +require "reactionview" +require "phlex-rails" +require "minitest/autorun" + +module RubyUI + extend Phlex::Kit + + Dir.glob("lib/ruby_ui/**/*.rb").reject { |f| f.include?("/docs/") || f.end_with?("_docs.rb") }.map do |path| + class_name = path.split("/").last.delete_suffix(".rb").split("_").map(&:capitalize).join.to_sym + + autoload class_name, path + end + + # The smallest Rails application that gives ReActionView what it reads: + # `Rails.root` (a template under it is "local", so a Herb rejection raises + # instead of falling back to Erubi), `Rails.env` and `Rails.logger`. No app/ + # directory, no routes, no database — an object, so the gem's tests compile + # ERB exactly as a host application will. + class TestApp < Rails::Application + ROOT = File.expand_path("..", __dir__) + PROBE_VIEWS = File.join(ROOT, "test/probes/views") + + config.root = ROOT + config.eager_load = false + config.secret_key_base = "ruby_ui-test" + config.logger = Logger.new(IO::NULL) + config.hosts.clear + + class << self + # One compiled-template cache per process, as in an app; a fresh view + # context per call, with the given view paths. + def view(*paths) + view_class.with_view_paths(paths.empty? ? [PROBE_VIEWS] : paths) + end + + private + + def view_class + @view_class ||= ActionView::Base.with_empty_template_cache + end + end + end +end + +ReActionView.config.intercept_erb = true +ReActionView.config.validation_mode = :raise +ReActionView.config.debug_mode = false +Rails.application.initialize! + +class ComponentTest < Minitest::Test + def render(component, &) + component.call(&) + end + + def phlex(&) + render Phlex::HTML.new, & + end + + def render_erb(template) + RubyUI::TestApp.view.render(template: template) + end +end +``` + +- [ ] **Step 5: Run the harness test and confirm it passes** + +```bash +cd gem +bundle exec rake test N=/ErbHarnessTest/ +``` + +Expected: `2 runs, 4 assertions, 0 failures, 0 errors`. + +- [ ] **Step 6: Run everything and confirm nothing else moved** + +```bash +cd gem +bundle exec rake +``` + +Expected: `502 runs, ... 0 failures, 0 errors, 0 skips` and `411 files inspected, no offenses detected`. Then: + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots +cd mcp && bundle exec exe/ruby-ui-mcp-build >/dev/null && git diff --exit-code data/registry.json && echo "registry current" +``` + +Expected: no snapshot lines; `registry current`. If any golden test fails or a snapshot changed, STOP — phlex-rails or the Rails boot has altered what the Phlex lane renders, and that is a finding, not something to re-record. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/ruby_ui.gemspec gem/Gemfile.lock gem/test/test_helper.rb gem/test/erb_harness_test.rb gem/test/probes/views/probe/malformed.html.erb +git commit -m "$(cat <<'MSG' +[Feature] Test harness: boot an inline Rails application so ERB compiles through Herb + +ReActionView's handler reads Rails.root, and a template outside it is +recompiled with Erubi when Herb rejects it. The gem's tests now boot the +smallest Rails::Application — no app directory, no routes — with the gem +as its root, so every ERB template compiles exactly as it will in a host +app, and a malformed one is refused at compile time. + +RAILS_ENV is pinned to test: in development, 1.6's Base emits a +comment before every component and six unit tests fail on it. + +phlex-rails is a development dependency for the migration only, so the +ERB fixtures can render components that are still Phlex. + +Co-Authored-By: Claude Sonnet 5 +MSG +)" +``` + +--- + +## Task 2: `RubyUI::Attributes`, with Phlex's guards + +The attribute layer was written and proved in the gate (`experiments/v2-gate` on `v2-herb`): `mix` with `Phlex::Helpers#mix` semantics, `merge_classes` with tailwind_merge, `flat` with Phlex 2.4.1's serialization — 15 hash shapes and 8 mix shapes checked against Phlex, canonical form to canonical form. It comes over as is, plus the one thing the gate left out and the review made mandatory (spec §4.3): Phlex's attribute guards. Phlex raises on an unsafe attribute name and drops a `javascript:` URL; the gate's layer passed `href="javascript:alert(1)" onclick="alert(1)"` straight to the page. + +**Files:** +- Create: `gem/lib/ruby_ui/attributes.rb` (from `v2-herb`, then patched) +- Create: `gem/test/ruby_ui/attributes_test.rb` +- Create: `gem/test/ruby_ui/attributes_differential_test.rb` (from `v2-herb`, then patched) +- Create: `gem/test/probes/views/probe/attributes.html.erb` + +**Interfaces:** +- Consumes: `RubyUI::TestApp.view`, `Golden::CanonicalHtml.call` (Phase 1). +- Produces: + - `RubyUI::Attributes.mix(*hashes)` → Hash (Phlex `mix` semantics; `key!` replaces). + - `RubyUI::Attributes.merge_classes(value)` → String. + - `RubyUI::Attributes.flat(hash)` → `Hash[String, String]`, the shape `tag.attributes` takes; raises `ArgumentError` on an unsafe name or an invalid URL-attribute value; omits a URL attribute whose value is a `javascript:` URL. + - Constants `UNSAFE_ATTRIBUTES`, `REF_ATTRIBUTES`, `UNSAFE_ATTRIBUTE_NAME_CHARS`. + +- [ ] **Step 1: Bring the layer over** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git show v2-herb:experiments/v2-gate/app/components/ruby_ui/attributes.rb > gem/lib/ruby_ui/attributes.rb +git show v2-herb:experiments/v2-gate/test/attributes_differential_test.rb > gem/test/ruby_ui/attributes_differential_test.rb +``` + +- [ ] **Step 2: Write the failing guard tests** + +Create `gem/test/ruby_ui/attributes_test.rb`: + +```ruby +# frozen_string_literal: true + +require "test_helper" + +# Phlex 2.4.1's attribute guards, ported into the 2.0 layer so a component +# given untrusted values keeps the protection it has today. Each case mirrors +# phlex/sgml/attributes.rb; the differential test covers the happy path. +class AttributesTest < Minitest::Test + def flat(**attributes) + RubyUI::Attributes.flat(attributes) + end + + def test_an_event_handler_attribute_name_raises + assert_raises(ArgumentError) { flat(onclick: "x") } + assert_raises(ArgumentError) { flat("onClick" => "x") } + assert_raises(ArgumentError) { flat(onerror: "x") } + end + + def test_srcdoc_sandbox_and_http_equiv_raise + assert_raises(ArgumentError) { flat(srcdoc: "

") } + assert_raises(ArgumentError) { flat(sandbox: "") } + assert_raises(ArgumentError) { flat("http-equiv" => "refresh") } + end + + def test_a_name_with_forbidden_characters_raises_at_any_level + assert_raises(ArgumentError) { flat("bad name" => "1") } + assert_raises(ArgumentError) { flat("a=b" => "1") } + assert_raises(ArgumentError) { flat(data: {"x y" => "1"}) } + end + + def test_a_javascript_url_is_dropped_from_a_url_attribute + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(src: "JavaScript:alert(1)")) + assert_equal({}, flat(href: " java\nscript:alert(1)")) + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(formaction: "javascript:alert(1)")) + end + + def test_an_ordinary_url_is_kept + assert_equal({"href" => "/edit"}, flat(href: "/edit")) + assert_equal({"href" => "https://example.com/?q=javascript"}, flat(href: "https://example.com/?q=javascript")) + assert_equal({"src" => "javascript-guide.png"}, flat(src: "javascript-guide.png")) + end + + def test_a_non_string_value_for_a_url_attribute_raises + assert_raises(ArgumentError) { flat(href: 1) } + assert_raises(ArgumentError) { flat(href: :edit) } + assert_raises(ArgumentError) { flat(href: {}) } + end + + def test_true_is_allowed_on_a_url_attribute + assert_equal({"href" => ""}, flat(href: true)) + end + + def test_a_nested_on_key_is_not_an_event_handler + # Phlex checks `on*` only on top-level names; `data-onclick` is a plain + # data attribute and stays one here. + assert_equal({"data-onclick" => "x"}, flat(data: {onclick: "x"})) + end + + def test_the_guard_does_not_touch_ordinary_attributes + assert_equal({"class" => "a b", "data-open" => "", "aria-label" => "L"}, + flat(class: ["a", "b"], data: {open: true}, aria: {label: "L"})) + end +end +``` + +- [ ] **Step 3: Run them and confirm the guard tests fail** + +```bash +cd gem +bundle exec rake test N=/AttributesTest/ +``` + +Expected: 9 runs; `test_an_ordinary_url_is_kept`, `test_true_is_allowed_on_a_url_attribute`, `test_a_nested_on_key_is_not_an_event_handler` and `test_the_guard_does_not_touch_ordinary_attributes` pass (the ported layer already does that); the other 5 FAIL — nothing raises, nothing is dropped. + +- [ ] **Step 4: Patch `attributes.rb` with the guards** + +In `gem/lib/ruby_ui/attributes.rb`: + +**(a)** Replace the comment paragraph that begins `# Not reproduced: Phlex's guards` (three lines, ending `depend on either.`) with: + +```ruby + # Phlex's guards (phlex/sgml/attributes.rb) are ported, so a component given + # untrusted values keeps the protection it has today: a name with `<>&"'/=`, + # whitespace or NUL raises; `srcdoc`, `sandbox`, `http-equiv` and any `on*` + # handler name raise; a URL-bearing attribute (`href`, `src`, `action`, …) + # whose decoded value starts with `javascript:` is dropped, and a non-String + # value for one of them raises. Not ported: Phlex's `:id`-must-be-a-lowercase- + # Symbol check (a Phlex convention) and the leading space it leaves when the + # first `style:` value is nil (no snapshot depends on it). +``` + +**(b)** After `TAILWIND_MERGER = TailwindMerge::Merger.new.freeze`, add: + +```ruby + UNSAFE_ATTRIBUTES = Set.new(%w[srcdoc sandbox http-equiv]).freeze + REF_ATTRIBUTES = Set.new(%w[href src action formaction lowsrc dynsrc background ping xlinkhref]).freeze + UNSAFE_ATTRIBUTE_NAME_CHARS = %r([<>&"'/=\s\x00]) + + # The character references decoded before the `javascript:` check. Phlex + # decodes every named reference; these are the numeric forms plus the + # named ones that can hide a scheme separator. + NAMED_REFERENCES = { + "colon" => ":", "tab" => "\t", "newline" => "\n", + "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" + }.freeze +``` + +**(c)** In `flat`, replace the two lines + +```ruby + name = key_name(key) + case value +``` + +with + +```ruby + name = key_name(key) + next if guard(name, value) == :drop + + case value +``` + +**(d)** In `nested`, replace the line that computes `name` — it begins `name = (key == :_) ?` — so that it is followed by the name-character check: + +```ruby + name = (key == :_) ? prefix.delete_suffix("-") : "#{prefix}#{key_name(key)}" + raise ArgumentError, "unsafe attribute name #{name.inspect}" if name.match?(UNSAFE_ATTRIBUTE_NAME_CHARS) + +``` + +**(e)** Add two private methods after `key_name`: + +```ruby + # :keep or :drop. Raises for the names Phlex refuses. + def guard(name, value) + raise ArgumentError, "unsafe attribute name #{name.inspect}" if name.match?(UNSAFE_ATTRIBUTE_NAME_CHARS) + + normalized = name.downcase.delete("^a-z-") + if UNSAFE_ATTRIBUTES.include?(normalized) || + (normalized.bytesize > 2 && normalized.start_with?("on") && !normalized.include?("-")) + raise ArgumentError, "unsafe attribute name #{name.inspect}" + end + + return :keep unless value != true && REF_ATTRIBUTES.include?(normalized) + raise ArgumentError, "invalid value for #{name}: #{value.inspect}" unless value.is_a?(String) + + decode_references(value).downcase.delete("^a-z:").start_with?("javascript:") ? :drop : :keep + end + + def decode_references(value) + value + .gsub(/&#x([0-9a-f]+);?/i) { [$1.to_i(16)].pack("U*") } + .gsub(/&#(\d+);?/) { [$1.to_i].pack("U*") } + .gsub(/&([a-z][a-z0-9]+);?/i) { NAMED_REFERENCES[$1.downcase] || "" } + end +``` + +- [ ] **Step 5: Run the guard tests and confirm all nine pass** + +```bash +cd gem +bundle exec rake test N=/AttributesTest/ +``` + +Expected: `9 runs, ... 0 failures, 0 errors`. + +- [ ] **Step 6: Adapt the differential test to the harness** + +Create `gem/test/probes/views/probe/attributes.html.erb` with exactly: + +```erb +

>
+``` + +In `gem/test/ruby_ui/attributes_differential_test.rb`: + +- Replace the `require "test_helper"` / `require "phlex"` lines at the top with: + + ```ruby + require "test_helper" + require "golden/canonical_html" + ``` + +- Replace `class AttributesDifferentialTest < ActiveSupport::TestCase` with `class AttributesDifferentialTest < Minitest::Test`. +- Every `test "…" do` block becomes `define_method(:"test_…") do`. Concretely: `test "flat: #{label}" do` → `define_method(:"test_flat_#{label.tr(" ", "_")}") do`; `test "mix ##{index + 1}: …" do` → `define_method(:"test_mix_#{index + 1}") do`; the last one, `test "merge_classes accepts …" do` → `def test_merge_classes_applies_tailwind_merge_like_1_6_base`. +- Replace the `canonical_erb` method body so it renders through the harness: + + ```ruby + def canonical_erb(attributes) + Golden::CanonicalHtml.call(RubyUI::TestApp.view.render(template: "probe/attributes", locals: {h: attributes})) + end + ``` + +- [ ] **Step 7: Run the differential test and confirm it passes** + +```bash +cd gem +bundle exec rake test N=/AttributesDifferentialTest/ +``` + +Expected: `24 runs, ... 0 failures, 0 errors` — 15 flat cases, 8 mix cases, 1 merge case. If any flat or mix case fails, STOP: the layer disagrees with Phlex 2.4.1 for that shape and the disagreement is the finding. + +- [ ] **Step 8: Run everything** + +```bash +cd gem +bundle exec rake +``` + +Expected: `535 runs, ... 0 failures, 0 errors, 0 skips`, `414 files inspected, no offenses detected`. Snapshots unchanged; registry current (`attributes.rb` is a top-level file, not under a component directory, so the builder does not embed it): + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots +cd mcp && bundle exec exe/ruby-ui-mcp-build >/dev/null && git diff --exit-code data/registry.json && echo "registry current" +``` + +- [ ] **Step 9: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/lib/ruby_ui/attributes.rb gem/test/ruby_ui/attributes_test.rb gem/test/ruby_ui/attributes_differential_test.rb gem/test/probes/views/probe/attributes.html.erb +git commit -m "$(cat <<'MSG' +[Feature] RubyUI::Attributes: the 2.0 attribute layer, with Phlex's guards + +mix, merge_classes and flat come from the v2 gate, where they were +checked against Phlex 2.4.1 for 15 hash shapes and 8 mix shapes; that +differential test comes with them. Added on top: Phlex's attribute +guards — an unsafe name raises, a javascript: URL in a URL attribute is +dropped — so a component given untrusted values keeps the protection +it has in 1.6. + +Co-Authored-By: Claude Sonnet 5 +MSG +)" +``` + +--- + +## Task 3: `RubyUI::Component` and the scoped sidecar lookup + +The 2.0 layer is a plain Ruby class that ActionView renders through `render_in`. Its sidecar is looked up under the entry of `RubyUI.component_roots` that contains the class file — `lib` in the gem, `app/components` in a host app — through a `LookupContext` of its own, never through the application's view paths. Measured in a spike on this branch: a host template at the same virtual path is not picked; an ERB block with a parameter is captured; a second render without a block has `content == nil`; an edit to the sidecar is picked up after `ActionView::LookupContext::DetailsKey.clear` (the resolver is registered with `ActionView::PathRegistry`, so Rails' reloader clears it). + +The class is named `Component` for the duration of Phase 2. `RubyUI::Base` is the Phlex base that 256 components still inherit; two classes cannot share the name. `Component` becomes `Base` in one commit in Phase 2.4, when no Phlex component is left (decision 6, Task 7). + +**Files:** +- Create: `gem/lib/ruby_ui/component.rb` +- Create: `gem/test/probes/ruby_ui/probes/div.rb`, `div.html.erb`, `rootless.rb`, `rootless.html.erb`, `with_id.rb`, `with_id.html.erb`, `frame.rb`, `frame.html.erb`, `fallback.rb`, `fallback.html.erb`, `bare.rb` (no sidecar, on purpose) +- Create: `gem/test/probes/views/probe/div_default.html.erb`, `div_block_arg.html.erb`, `div_nested.html.erb`, `fallback_empty.html.erb`, `fallback_blank.html.erb`, `fallback_text.html.erb` +- Create: `gem/test/probes/views/ruby_ui/probes/div.html.erb` (the host decoy) +- Create: `gem/test/ruby_ui/component_test.rb` +- Modify: `gem/test/test_helper.rb` (component roots, probe loading) + +**Interfaces:** +- Consumes: `RubyUI::Attributes` (Task 2), `RubyUI::TestApp.view`, `ComponentTest#render_erb` (Task 1). +- Produces: + - `RubyUI.component_roots` → `Array`; `RubyUI.component_roots = [...]`. + - `RubyUI.lookup_for(root)` → memoized `ActionView::LookupContext` scoped to `root`. + - `RubyUI::Component#initialize(**attrs)`, `#attrs` (`Hash[String, String]`), `#render_in(view_context, **, &block)`, `#content` (`ActiveSupport::SafeBuffer` or `nil`), `#helpers` (the view context; raises outside `render_in`), private `#default_attrs` (`{}`). + - `RubyUI::Component.template` → `ActionView::Template` for the sidecar; raises `ArgumentError` naming the missing path or the roots. + - The `component` local inside every sidecar. + - Probe components `RubyUI::Probes::{Div, Rootless, WithId, Frame, Fallback, Bare}` under the root `gem/test/probes`. + +- [ ] **Step 1: Write the layer** + +Create `gem/lib/ruby_ui/component.rb`: + +```ruby +# frozen_string_literal: true + +require "action_view" +require_relative "attributes" + +module RubyUI + class << self + # The directories that hold `ruby_ui/`: `app/components` in a host + # application, `lib` in this gem. A component's sidecar template is looked + # up under the root that contains its class file, and nowhere else — the + # application's view paths are never consulted, so a host template at the + # same virtual path cannot shadow it and it cannot shadow the host. + def component_roots + @component_roots ||= [] + end + + attr_writer :component_roots + + def lookup_for(root) + (@lookups ||= {})[root] ||= ActionView::LookupContext.new( + ActionView::PathRegistry.cast_file_system_resolvers([root]), {formats: [:html]} + ) + end + end + + # The 2.0 component layer: a plain Ruby object that ActionView renders + # through `render_in`, with an ERB sidecar template next to the class file. + # + # # app/components/ruby_ui/dialog/dialog.rb + # class RubyUI::Dialog < RubyUI::Component + # def initialize(open: false, **attrs) + # @open = open + # super(**attrs) + # end + # + # private def default_attrs + # {data: {controller: "ruby-ui--dialog", ruby_ui__dialog_open_value: @open}} + # end + # end + # + # # app/components/ruby_ui/dialog/dialog.html.erb + #
><%= component.content %>
+ # + # # a view + # <%= render RubyUI::Dialog.new(open: true) do %> ... <% end %> + # + # `attrs` is computed in `initialize` with no view context — mix, Tailwind + # merge, then Phlex-semantics serialization (see Attributes) — so a component + # can read a neighbour's computed attributes (`Button.new(...).attrs["class"]`). + # `render_in` captures the caller's block with the component as the block + # argument (for `do |group|` components), then renders the sidecar with + # `component` as its only local. Nothing else: no named slots, no DSL. + # + # Named `Component` while the Phlex `RubyUI::Base` still exists; it takes + # the name `Base` when the last Phlex component is gone. + class Component + attr_reader :attrs, :content + + def initialize(**user_attrs) + mixed = Attributes.mix(default_attrs, user_attrs) + mixed[:class] = Attributes.merge_classes(mixed[:class]) if mixed[:class] + @attrs = Attributes.flat(mixed) + end + + # ActionView's renderable protocol. Rails passes `locals:`; the caller's + # locals are not the component's, so they are accepted and ignored. + # `content` is set on every call — nil without a block — so an instance + # rendered twice never repeats its first content. + def render_in(view_context, **, &block) + @view_context = view_context + @content = block ? view_context.capture(self, &block) : nil + self.class.template.render(view_context, {component: self}) + end + + # The view context, for a component that needs a Rails helper from Ruby + # (`helpers.form_authenticity_token`) or renders a neighbour from a method. + # Only available during render_in. + def helpers + @view_context or raise ArgumentError, "#{self.class.name} has no view context outside render_in" + end + + class << self + # Looked up on every render, not cached here: the resolver behind the + # lookup context caches compiled templates and Rails' reloader clears it, + # so a Template cached on the class would outlive an edit in development. + def template + root = component_root + relative = source_file.delete_prefix("#{root}/").delete_suffix(".rb") + prefix, base = File.split(relative) + RubyUI.lookup_for(root).find(base, [prefix], false, [:component]) + rescue ActionView::MissingTemplate + raise ArgumentError, "#{name} has no sidecar template at #{relative}.html.erb under #{root}" + end + + def source_file + @source_file ||= Object.const_source_location(name)&.first or + raise ArgumentError, "#{name}: no source location to derive a sidecar template from" + end + + def component_root + @component_root ||= RubyUI.component_roots.map(&:to_s).find { |root| source_file.start_with?("#{root}/") } or + raise ArgumentError, "#{name}: #{source_file} is under none of RubyUI.component_roots #{RubyUI.component_roots.inspect}" + end + end + + private + + def default_attrs + {} + end + end +end +``` + +- [ ] **Step 2: Write the probe components** + +Every file below is under `gem/test/probes/ruby_ui/probes/`. + +`div.rb`: + +```ruby +# frozen_string_literal: true + +module RubyUI + module Probes + # One element with attributes and content: the shape of most components. + class Div < Component + private + + def default_attrs + {class: "probe", data: {probe: true}} + end + end + end +end +``` + +`div.html.erb`: + +```erb +
><%= component.content %>
+``` + +`rootless.rb`: + +```ruby +# frozen_string_literal: true + +module RubyUI + module Probes + # Renders no root element at all when hidden — DataTablePagination's shape. + class Rootless < Component + def initialize(shown: true, **attrs) + @shown = shown + super(**attrs) + end + + def shown? = @shown + end + end +end +``` + +`rootless.html.erb`: + +```erb +<% if component.shown? %>>shown<% end %> +``` + +`with_id.rb`: + +```ruby +# frozen_string_literal: true + +module RubyUI + module Probes + # Mints an id and cross-references it — SelectContent's shape. + class WithId < Component + attr_reader :id + + def initialize(**attrs) + @id = "content#{SecureRandom.hex(4)}" + super(**attrs) + end + end + end +end +``` + +`with_id.html.erb`: + +```erb +
<%= component.content %>
+``` + +`frame.rb`: + +```ruby +# frozen_string_literal: true + +module RubyUI + module Probes + # A custom-element root — DataTableFrame's shape. + class Frame < Component + private + + def default_attrs + {id: "frame"} + end + end + end +end +``` + +`frame.html.erb`: + +```erb +><%= component.content %> +``` + +`fallback.rb`: + +```ruby +# frozen_string_literal: true + +module RubyUI + module Probes + # Content or a placeholder — SelectValue's shape. + class Fallback < Component + attr_reader :placeholder + + def initialize(placeholder:, **attrs) + @placeholder = placeholder + super(**attrs) + end + end + end +end +``` + +`fallback.html.erb`: + +```erb +<%= component.content.presence || component.placeholder %> +``` + +`bare.rb` — deliberately without a sidecar: + +```ruby +# frozen_string_literal: true + +module RubyUI + module Probes + # Has no sidecar on purpose: the lookup must say so, by name and path. + class Bare < Component + end + end +end +``` + +- [ ] **Step 3: Write the probe views** + +Under `gem/test/probes/views/probe/`: + +`div_default.html.erb`: + +```erb +<%= render RubyUI::Probes::Div.new do %>Hello<% end %> +``` + +`div_block_arg.html.erb`: + +```erb +<%= render RubyUI::Probes::Div.new(id: "outer") do |div| %>id=<%= div.attrs["id"] %><% end %> +``` + +`div_nested.html.erb`: + +```erb +<%= render RubyUI::Probes::Div.new(id: "outer") do |outer| %><%= render RubyUI::Probes::Div.new(id: "inner") do %>in <%= outer.attrs["id"] %><% end %><% end %> +``` + +`fallback_empty.html.erb`: + +```erb +<%= render RubyUI::Probes::Fallback.new(placeholder: "Pick one") do %><% end %> +``` + +`fallback_blank.html.erb`: + +```erb +<%= render RubyUI::Probes::Fallback.new(placeholder: "Pick one") do %> +<% end %> +``` + +`fallback_text.html.erb`: + +```erb +<%= render RubyUI::Probes::Fallback.new(placeholder: "Pick one") do %>Apple<% end %> +``` + +And the host decoy, `gem/test/probes/views/ruby_ui/probes/div.html.erb` — a template at exactly the virtual path of `Div`'s sidecar, on the harness's view path, which a view-path lookup would pick and the scoped lookup must not: + +```erb +HOST SHADOW +``` + +- [ ] **Step 4: Wire the roots and the probes into the helper** + +In `gem/test/test_helper.rb`, after `Rails.application.initialize!`, add: + +```ruby +# Two component roots: the gem's own components, and the test-only probes. +# A class's sidecar is looked up under the root that contains the class file. +RubyUI.component_roots = [File.join(RubyUI::TestApp::ROOT, "lib"), File.join(RubyUI::TestApp::ROOT, "test/probes")] +Dir.glob(File.join(RubyUI::TestApp::ROOT, "test/probes/ruby_ui/**/*.rb")).sort.each { |probe| require probe } +``` + +- [ ] **Step 5: Write the failing layer tests** + +Create `gem/test/ruby_ui/component_test.rb`: + +```ruby +# frozen_string_literal: true + +require "test_helper" +require "golden/canonical_html" + +# The 2.0 component layer, through the probe components under test/probes. +# Output is compared in canonical form where whitespace is irrelevant and raw +# where it is the point. Inherits the helper base for render_erb. +class LayerTest < ComponentTest + def canonical(html) + Golden::CanonicalHtml.call(html) + end + + def view + RubyUI::TestApp.view + end + + def test_attrs_are_computed_with_no_view_context + assert_equal({"class" => "probe", "data-probe" => ""}, RubyUI::Probes::Div.new.attrs) + end + + def test_caller_classes_merge_over_defaults_and_nil_keeps_the_default + assert_equal "probe p-4", RubyUI::Probes::Div.new(class: "p-4").attrs["class"] + assert_equal "probe", RubyUI::Probes::Div.new(class: nil).attrs["class"] + assert_equal "p-4", RubyUI::Probes::Div.new(class!: "p-4").attrs["class"] + end + + def test_renders_the_sidecar_with_attributes_and_content + assert_equal canonical(%(
Hello
)), canonical(render_erb("probe/div_default")) + end + + def test_content_is_nil_when_rendered_without_a_block_even_after_a_render_with_one + component = RubyUI::Probes::Div.new + view.render(component) { "first" } + + assert_equal canonical(%(
)), canonical(view.render(component)) + end + + def test_the_block_receives_the_component + assert_includes render_erb("probe/div_block_arg"), "id=outer" + end + + def test_a_component_renders_inside_another_components_block + html = render_erb("probe/div_nested") + + assert_includes html, %(id="inner") + assert_includes html, "in outer" + end + + def test_a_component_can_render_nothing + assert_equal "", view.render(RubyUI::Probes::Rootless.new(shown: false)).strip + assert_includes view.render(RubyUI::Probes::Rootless.new), "shown" + end + + def test_a_minted_id_and_its_reference_line_up + html = view.render(RubyUI::Probes::WithId.new) { "body" } + id = html[/id="(content[0-9a-f]{8})"/, 1] + + refute_nil id + assert_includes html, %(aria-controls="#{id}") + end + + def test_a_custom_element_root + assert_equal canonical(%(x)), canonical(view.render(RubyUI::Probes::Frame.new) { "x" }) + end + + def test_content_presence_falls_back_to_the_placeholder + assert_equal canonical("Pick one"), canonical(render_erb("probe/fallback_empty")) + assert_equal canonical("Pick one"), canonical(render_erb("probe/fallback_blank")) + assert_equal canonical("Apple"), canonical(render_erb("probe/fallback_text")) + end + + def test_helpers_raises_outside_render_in + error = assert_raises(ArgumentError) { RubyUI::Probes::Div.new.helpers } + + assert_match(/no view context/, error.message) + end + + def test_a_missing_sidecar_is_named + error = assert_raises(ArgumentError) { view.render(RubyUI::Probes::Bare.new) } + + assert_match(%r{ruby_ui/probes/bare\.html\.erb}, error.message) + end + + def test_a_host_template_at_the_same_virtual_path_is_not_picked + refute_includes render_erb("probe/div_default"), "HOST SHADOW" + end + + def test_overlapping_roots_resolve_the_same_sidecar + # With the gem directory itself as a root ahead of test/probes, Div's file + # matches the wider root first; the relative path grows, the sidecar found + # is the same one. + original = RubyUI.component_roots + RubyUI.component_roots = [RubyUI::TestApp::ROOT, *original] + RubyUI::Probes::Div.instance_variable_set(:@component_root, nil) + + assert_equal canonical(%(
Hello
)), canonical(render_erb("probe/div_default")) + ensure + RubyUI.component_roots = original + RubyUI::Probes::Div.instance_variable_set(:@component_root, nil) + end + + class Homeless < RubyUI::Component + end + + def test_a_class_outside_every_component_root_is_refused + error = assert_raises(ArgumentError) { Homeless.template } + + assert_match(/component_roots/, error.message) + end +end +``` + +- [ ] **Step 6: Run them and confirm they fail** + +```bash +cd gem +bundle exec rake test N=/LayerTest/ +``` + +Expected: errors — `RubyUI::Component` does not exist yet if Step 1 was skipped; with Step 1 in place and Step 4 not yet applied, `RubyUI::Probes` is undefined. Every test must be red before Step 4 is applied. (Apply Steps 1–3 first, run, observe the `NameError`s, then apply Step 4.) + +- [ ] **Step 7: Run them and confirm they pass** + +```bash +cd gem +bundle exec rake test N=/LayerTest/ +``` + +Expected: `15 runs, ... 0 failures, 0 errors`. + +- [ ] **Step 8: Run everything** + +```bash +cd gem +bundle exec rake +``` + +Expected: `550 runs, ... 0 failures, 0 errors, 0 skips`, `422 files inspected, no offenses detected` (414 + `component.rb` + `component_test.rb` + six probe `.rb` files). Snapshots unchanged; registry current. + +- [ ] **Step 9: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/lib/ruby_ui/component.rb gem/test/test_helper.rb gem/test/probes gem/test/ruby_ui/component_test.rb +git commit -m "$(cat <<'MSG' +[Feature] RubyUI::Component: the 2.0 layer with a scoped sidecar lookup + +A plain Ruby class ActionView renders through render_in. Its sidecar is +found under the RubyUI.component_roots entry that holds the class file, +through a LookupContext of its own — the application's view paths are +never consulted, so a host template at the same virtual path cannot +shadow the sidecar and the sidecar cannot shadow the host. content is +set on every render, nil without a block. + +Named Component while the Phlex RubyUI::Base still exists; it takes the +name Base when the last Phlex component is gone. + +Probe components under test/probes exercise the layer without touching +a shipped component. + +Co-Authored-By: Claude Sonnet 5 +MSG +)" +``` + +--- + +## Task 4: The `enum` helper + +Decision B: components coerce and validate their enumerated attributes. `size: "lg"` arrives as a String from a tag or from `params`, `size: :lg` as a Symbol from Ruby; both must select `SIZES[:lg]`, `nil` takes the default, and anything else names the allowed values instead of dropping the class silently (the 1.6 bug in `DialogContent` and `Badge`). + +**Files:** +- Modify: `gem/lib/ruby_ui/component.rb` +- Create: `gem/test/ruby_ui/enum_test.rb` + +**Interfaces:** +- Consumes: `RubyUI::Component` (Task 3). +- Produces: `RubyUI::Component#enum(value, table, default:)` → the Symbol key; raises `ArgumentError` listing `table.keys` for an unknown value. Private, for use in a subclass's `initialize`. + +- [ ] **Step 1: Write the failing tests** + +Create `gem/test/ruby_ui/enum_test.rb`: + +```ruby +# frozen_string_literal: true + +require "test_helper" + +class EnumTest < Minitest::Test + class Sized < RubyUI::Component + SIZES = {sm: "h-8", md: "h-9", lg: "h-10"}.freeze + + attr_reader :size + + def initialize(size: nil, **attrs) + @size = enum(size, SIZES, default: :md) + super(**attrs) + end + end + + def test_a_symbol_selects_its_entry + assert_equal :lg, Sized.new(size: :lg).size + end + + def test_a_string_is_coerced_to_the_symbol + assert_equal :lg, Sized.new(size: "lg").size + end + + def test_nil_takes_the_default + assert_equal :md, Sized.new.size + assert_equal :md, Sized.new(size: nil).size + end + + def test_an_unknown_value_raises_naming_the_allowed_ones + error = assert_raises(ArgumentError) { Sized.new(size: "xlg") } + + assert_match(/"xlg"/, error.message) + assert_match(/:sm, :md, :lg/, error.message) + end + + def test_a_value_that_cannot_be_a_symbol_raises_the_same_way + error = assert_raises(ArgumentError) { Sized.new(size: 42) } + + assert_match(/42/, error.message) + end +end +``` + +- [ ] **Step 2: Run them and confirm they fail** + +```bash +cd gem +bundle exec rake test N=/EnumTest/ +``` + +Expected: 5 runs, 5 errors — `NoMethodError: undefined method 'enum'`. + +- [ ] **Step 3: Add the helper** + +In `gem/lib/ruby_ui/component.rb`, inside `class Component`, in the `private` section after `default_attrs`, add: + +```ruby + # Coerces and validates an enumerated attribute. `size: "lg"` from a tag or + # from params arrives as a String, `size: :lg` from Ruby as a Symbol, and + # both must select `table[:lg]`; nil takes the default. Anything else names + # the allowed values instead of silently dropping the class. + def enum(value, table, default:) + key = value.nil? ? default : (value.respond_to?(:to_sym) ? value.to_sym : value) + return key if table.key?(key) + + raise ArgumentError, + "#{self.class.name}: #{value.inspect} is not one of #{table.keys.map(&:inspect).join(", ")}" + end +``` + +- [ ] **Step 4: Run them and confirm they pass** + +```bash +cd gem +bundle exec rake test N=/EnumTest/ +``` + +Expected: `5 runs, ... 0 failures, 0 errors`. + +- [ ] **Step 5: Run everything and commit** + +```bash +cd gem +bundle exec rake +``` + +Expected: `555 runs, ... 0 failures, 0 errors, 0 skips`, `423 files inspected, no offenses detected`. Snapshots unchanged; registry current. + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/lib/ruby_ui/component.rb gem/test/ruby_ui/enum_test.rb +git commit -m "$(cat <<'MSG' +[Feature] RubyUI::Component#enum: coerce and validate enumerated attributes + +size: "lg" from a tag or params and size: :lg from Ruby both select +SIZES[:lg]; nil takes the default; anything else raises naming the +allowed values, instead of indexing a Symbol-keyed hash with a String +and silently dropping the class, as DialogContent and Badge do in 1.6. + +Co-Authored-By: Claude Sonnet 5 +MSG +)" +``` + +--- + +## Task 5: The golden suite's ERB lane, proved on Button + +Every scenario keeps its Phlex block. A scenario may also have an ERB fixture at `test/golden/views//.html.erb`; when it does, the suite renders the fixture through the harness and compares it against the **same frozen snapshot**. The two lanes are independent tests. Because `phlex-rails` is loaded, a fixture can render a component that is still Phlex — so the fixtures for all 188 scenarios can be written and made green before a single component migrates (decision 7). This task builds the lane and writes Button's 15 fixtures as the proof; plan 2.0b writes the other 173. + +During the transition the Phlex lane records (`golden:update` writes from it when a scenario has a block); the ERB lane only compares. When the last block goes, the ERB lane records. + +**Files:** +- Modify: `gem/test/golden/catalog.rb` +- Modify: `gem/test/golden/harness.rb` +- Modify: `gem/test/golden_test.rb` +- Create: `gem/test/golden/views/button/*.html.erb` (15 files) + +**Interfaces:** +- Consumes: `RubyUI::TestApp.view(*paths)` (Task 1); `RubyUI::Component` (Task 3) for the pins and the coverage hook. +- Produces: + - `Golden::Catalog::VIEWS_ROOT`; `Scenario#fixture_path`, `#fixture?`, `#lanes` (`[:phlex, :erb]` subset), `#test_name(lane)`. + - `Golden::Catalog.scenario(name, pending: nil, &block)` — the block is now optional (a scenario may exist only as a fixture, from 2.0b on). + - `Golden::Catalog.fixture_files` → every `.html.erb` under `VIEWS_ROOT`. + - `Golden::Harness.render_erb(scenario)` → raw HTML, with the pins active. + - `Golden::Catalog.component_classes` covers `RubyUI::Base` and `RubyUI::Component` subclasses. + +- [ ] **Step 1: Extend the catalog** + +In `gem/test/golden/catalog.rb`: + +**(a)** After `LIB_ROOT = …`, add: + +```ruby + VIEWS_ROOT = File.expand_path("views", __dir__) +``` + +**(b)** Inside the `Scenario` struct, after `test_name`, add: + +```ruby + def fixture_path + File.join(VIEWS_ROOT, component, "#{name}.html.erb") + end + + def fixture? + File.exist?(fixture_path) + end + + # The renderers this scenario runs through: its Phlex block while it has + # one, its ERB fixture once it has one. Both compare against one snapshot. + def lanes + [(:phlex if block), (:erb if fixture?)].compact + end + + def test_name(lane = nil) + lane ? :"test_#{component}__#{name}__#{lane}" : :"test_#{component}__#{name}" + end +``` + +and delete the original `test_name` method (the one with no parameter). + +**(c)** In `scenario`, the block is now optional; replace the method with: + +```ruby + def scenario(name, pending: nil, &block) + raise "scenario #{name.inspect} declared outside a component block" unless @component + + slug = "#{@component}/#{name}" + raise "duplicate scenario #{slug}" if scenarios.any? { |existing| existing.slug == slug } + + scenarios << Scenario.new(@component, name.to_s, block, pending) + end +``` + +(identical to the current body — the change is that `&block` may be nil, which `Scenario#lanes` handles.) + +**(d)** Add a coverage query next to `component_directories`: + +```ruby + def fixture_files + Dir.glob(File.join(VIEWS_ROOT, "**", "*.html.erb")).sort + end +``` + +**(e)** In `component_classes`, replace + +```ruby + .select { |constant| constant.is_a?(Class) && constant < RubyUI::Base } +``` + +with + +```ruby + .select { |constant| constant.is_a?(Class) && (constant < RubyUI::Base || constant < RubyUI::Component) } +``` + +- [ ] **Step 2: Extend the harness** + +In `gem/test/golden/harness.rb`: + +**(a)** After the `render` method, add: + +```ruby + # The ERB lane: renders a scenario's fixture through the same harness a + # host application's templates go through, with the same pins active. + def render_erb(scenario) + @active = true + @hex_calls = 0 + @rand_calls = 0 + RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT).render(template: "#{scenario.component}/#{scenario.name}") + ensure + @active = false + end +``` + +**(b)** After the `RecordsRenderedClass` module, add: + +```ruby + # The 2.0 layer has no before_template; render_in is the hook that fires on + # every render and nothing overrides. + module RecordsRenderedComponent + def render_in(...) + Golden::Harness.record(self.class) + super + end + end +``` + +**(c)** After the two `RubyUI::Base.prepend` lines at the bottom, add: + +```ruby +RubyUI::Component.prepend(Golden::DeterministicRandom) +RubyUI::Component.prepend(Golden::RecordsRenderedComponent) +``` + +- [ ] **Step 3: Extend the runner** + +In `gem/test/golden_test.rb`: + +**(a)** Replace the scenario loop + +```ruby + Golden::Catalog.scenarios.each do |scenario| + define_method(scenario.test_name) { assert_golden(scenario) } + end +``` + +with + +```ruby + Golden::Catalog.scenarios.each do |scenario| + scenario.lanes.each do |lane| + define_method(scenario.test_name(lane)) { assert_golden(scenario, lane) } + end + end +``` + +**(b)** Change `def assert_golden(scenario)` to `def assert_golden(scenario, lane)`, and inside it replace every `canonicalize(scenario)` with `canonicalize(scenario, lane)`. Replace the `if UPDATE` line with + +```ruby + if UPDATE && recording_lane?(scenario, lane) +``` + +**(c)** Replace `canonicalize` and add the two helpers: + +```ruby + def canonicalize(scenario, lane) + Golden::CanonicalHtml.call(render(scenario, lane)) + end + + def render(scenario, lane) + (lane == :erb) ? Golden::Harness.render_erb(scenario) : Golden::Harness.render(&scenario.block) + end + + # While a scenario still has a Phlex block, that lane is the one that + # records; the ERB lane only compares. Once the block is gone, the ERB lane + # records. + def recording_lane?(scenario, lane) + (lane == :phlex) || scenario.block.nil? + end +``` + +**(d)** In `GoldenCoverageTest.rendered_classes`, replace + +```ruby + Golden::Catalog.scenarios.each { |scenario| Golden::Harness.render(&scenario.block) } +``` + +with + +```ruby + Golden::Catalog.scenarios.each do |scenario| + Golden::Harness.render(&scenario.block) if scenario.block + Golden::Harness.render_erb(scenario) if scenario.fixture? + end +``` + +**(e)** Add a coverage test to `GoldenCoverageTest`: + +```ruby + def test_no_orphan_fixture_files + expected = Golden::Catalog.scenarios.map(&:fixture_path).sort + orphans = Golden::Catalog.fixture_files - expected + + assert_empty orphans, + "fixture files with no scenario (delete them): #{orphans.map { |path| path.delete_prefix("#{Golden::Catalog::VIEWS_ROOT}/") }.join(", ")}" + end +``` + +- [ ] **Step 4: Run the suite and confirm it is unchanged** + +No fixture exists yet, so every scenario has exactly one lane: + +```bash +cd gem +bundle exec rake golden +``` + +Expected: `200 runs, ... 0 failures, 0 errors, 0 skips` — 188 Phlex-lane scenarios, 4 coverage tests, 7 canonicalizer tests, 1 harness test. + +- [ ] **Step 5: Write Button's fixtures** + +The catalog's Button block (`gem/test/golden/scenarios.rb`) is: + +```ruby +Golden::Catalog.component "button" do + %i[primary secondary destructive outline ghost link].each do |variant| + scenario "variant_#{variant}" do + RubyUI.Button(variant: variant) { variant.to_s } + end + end + + %i[sm md lg xl].each do |size| + scenario "size_#{size}" do + RubyUI.Button(size: size) { size.to_s } + end + + scenario "icon_size_#{size}" do + RubyUI.Button(size: size, icon: true) { "X" } + end + end + + scenario "submit_disabled" do + RubyUI.Button(type: :submit, disabled: true) { "Save" } + end +end +``` + +The translation rule, which 2.0b applies to every other component: `RubyUI.X(args) { "text" }` becomes `<%= render RubyUI::X.new(args) do %>text<% end %>`, on one line, block text verbatim. Create these 15 files under `gem/test/golden/views/button/`, each containing exactly the one line shown (plus a trailing newline): + +| File | Content | +| --- | --- | +| `variant_primary.html.erb` | `<%= render RubyUI::Button.new(variant: :primary) do %>primary<% end %>` | +| `variant_secondary.html.erb` | `<%= render RubyUI::Button.new(variant: :secondary) do %>secondary<% end %>` | +| `variant_destructive.html.erb` | `<%= render RubyUI::Button.new(variant: :destructive) do %>destructive<% end %>` | +| `variant_outline.html.erb` | `<%= render RubyUI::Button.new(variant: :outline) do %>outline<% end %>` | +| `variant_ghost.html.erb` | `<%= render RubyUI::Button.new(variant: :ghost) do %>ghost<% end %>` | +| `variant_link.html.erb` | `<%= render RubyUI::Button.new(variant: :link) do %>link<% end %>` | +| `size_sm.html.erb` | `<%= render RubyUI::Button.new(size: :sm) do %>sm<% end %>` | +| `size_md.html.erb` | `<%= render RubyUI::Button.new(size: :md) do %>md<% end %>` | +| `size_lg.html.erb` | `<%= render RubyUI::Button.new(size: :lg) do %>lg<% end %>` | +| `size_xl.html.erb` | `<%= render RubyUI::Button.new(size: :xl) do %>xl<% end %>` | +| `icon_size_sm.html.erb` | `<%= render RubyUI::Button.new(size: :sm, icon: true) do %>X<% end %>` | +| `icon_size_md.html.erb` | `<%= render RubyUI::Button.new(size: :md, icon: true) do %>X<% end %>` | +| `icon_size_lg.html.erb` | `<%= render RubyUI::Button.new(size: :lg, icon: true) do %>X<% end %>` | +| `icon_size_xl.html.erb` | `<%= render RubyUI::Button.new(size: :xl, icon: true) do %>X<% end %>` | +| `submit_disabled.html.erb` | `<%= render RubyUI::Button.new(type: :submit, disabled: true) do %>Save<% end %>` | + +- [ ] **Step 6: Run the suite and confirm the ERB lane is at parity** + +```bash +cd gem +bundle exec rake golden +``` + +Expected: `215 runs, ... 0 failures, 0 errors, 0 skips` — the 15 `test_button__*__erb` tests are new and green against the frozen snapshots, rendering the still-Phlex `RubyUI::Button` through phlex-rails. Then: + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots +``` + +Expected: no output. **If any `__erb` test fails, STOP** and report which and its diff — either the fixture is mistranslated or phlex-rails renders differently from a direct Phlex call, and the difference is the finding. + +- [ ] **Step 7: Run everything and commit** + +```bash +cd gem +bundle exec rake +``` + +Expected: `571 runs, ... 0 failures, 0 errors, 0 skips`, `423 files inspected, no offenses detected`. Registry current. + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden/catalog.rb gem/test/golden/harness.rb gem/test/golden_test.rb gem/test/golden/views +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: an ERB lane, proved on Button + +A scenario may now have an ERB fixture under test/golden/views next to +its Phlex block; the suite renders the fixture through the harness and +compares it against the same frozen snapshot, as its own test. With +phlex-rails loaded a fixture can render a component that is still +Phlex, so every fixture can be written and made green before a single +component migrates — after which a migration changes only the +implementation, never the ruler. + +Button's 15 fixtures are the proof: byte-identical in canonical form +to the snapshots the Phlex lane recorded. + +Co-Authored-By: Claude Sonnet 5 +MSG +)" +``` + +--- + +## Task 6: The strict lane + +The canonical form is blind, by design, to whitespace between element siblings and at text–element boundaries (§9.1). For the components whose output is text — Typography, InlineCode and InlineLink (`typography`), Badge, FormFieldError (`form`), ComboboxItem (`combobox`), ShortcutKey — the suite keeps a second snapshot: the canonical form in **preserve mode** over the whole fragment (text verbatim, whitespace kept, attributes still sorted, comments still dropped, the fragment's own leading and trailing whitespace trimmed). Recorded from the Phlex lane now, while Phlex still renders; a fixture or a migrated component that adds a newline where Phlex emitted none fails it. + +**Files:** +- Modify: `gem/test/golden/canonical_html.rb` +- Modify: `gem/test/golden/canonical_html_test.rb` +- Modify: `gem/test/golden/catalog.rb` +- Modify: `gem/test/golden_test.rb` +- Create (by re-recording): 36 files under `gem/test/golden/strict/` + +**Interfaces:** +- Consumes: Task 5's lanes. +- Produces: + - `Golden::CanonicalHtml.call(html, strict: false)`; `strict: true` is the preserve-mode form. + - `Golden::Catalog::STRICT_ROOT`, `STRICT_COMPONENTS`; `Scenario#strict?`, `#strict_snapshot_path`; `Golden::Catalog.strict_files`. + +- [ ] **Step 1: Write the failing canonicalizer tests** + +Add to `gem/test/golden/canonical_html_test.rb`, inside the class: + +```ruby + def strict(html) + Golden::CanonicalHtml.call(html, strict: true) + end + + def test_strict_sees_whitespace_between_inline_siblings + refute_equal strict("ab"), strict("a b") + end + + def test_strict_sees_whitespace_at_a_text_element_boundary + refute_equal strict("

Hello w

"), strict("

Hellow

") + end + + def test_strict_keeps_text_verbatim_and_still_sorts_attributes + assert_equal %(
\n two words\n
), strict(%(
\n two words\n
)) + end + + def test_strict_trims_the_fragments_own_edges + assert_equal "x", strict("\n x\n") + end + + def test_strict_is_a_fixed_point + once = strict(%(

Hello w\na < b

\n)) + + assert_equal once, strict(once) + end + + def test_strict_keeps_raw_text_elements_raw + once = strict("
") + + assert_equal once, strict(once) + assert_includes once, "a < b" + end +``` + +- [ ] **Step 2: Run them and confirm they fail** + +```bash +cd gem +bundle exec rake test N=/GoldenCanonicalHtmlTest#test_strict/ +``` + +Expected: 6 runs, 6 errors — `ArgumentError: unknown keyword: :strict`. + +- [ ] **Step 3: Add strict mode** + +In `gem/test/golden/canonical_html.rb`: + +**(a)** Replace `def call(html)` and its body with: + +```ruby + # `strict: true` is the preserve-mode form: text verbatim, whitespace + # kept, only the fragment's own edges trimmed. The normal form is blind to + # whitespace between siblings and at text boundaries by design; the + # strict form is for the components whose output is text. + def call(html, strict: false) + out = +"" + parse(html).each { |node| emit(node, 0, out, strict ? :preserve : :normal) } + strict ? out.strip : out + end +``` + +**(b)** In `child_mode`, move the raw-text check ahead of the mode check, so a `"}}, + "empty string is kept" => {value: "", data: {x: ""}}, + "string keys pass through" => {"data-x" => "1", "aria-label" => "L"}, + "root key from mix" => {data: {_: "root", x: "1"}}, + "date" => {datetime: Date.new(2026, 9, 7)}, + "empty token list omits the attribute" => {class: [], data: {tokens: [nil]}}, + "url attribute from a non-string" => {href: 1, src: ["/", "a.png"]}, + "javascript url is dropped, a data attribute is not" => {:href => :"javascript:x", "data-href" => "javascript:kept"}, + "out-of-range character reference" => {href: "java�script:alert(1)"} + }.freeze + + FLAT_CASES.each do |label, attributes| + define_method(:"test_flat_#{label.tr(" ", "_")}") do + assert_equal canonical_phlex(attributes), canonical_erb(attributes), + "Phlex 2.4.1 and RubyUI::Attributes.flat disagree for #{attributes.inspect}" + end + end + + MIX_CASES = [ + [{class: "a"}, {class: "b"}], + [{class: ["a", "b"]}, {class: "c"}], + [{data: {a: 1, nested: {x: 1}}}, {data: {b: 2, nested: {y: 2}}}], + [{class: "a", data: {x: 1}}, {class: nil, data: nil}], + [{class: "a", data: {x: 1}}, {class!: "b", data!: {y: 2}}], + [{data: {action: "click->a#b"}}, {data: {action: "click->c#d"}}], + [{data: "x"}, {data: {y: 1}}], + [{aria: {label: "l"}}, {aria: "flat"}] + ].freeze + + MIX_CASES.each_with_index do |(defaults, user), index| + define_method(:"test_mix_#{index + 1}") do + oracle = PhlexDiv.new({}).send(:mix, defaults, user) + mixed = RubyUI::Attributes.mix(defaults, user) + + assert_equal oracle, mixed + assert_equal canonical_phlex(oracle), canonical_erb(mixed) + end + end + + def test_merge_classes_applies_tailwind_merge_like_1_6_base + assert_equal "p-2 px-4", RubyUI::Attributes.merge_classes(["p-2", nil, ["px-4"]]) + assert_equal "py-2 px-8", RubyUI::Attributes.merge_classes("px-4 py-2 px-8") + end + + private + + def canonical_phlex(attributes) + Golden::CanonicalHtml.call(PhlexDiv.new(attributes).call) + end + + def canonical_erb(attributes) + Golden::CanonicalHtml.call(RubyUI::TestApp.view.render(template: "probe/attributes", locals: {h: attributes})) + end +end diff --git a/gem/test/ruby_ui/attributes_test.rb b/gem/test/ruby_ui/attributes_test.rb new file mode 100644 index 00000000..2694aafe --- /dev/null +++ b/gem/test/ruby_ui/attributes_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "test_helper" + +# Phlex 2.4.1's attribute guards, ported into the 2.0 layer so a component +# given untrusted values keeps the protection it has today. Each case mirrors +# phlex/sgml/attributes.rb; the differential test covers the happy path. +class AttributesTest < Minitest::Test + def flat(**attributes) + RubyUI::Attributes.flat(attributes) + end + + def test_an_event_handler_attribute_name_raises + assert_raises(ArgumentError) { flat(onclick: "x") } + assert_raises(ArgumentError) { flat("onClick" => "x") } + assert_raises(ArgumentError) { flat(onerror: "x") } + end + + def test_srcdoc_sandbox_and_http_equiv_raise + assert_raises(ArgumentError) { flat(srcdoc: "

") } + assert_raises(ArgumentError) { flat(sandbox: "") } + assert_raises(ArgumentError) { flat("http-equiv" => "refresh") } + end + + def test_a_name_with_forbidden_characters_raises_at_any_level + assert_raises(ArgumentError) { flat("bad name" => "1") } + assert_raises(ArgumentError) { flat("a=b" => "1") } + assert_raises(ArgumentError) { flat(data: {"x y" => "1"}) } + end + + def test_a_javascript_url_is_dropped_from_a_url_attribute + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(src: "JavaScript:alert(1)")) + assert_equal({}, flat(href: " java\nscript:alert(1)")) + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(href: "javascript:alert(1)")) + assert_equal({}, flat(formaction: "javascript:alert(1)")) + end + + def test_an_ordinary_url_is_kept + assert_equal({"href" => "/edit"}, flat(href: "/edit")) + assert_equal({"href" => "https://example.com/?q=javascript"}, flat(href: "https://example.com/?q=javascript")) + assert_equal({"src" => "javascript-guide.png"}, flat(src: "javascript-guide.png")) + end + + def test_a_non_string_value_is_serialized_then_checked + # Phlex serializes first and checks the result: 1 and :edit are ordinary + # values, a Symbol that spells a javascript: URL is not. + assert_equal({"href" => "1"}, flat(href: 1)) + assert_equal({"href" => "edit"}, flat(href: :edit)) + assert_equal({"href" => "/ edit"}, flat(href: ["/", "edit"])) + assert_equal({}, flat(href: :"javascript:x")) + end + + def test_an_out_of_range_character_reference_decodes_to_nothing + # Phlex rescues the failed pack and treats the reference as empty, which + # leaves `javascript:` in front. + assert_equal({}, flat(href: "java�script:alert(1)")) + end + + def test_true_is_allowed_on_a_url_attribute + assert_equal({"href" => ""}, flat(href: true)) + end + + def test_a_nested_on_key_is_not_an_event_handler + # Phlex checks `on*` only on top-level names; `data-onclick` is a plain + # data attribute and stays one here. + assert_equal({"data-onclick" => "x"}, flat(data: {onclick: "x"})) + end + + def test_the_guard_does_not_touch_ordinary_attributes + assert_equal({"class" => "a b", "data-open" => "", "aria-label" => "L"}, + flat(class: ["a", "b"], data: {open: true}, aria: {label: "L"})) + end +end From 7df35df57ddb42c6992e6929dcc4707aacd07f91 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 13:08:10 -0300 Subject: [PATCH 07/20] [Bug Fix] RubyUI::Attributes: refuse a URL attribute with no String form, as Phlex does flat silently omitted href: [] and src: {} instead of raising; Phlex 2.4.1 raises for both. Added refuse_url_value! so a URL attribute without a String form fails loud, matching the Interfaces contract. Co-Authored-By: Claude Sonnet 5 --- gem/lib/ruby_ui/attributes.rb | 19 +++++++++++++++++-- gem/test/ruby_ui/attributes_test.rb | 7 +++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/gem/lib/ruby_ui/attributes.rb b/gem/lib/ruby_ui/attributes.rb index b967e761..478aa1ac 100644 --- a/gem/lib/ruby_ui/attributes.rb +++ b/gem/lib/ruby_ui/attributes.rb @@ -85,9 +85,16 @@ def flat(attributes) name = key_name(key) case value when Hash - (name == "style") ? emit(out, name, styles(value)) : nested(value, "#{name}-", out) + if name == "style" + emit(out, name, styles(value)) + else + refuse_url_value!(name, value) + nested(value, "#{name}-", out) + end when Array, Set - emit(out, name, (name == "style") ? styles(value) : tokens(value)) + serialized = (name == "style") ? styles(value) : tokens(value) + refuse_url_value!(name, value) if serialized.nil? + emit(out, name, serialized) else emit(out, name, scalar(value)) end @@ -113,6 +120,14 @@ def emit(out, name, serialized) out[name] = serialized unless guard(name, serialized) == :drop end + # Phlex raises for a URL attribute whose value has no String form — an + # empty token list or a Hash — rather than omitting it. + def refuse_url_value!(name, value) + return unless REF_ATTRIBUTES.include?(name.downcase.delete("^a-z-")) + + raise ArgumentError, "invalid value for #{name}: #{value.inspect}" + end + # :keep or :drop. Raises for the names Phlex refuses. def guard(name, serialized) raise ArgumentError, "unsafe attribute name #{name.inspect}" if name.match?(UNSAFE_ATTRIBUTE_NAME_CHARS) diff --git a/gem/test/ruby_ui/attributes_test.rb b/gem/test/ruby_ui/attributes_test.rb index 2694aafe..a8bf12d9 100644 --- a/gem/test/ruby_ui/attributes_test.rb +++ b/gem/test/ruby_ui/attributes_test.rb @@ -73,4 +73,11 @@ def test_the_guard_does_not_touch_ordinary_attributes assert_equal({"class" => "a b", "data-open" => "", "aria-label" => "L"}, flat(class: ["a", "b"], data: {open: true}, aria: {label: "L"})) end + + def test_a_url_attribute_with_no_string_form_raises_as_in_phlex + # Phlex 2.4.1 raises "Invalid attribute value" for a URL attribute given an + # empty token list or a Hash, rather than omitting it. + assert_raises(ArgumentError) { flat(href: []) } + assert_raises(ArgumentError) { flat(src: {}) } + end end From e5e7a3e8c8f81d2b38e82a79586e40ef5c82e2a0 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 13:13:19 -0300 Subject: [PATCH 08/20] [Feature] RubyUI::Component: the 2.0 layer with a scoped sidecar lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain Ruby class ActionView renders through render_in. Its sidecar is found under the RubyUI.component_roots entry that holds the class file, through a LookupContext of its own — the application's view paths are never consulted, so a host template at the same virtual path cannot shadow the sidecar and the sidecar cannot shadow the host. content is set on every render, nil without a block. Named Component while the Phlex RubyUI::Base still exists; it takes the name Base when the last Phlex component is gone. Probe components under test/probes exercise the layer without touching a shipped component. Co-Authored-By: Claude Sonnet 5 --- gem/lib/ruby_ui/component.rb | 112 ++++++++++++++++++ gem/test/probes/ruby_ui/probes/bare.rb | 9 ++ gem/test/probes/ruby_ui/probes/div.html.erb | 1 + gem/test/probes/ruby_ui/probes/div.rb | 14 +++ .../probes/ruby_ui/probes/fallback.html.erb | 1 + gem/test/probes/ruby_ui/probes/fallback.rb | 15 +++ gem/test/probes/ruby_ui/probes/frame.html.erb | 1 + gem/test/probes/ruby_ui/probes/frame.rb | 14 +++ .../probes/ruby_ui/probes/rootless.html.erb | 1 + gem/test/probes/ruby_ui/probes/rootless.rb | 15 +++ .../probes/ruby_ui/probes/with_id.html.erb | 1 + gem/test/probes/ruby_ui/probes/with_id.rb | 15 +++ .../probes/views/probe/div_block_arg.html.erb | 1 + .../probes/views/probe/div_default.html.erb | 1 + .../probes/views/probe/div_nested.html.erb | 1 + .../views/probe/fallback_blank.html.erb | 2 + .../views/probe/fallback_empty.html.erb | 1 + .../probes/views/probe/fallback_text.html.erb | 1 + .../probes/views/ruby_ui/probes/div.html.erb | 1 + gem/test/ruby_ui/component_test.rb | 111 +++++++++++++++++ gem/test/test_helper.rb | 9 ++ 21 files changed, 327 insertions(+) create mode 100644 gem/lib/ruby_ui/component.rb create mode 100644 gem/test/probes/ruby_ui/probes/bare.rb create mode 100644 gem/test/probes/ruby_ui/probes/div.html.erb create mode 100644 gem/test/probes/ruby_ui/probes/div.rb create mode 100644 gem/test/probes/ruby_ui/probes/fallback.html.erb create mode 100644 gem/test/probes/ruby_ui/probes/fallback.rb create mode 100644 gem/test/probes/ruby_ui/probes/frame.html.erb create mode 100644 gem/test/probes/ruby_ui/probes/frame.rb create mode 100644 gem/test/probes/ruby_ui/probes/rootless.html.erb create mode 100644 gem/test/probes/ruby_ui/probes/rootless.rb create mode 100644 gem/test/probes/ruby_ui/probes/with_id.html.erb create mode 100644 gem/test/probes/ruby_ui/probes/with_id.rb create mode 100644 gem/test/probes/views/probe/div_block_arg.html.erb create mode 100644 gem/test/probes/views/probe/div_default.html.erb create mode 100644 gem/test/probes/views/probe/div_nested.html.erb create mode 100644 gem/test/probes/views/probe/fallback_blank.html.erb create mode 100644 gem/test/probes/views/probe/fallback_empty.html.erb create mode 100644 gem/test/probes/views/probe/fallback_text.html.erb create mode 100644 gem/test/probes/views/ruby_ui/probes/div.html.erb create mode 100644 gem/test/ruby_ui/component_test.rb diff --git a/gem/lib/ruby_ui/component.rb b/gem/lib/ruby_ui/component.rb new file mode 100644 index 00000000..5c970308 --- /dev/null +++ b/gem/lib/ruby_ui/component.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "action_view" +require_relative "attributes" + +module RubyUI + class << self + # The directories that hold `ruby_ui/`: `app/components` in a host + # application, `lib` in this gem. A component's sidecar template is looked + # up under the root that contains its class file, and nowhere else — the + # application's view paths are never consulted, so a host template at the + # same virtual path cannot shadow it and it cannot shadow the host. + def component_roots + @component_roots ||= [] + end + + attr_writer :component_roots + + def lookup_for(root) + (@lookups ||= {})[root] ||= ActionView::LookupContext.new( + ActionView::PathRegistry.cast_file_system_resolvers([root]), {formats: [:html]} + ) + end + end + + # The 2.0 component layer: a plain Ruby object that ActionView renders + # through `render_in`, with an ERB sidecar template next to the class file. + # + # # app/components/ruby_ui/dialog/dialog.rb + # class RubyUI::Dialog < RubyUI::Component + # def initialize(open: false, **attrs) + # @open = open + # super(**attrs) + # end + # + # private def default_attrs + # {data: {controller: "ruby-ui--dialog", ruby_ui__dialog_open_value: @open}} + # end + # end + # + # # app/components/ruby_ui/dialog/dialog.html.erb + #

><%= component.content %>
+ # + # # a view + # <%= render RubyUI::Dialog.new(open: true) do %> ... <% end %> + # + # `attrs` is computed in `initialize` with no view context — mix, Tailwind + # merge, then Phlex-semantics serialization (see Attributes) — so a component + # can read a neighbour's computed attributes (`Button.new(...).attrs["class"]`). + # `render_in` captures the caller's block with the component as the block + # argument (for `do |group|` components), then renders the sidecar with + # `component` as its only local. Nothing else: no named slots, no DSL. + # + # Named `Component` while the Phlex `RubyUI::Base` still exists; it takes + # the name `Base` when the last Phlex component is gone. + class Component + attr_reader :attrs, :content + + def initialize(**user_attrs) + mixed = Attributes.mix(default_attrs, user_attrs) + mixed[:class] = Attributes.merge_classes(mixed[:class]) if mixed[:class] + @attrs = Attributes.flat(mixed) + end + + # ActionView's renderable protocol. Rails passes `locals:`; the caller's + # locals are not the component's, so they are accepted and ignored. + # `content` is set on every call — nil without a block — so an instance + # rendered twice never repeats its first content. + def render_in(view_context, **, &block) + @view_context = view_context + @content = block ? view_context.capture(self, &block) : nil + self.class.template.render(view_context, {component: self}) + end + + # The view context, for a component that needs a Rails helper from Ruby + # (`helpers.form_authenticity_token`) or renders a neighbour from a method. + # Only available during render_in. + def helpers + @view_context or raise ArgumentError, "#{self.class.name} has no view context outside render_in" + end + + class << self + # Looked up on every render, not cached here: the resolver behind the + # lookup context caches compiled templates and Rails' reloader clears it, + # so a Template cached on the class would outlive an edit in development. + def template + root = component_root + relative = source_file.delete_prefix("#{root}/").delete_suffix(".rb") + prefix, base = File.split(relative) + RubyUI.lookup_for(root).find(base, [prefix], false, [:component]) + rescue ActionView::MissingTemplate + raise ArgumentError, "#{name} has no sidecar template at #{relative}.html.erb under #{root}" + end + + def source_file + @source_file ||= Object.const_source_location(name)&.first or + raise ArgumentError, "#{name}: no source location to derive a sidecar template from" + end + + def component_root + @component_root ||= RubyUI.component_roots.map(&:to_s).find { |root| source_file.start_with?("#{root}/") } or + raise ArgumentError, "#{name}: #{source_file} is under none of RubyUI.component_roots #{RubyUI.component_roots.inspect}" + end + end + + private + + def default_attrs + {} + end + end +end diff --git a/gem/test/probes/ruby_ui/probes/bare.rb b/gem/test/probes/ruby_ui/probes/bare.rb new file mode 100644 index 00000000..2d3434c7 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/bare.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyUI + module Probes + # Has no sidecar on purpose: the lookup must say so, by name and path. + class Bare < Component + end + end +end diff --git a/gem/test/probes/ruby_ui/probes/div.html.erb b/gem/test/probes/ruby_ui/probes/div.html.erb new file mode 100644 index 00000000..097a1dda --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/div.html.erb @@ -0,0 +1 @@ +
><%= component.content %>
diff --git a/gem/test/probes/ruby_ui/probes/div.rb b/gem/test/probes/ruby_ui/probes/div.rb new file mode 100644 index 00000000..336df2b0 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/div.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module RubyUI + module Probes + # One element with attributes and content: the shape of most components. + class Div < Component + private + + def default_attrs + {class: "probe", data: {probe: true}} + end + end + end +end diff --git a/gem/test/probes/ruby_ui/probes/fallback.html.erb b/gem/test/probes/ruby_ui/probes/fallback.html.erb new file mode 100644 index 00000000..71b1b942 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/fallback.html.erb @@ -0,0 +1 @@ +<%= component.content.presence || component.placeholder %> diff --git a/gem/test/probes/ruby_ui/probes/fallback.rb b/gem/test/probes/ruby_ui/probes/fallback.rb new file mode 100644 index 00000000..f8be8c74 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/fallback.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + module Probes + # Content or a placeholder — SelectValue's shape. + class Fallback < Component + attr_reader :placeholder + + def initialize(placeholder:, **attrs) + @placeholder = placeholder + super(**attrs) + end + end + end +end diff --git a/gem/test/probes/ruby_ui/probes/frame.html.erb b/gem/test/probes/ruby_ui/probes/frame.html.erb new file mode 100644 index 00000000..37f5c195 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/frame.html.erb @@ -0,0 +1 @@ +><%= component.content %> diff --git a/gem/test/probes/ruby_ui/probes/frame.rb b/gem/test/probes/ruby_ui/probes/frame.rb new file mode 100644 index 00000000..1266eb43 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/frame.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module RubyUI + module Probes + # A custom-element root — DataTableFrame's shape. + class Frame < Component + private + + def default_attrs + {id: "frame"} + end + end + end +end diff --git a/gem/test/probes/ruby_ui/probes/rootless.html.erb b/gem/test/probes/ruby_ui/probes/rootless.html.erb new file mode 100644 index 00000000..ded78503 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/rootless.html.erb @@ -0,0 +1 @@ +<% if component.shown? %>>shown<% end %> diff --git a/gem/test/probes/ruby_ui/probes/rootless.rb b/gem/test/probes/ruby_ui/probes/rootless.rb new file mode 100644 index 00000000..89d2b584 --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/rootless.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + module Probes + # Renders no root element at all when hidden — DataTablePagination's shape. + class Rootless < Component + def initialize(shown: true, **attrs) + @shown = shown + super(**attrs) + end + + def shown? = @shown + end + end +end diff --git a/gem/test/probes/ruby_ui/probes/with_id.html.erb b/gem/test/probes/ruby_ui/probes/with_id.html.erb new file mode 100644 index 00000000..73cf503d --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/with_id.html.erb @@ -0,0 +1 @@ +
<%= component.content %>
diff --git a/gem/test/probes/ruby_ui/probes/with_id.rb b/gem/test/probes/ruby_ui/probes/with_id.rb new file mode 100644 index 00000000..a15d6cbe --- /dev/null +++ b/gem/test/probes/ruby_ui/probes/with_id.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + module Probes + # Mints an id and cross-references it — SelectContent's shape. + class WithId < Component + attr_reader :id + + def initialize(**attrs) + @id = "content#{SecureRandom.hex(4)}" + super + end + end + end +end diff --git a/gem/test/probes/views/probe/div_block_arg.html.erb b/gem/test/probes/views/probe/div_block_arg.html.erb new file mode 100644 index 00000000..4c137ac9 --- /dev/null +++ b/gem/test/probes/views/probe/div_block_arg.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Probes::Div.new(id: "outer") do |div| %>id=<%= div.attrs["id"] %><% end %> diff --git a/gem/test/probes/views/probe/div_default.html.erb b/gem/test/probes/views/probe/div_default.html.erb new file mode 100644 index 00000000..9f013a63 --- /dev/null +++ b/gem/test/probes/views/probe/div_default.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Probes::Div.new do %>Hello<% end %> diff --git a/gem/test/probes/views/probe/div_nested.html.erb b/gem/test/probes/views/probe/div_nested.html.erb new file mode 100644 index 00000000..9ed5bb6a --- /dev/null +++ b/gem/test/probes/views/probe/div_nested.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Probes::Div.new(id: "outer") do |outer| %><%= render RubyUI::Probes::Div.new(id: "inner") do %>in <%= outer.attrs["id"] %><% end %><% end %> diff --git a/gem/test/probes/views/probe/fallback_blank.html.erb b/gem/test/probes/views/probe/fallback_blank.html.erb new file mode 100644 index 00000000..a0173711 --- /dev/null +++ b/gem/test/probes/views/probe/fallback_blank.html.erb @@ -0,0 +1,2 @@ +<%= render RubyUI::Probes::Fallback.new(placeholder: "Pick one") do %> +<% end %> diff --git a/gem/test/probes/views/probe/fallback_empty.html.erb b/gem/test/probes/views/probe/fallback_empty.html.erb new file mode 100644 index 00000000..55a7f348 --- /dev/null +++ b/gem/test/probes/views/probe/fallback_empty.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Probes::Fallback.new(placeholder: "Pick one") do %><% end %> diff --git a/gem/test/probes/views/probe/fallback_text.html.erb b/gem/test/probes/views/probe/fallback_text.html.erb new file mode 100644 index 00000000..8847ae43 --- /dev/null +++ b/gem/test/probes/views/probe/fallback_text.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Probes::Fallback.new(placeholder: "Pick one") do %>Apple<% end %> diff --git a/gem/test/probes/views/ruby_ui/probes/div.html.erb b/gem/test/probes/views/ruby_ui/probes/div.html.erb new file mode 100644 index 00000000..42b4776b --- /dev/null +++ b/gem/test/probes/views/ruby_ui/probes/div.html.erb @@ -0,0 +1 @@ +HOST SHADOW diff --git a/gem/test/ruby_ui/component_test.rb b/gem/test/ruby_ui/component_test.rb new file mode 100644 index 00000000..d7ee6a6b --- /dev/null +++ b/gem/test/ruby_ui/component_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "test_helper" +require "golden/canonical_html" + +# The 2.0 component layer, through the probe components under test/probes. +# Output is compared in canonical form where whitespace is irrelevant and raw +# where it is the point. Inherits the helper base for render_erb. +class LayerTest < ComponentTest + def canonical(html) + Golden::CanonicalHtml.call(html) + end + + def view + RubyUI::TestApp.view + end + + def test_attrs_are_computed_with_no_view_context + assert_equal({"class" => "probe", "data-probe" => ""}, RubyUI::Probes::Div.new.attrs) + end + + def test_caller_classes_merge_over_defaults_and_nil_keeps_the_default + assert_equal "probe p-4", RubyUI::Probes::Div.new(class: "p-4").attrs["class"] + assert_equal "probe", RubyUI::Probes::Div.new(class: nil).attrs["class"] + assert_equal "p-4", RubyUI::Probes::Div.new(class!: "p-4").attrs["class"] + end + + def test_renders_the_sidecar_with_attributes_and_content + assert_equal canonical(%(
Hello
)), canonical(render_erb("probe/div_default")) + end + + def test_content_is_nil_when_rendered_without_a_block_even_after_a_render_with_one + component = RubyUI::Probes::Div.new + view.render(component) { "first" } + + assert_equal canonical(%(
)), canonical(view.render(component)) + end + + def test_the_block_receives_the_component + assert_includes render_erb("probe/div_block_arg"), "id=outer" + end + + def test_a_component_renders_inside_another_components_block + html = render_erb("probe/div_nested") + + assert_includes html, %(id="inner") + assert_includes html, "in outer" + end + + def test_a_component_can_render_nothing + assert_equal "", view.render(RubyUI::Probes::Rootless.new(shown: false)).strip + assert_includes view.render(RubyUI::Probes::Rootless.new), "shown" + end + + def test_a_minted_id_and_its_reference_line_up + html = view.render(RubyUI::Probes::WithId.new) { "body" } + id = html[/id="(content[0-9a-f]{8})"/, 1] + + refute_nil id + assert_includes html, %(aria-controls="#{id}") + end + + def test_a_custom_element_root + assert_equal canonical(%(x)), canonical(view.render(RubyUI::Probes::Frame.new) { "x" }) + end + + def test_content_presence_falls_back_to_the_placeholder + assert_equal canonical("Pick one"), canonical(render_erb("probe/fallback_empty")) + assert_equal canonical("Pick one"), canonical(render_erb("probe/fallback_blank")) + assert_equal canonical("Apple"), canonical(render_erb("probe/fallback_text")) + end + + def test_helpers_raises_outside_render_in + error = assert_raises(ArgumentError) { RubyUI::Probes::Div.new.helpers } + + assert_match(/no view context/, error.message) + end + + def test_a_missing_sidecar_is_named + error = assert_raises(ArgumentError) { view.render(RubyUI::Probes::Bare.new) } + + assert_match(%r{ruby_ui/probes/bare\.html\.erb}, error.message) + end + + def test_a_host_template_at_the_same_virtual_path_is_not_picked + refute_includes render_erb("probe/div_default"), "HOST SHADOW" + end + + def test_overlapping_roots_resolve_the_same_sidecar + # With the gem directory itself as a root ahead of test/probes, Div's file + # matches the wider root first; the relative path grows, the sidecar found + # is the same one. + original = RubyUI.component_roots + RubyUI.component_roots = [RubyUI::TestApp::ROOT, *original] + RubyUI::Probes::Div.instance_variable_set(:@component_root, nil) + + assert_equal canonical(%(
Hello
)), canonical(render_erb("probe/div_default")) + ensure + RubyUI.component_roots = original + RubyUI::Probes::Div.instance_variable_set(:@component_root, nil) + end + + class Homeless < RubyUI::Component + end + + def test_a_class_outside_every_component_root_is_refused + error = assert_raises(ArgumentError) { Homeless.template } + + assert_match(/component_roots/, error.message) + end +end diff --git a/gem/test/test_helper.rb b/gem/test/test_helper.rb index dd7af988..f278a31d 100644 --- a/gem/test/test_helper.rb +++ b/gem/test/test_helper.rb @@ -58,6 +58,15 @@ def view_class ReActionView.config.debug_mode = false Rails.application.initialize! +# component_roots= is a module method, not a constant: the autoload above does +# not reach it, so the file is required outright. +require "ruby_ui/component" + +# Two component roots: the gem's own components, and the test-only probes. +# A class's sidecar is looked up under the root that contains the class file. +RubyUI.component_roots = [File.join(RubyUI::TestApp::ROOT, "lib"), File.join(RubyUI::TestApp::ROOT, "test/probes")] +Dir.glob(File.join(RubyUI::TestApp::ROOT, "test/probes/ruby_ui/**/*.rb")).sort.each { |probe| require probe } + class ComponentTest < Minitest::Test def render(component, &) component.call(&) From 75d3dfe794ee71797d8eac105c23b0cb3976247c Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 13:20:57 -0300 Subject: [PATCH 09/20] [Feature] RubyUI::Component#enum: coerce and validate enumerated attributes size: "lg" from a tag or params and size: :lg from Ruby both select SIZES[:lg]; nil takes the default; anything else raises naming the allowed values, instead of indexing a Symbol-keyed hash with a String and silently dropping the class, as DialogContent and Badge do in 1.6. Co-Authored-By: Claude Haiku 4.5 --- gem/lib/ruby_ui/component.rb | 13 +++++++++++ gem/test/ruby_ui/enum_test.rb | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 gem/test/ruby_ui/enum_test.rb diff --git a/gem/lib/ruby_ui/component.rb b/gem/lib/ruby_ui/component.rb index 5c970308..13637d83 100644 --- a/gem/lib/ruby_ui/component.rb +++ b/gem/lib/ruby_ui/component.rb @@ -108,5 +108,18 @@ def component_root def default_attrs {} end + + # Coerces and validates an enumerated attribute. `size: "lg"` from a tag or + # from params arrives as a String, `size: :lg` from Ruby as a Symbol, and + # both must select `table[:lg]`; nil takes the default. Anything else names + # the allowed values instead of silently dropping the class. + def enum(value, table, default:) + key = value.nil? ? default : value + key = key.to_sym if key.respond_to?(:to_sym) + return key if table.key?(key) + + raise ArgumentError, + "#{self.class.name}: #{value.inspect} is not one of #{table.keys.map(&:inspect).join(", ")}" + end end end diff --git a/gem/test/ruby_ui/enum_test.rb b/gem/test/ruby_ui/enum_test.rb new file mode 100644 index 00000000..0e61864a --- /dev/null +++ b/gem/test/ruby_ui/enum_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "test_helper" + +class EnumTest < Minitest::Test + class Sized < RubyUI::Component + SIZES = {sm: "h-8", md: "h-9", lg: "h-10"}.freeze + + attr_reader :size + + def initialize(size: nil, **attrs) + @size = enum(size, SIZES, default: :md) + super(**attrs) + end + end + + def test_a_symbol_selects_its_entry + assert_equal :lg, Sized.new(size: :lg).size + end + + def test_a_string_is_coerced_to_the_symbol + assert_equal :lg, Sized.new(size: "lg").size + end + + def test_nil_takes_the_default + assert_equal :md, Sized.new.size + assert_equal :md, Sized.new(size: nil).size + end + + def test_an_unknown_value_raises_naming_the_allowed_ones + error = assert_raises(ArgumentError) { Sized.new(size: "xlg") } + + assert_match(/"xlg"/, error.message) + assert_match(/:sm, :md, :lg/, error.message) + end + + def test_a_value_that_cannot_be_a_symbol_raises_the_same_way + error = assert_raises(ArgumentError) { Sized.new(size: 42) } + + assert_match(/42/, error.message) + end +end From 4eb08a6b2a285db7586deb0d220d8583b635096c Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 13:26:23 -0300 Subject: [PATCH 10/20] [Feature] Golden suite: an ERB lane, proved on Button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scenario may now have an ERB fixture under test/golden/views next to its Phlex block; the suite renders the fixture through the harness and compares it against the same frozen snapshot, as its own test. With phlex-rails loaded a fixture can render a component that is still Phlex, so every fixture can be written and made green before a single component migrates — after which a migration changes only the implementation, never the ruler. Button's 15 fixtures are the proof: byte-identical in canonical form to the snapshots the Phlex lane recorded. Co-Authored-By: Claude Sonnet 5 --- gem/test/golden/catalog.rb | 33 ++++++++++- gem/test/golden/harness.rb | 23 ++++++++ .../golden/views/button/icon_size_lg.html.erb | 1 + .../golden/views/button/icon_size_md.html.erb | 1 + .../golden/views/button/icon_size_sm.html.erb | 1 + .../golden/views/button/icon_size_xl.html.erb | 1 + gem/test/golden/views/button/size_lg.html.erb | 1 + gem/test/golden/views/button/size_md.html.erb | 1 + gem/test/golden/views/button/size_sm.html.erb | 1 + gem/test/golden/views/button/size_xl.html.erb | 1 + .../views/button/submit_disabled.html.erb | 1 + .../views/button/variant_destructive.html.erb | 1 + .../views/button/variant_ghost.html.erb | 1 + .../golden/views/button/variant_link.html.erb | 1 + .../views/button/variant_outline.html.erb | 1 + .../views/button/variant_primary.html.erb | 1 + .../views/button/variant_secondary.html.erb | 1 + gem/test/golden_test.rb | 59 +++++++++++++++---- 18 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 gem/test/golden/views/button/icon_size_lg.html.erb create mode 100644 gem/test/golden/views/button/icon_size_md.html.erb create mode 100644 gem/test/golden/views/button/icon_size_sm.html.erb create mode 100644 gem/test/golden/views/button/icon_size_xl.html.erb create mode 100644 gem/test/golden/views/button/size_lg.html.erb create mode 100644 gem/test/golden/views/button/size_md.html.erb create mode 100644 gem/test/golden/views/button/size_sm.html.erb create mode 100644 gem/test/golden/views/button/size_xl.html.erb create mode 100644 gem/test/golden/views/button/submit_disabled.html.erb create mode 100644 gem/test/golden/views/button/variant_destructive.html.erb create mode 100644 gem/test/golden/views/button/variant_ghost.html.erb create mode 100644 gem/test/golden/views/button/variant_link.html.erb create mode 100644 gem/test/golden/views/button/variant_outline.html.erb create mode 100644 gem/test/golden/views/button/variant_primary.html.erb create mode 100644 gem/test/golden/views/button/variant_secondary.html.erb diff --git a/gem/test/golden/catalog.rb b/gem/test/golden/catalog.rb index d4555fad..a6344607 100644 --- a/gem/test/golden/catalog.rb +++ b/gem/test/golden/catalog.rb @@ -6,6 +6,7 @@ module Golden module Catalog SNAPSHOT_ROOT = File.expand_path("snapshots", __dir__) LIB_ROOT = File.expand_path("../../lib/ruby_ui", __dir__) + VIEWS_ROOT = File.expand_path("views", __dir__) # `pending` holds a reason string when a scenario cannot be pinned by a # snapshot. A pending scenario is still declared, still rendered, and still @@ -24,8 +25,28 @@ def snapshot_path File.join(SNAPSHOT_ROOT, component, "#{name}.html") end - def test_name - :"test_#{component}__#{name}" + def fixture_path + File.join(VIEWS_ROOT, component, "#{name}.html.erb") + end + + def fixture? + File.exist?(fixture_path) + end + + # The renderers this scenario runs through: its Phlex block while it has + # one, its ERB fixture once it has one. Both compare against one snapshot. + def lanes + [(:phlex if block), (:erb if fixture?)].compact + end + + # The lane whose render is written to disk on `golden:update`: the Phlex + # block while the scenario has one, the ERB fixture after. + def recording_lane + block ? :phlex : :erb + end + + def test_name(lane = nil) + lane ? :"test_#{component}__#{name}__#{lane}" : :"test_#{component}__#{name}" end end @@ -51,6 +72,12 @@ def scenario(name, pending: nil, &block) scenarios << Scenario.new(@component, name.to_s, block, pending) end + # Every .html.erb fixture under VIEWS_ROOT — the ERB lane's coverage + # source, mirrored against the catalog by test_no_orphan_fixture_files. + def fixture_files + Dir.glob(File.join(VIEWS_ROOT, "**", "*.html.erb")).sort + end + # Every directory under lib/ruby_ui/ is a component and must appear in the # catalog. `docs/` holds the documentation views that ship with the gem, # not components. @@ -69,7 +96,7 @@ def component_classes .flat_map { |directory| Dir.glob(File.join(LIB_ROOT, directory, "*.rb")) } .reject { |path| path.end_with?("_docs.rb") } .map { |path| constant_for(path) } - .select { |constant| constant.is_a?(Class) && constant < RubyUI::Base } + .select { |constant| constant.is_a?(Class) && (constant < RubyUI::Base || constant < RubyUI::Component) } .map(&:name) .sort end diff --git a/gem/test/golden/harness.rb b/gem/test/golden/harness.rb index 5b39c69a..98dc07cf 100644 --- a/gem/test/golden/harness.rb +++ b/gem/test/golden/harness.rb @@ -29,6 +29,17 @@ def render(&block) @active = false end + # The ERB lane: renders a scenario's fixture through the same harness a + # host application's templates go through, with the same pins active. + def render_erb(scenario) + @active = true + @hex_calls = 0 + @rand_calls = 0 + RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT).render(template: "#{scenario.component}/#{scenario.name}") + ensure + @active = false + end + # `SecureRandom.hex` and `rand` are the only two sources of # non-determinism in the 1.6 surface: TooltipContent, SelectContent and # DatePicker mint DOM ids with `SecureRandom.hex(4)`, and @@ -100,6 +111,15 @@ def before_template super end end + + # The 2.0 layer has no before_template; render_in is the hook that fires on + # every render and nothing overrides. + module RecordsRenderedComponent + def render_in(...) + Golden::Harness.record(self.class) + super + end + end end SecureRandom.singleton_class.prepend(Golden::DeterministicSecureRandom) @@ -109,3 +129,6 @@ def before_template # seeding. RubyUI::Base.prepend(Golden::DeterministicRandom) RubyUI::Base.prepend(Golden::RecordsRenderedClass) + +RubyUI::Component.prepend(Golden::DeterministicRandom) +RubyUI::Component.prepend(Golden::RecordsRenderedComponent) diff --git a/gem/test/golden/views/button/icon_size_lg.html.erb b/gem/test/golden/views/button/icon_size_lg.html.erb new file mode 100644 index 00000000..3d85fc10 --- /dev/null +++ b/gem/test/golden/views/button/icon_size_lg.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :lg, icon: true) do %>X<% end %> diff --git a/gem/test/golden/views/button/icon_size_md.html.erb b/gem/test/golden/views/button/icon_size_md.html.erb new file mode 100644 index 00000000..18f3aff2 --- /dev/null +++ b/gem/test/golden/views/button/icon_size_md.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :md, icon: true) do %>X<% end %> diff --git a/gem/test/golden/views/button/icon_size_sm.html.erb b/gem/test/golden/views/button/icon_size_sm.html.erb new file mode 100644 index 00000000..cf6dce6e --- /dev/null +++ b/gem/test/golden/views/button/icon_size_sm.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :sm, icon: true) do %>X<% end %> diff --git a/gem/test/golden/views/button/icon_size_xl.html.erb b/gem/test/golden/views/button/icon_size_xl.html.erb new file mode 100644 index 00000000..f31125f7 --- /dev/null +++ b/gem/test/golden/views/button/icon_size_xl.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :xl, icon: true) do %>X<% end %> diff --git a/gem/test/golden/views/button/size_lg.html.erb b/gem/test/golden/views/button/size_lg.html.erb new file mode 100644 index 00000000..5e5c586a --- /dev/null +++ b/gem/test/golden/views/button/size_lg.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :lg) do %>lg<% end %> diff --git a/gem/test/golden/views/button/size_md.html.erb b/gem/test/golden/views/button/size_md.html.erb new file mode 100644 index 00000000..2b8d4f47 --- /dev/null +++ b/gem/test/golden/views/button/size_md.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :md) do %>md<% end %> diff --git a/gem/test/golden/views/button/size_sm.html.erb b/gem/test/golden/views/button/size_sm.html.erb new file mode 100644 index 00000000..32e5eb19 --- /dev/null +++ b/gem/test/golden/views/button/size_sm.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :sm) do %>sm<% end %> diff --git a/gem/test/golden/views/button/size_xl.html.erb b/gem/test/golden/views/button/size_xl.html.erb new file mode 100644 index 00000000..79145804 --- /dev/null +++ b/gem/test/golden/views/button/size_xl.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(size: :xl) do %>xl<% end %> diff --git a/gem/test/golden/views/button/submit_disabled.html.erb b/gem/test/golden/views/button/submit_disabled.html.erb new file mode 100644 index 00000000..8d249b11 --- /dev/null +++ b/gem/test/golden/views/button/submit_disabled.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(type: :submit, disabled: true) do %>Save<% end %> diff --git a/gem/test/golden/views/button/variant_destructive.html.erb b/gem/test/golden/views/button/variant_destructive.html.erb new file mode 100644 index 00000000..e5771ece --- /dev/null +++ b/gem/test/golden/views/button/variant_destructive.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(variant: :destructive) do %>destructive<% end %> diff --git a/gem/test/golden/views/button/variant_ghost.html.erb b/gem/test/golden/views/button/variant_ghost.html.erb new file mode 100644 index 00000000..a21934ff --- /dev/null +++ b/gem/test/golden/views/button/variant_ghost.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(variant: :ghost) do %>ghost<% end %> diff --git a/gem/test/golden/views/button/variant_link.html.erb b/gem/test/golden/views/button/variant_link.html.erb new file mode 100644 index 00000000..cdd65e18 --- /dev/null +++ b/gem/test/golden/views/button/variant_link.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(variant: :link) do %>link<% end %> diff --git a/gem/test/golden/views/button/variant_outline.html.erb b/gem/test/golden/views/button/variant_outline.html.erb new file mode 100644 index 00000000..d6838a9f --- /dev/null +++ b/gem/test/golden/views/button/variant_outline.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(variant: :outline) do %>outline<% end %> diff --git a/gem/test/golden/views/button/variant_primary.html.erb b/gem/test/golden/views/button/variant_primary.html.erb new file mode 100644 index 00000000..b6bb1d2d --- /dev/null +++ b/gem/test/golden/views/button/variant_primary.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(variant: :primary) do %>primary<% end %> diff --git a/gem/test/golden/views/button/variant_secondary.html.erb b/gem/test/golden/views/button/variant_secondary.html.erb new file mode 100644 index 00000000..72890e12 --- /dev/null +++ b/gem/test/golden/views/button/variant_secondary.html.erb @@ -0,0 +1 @@ +<%= render RubyUI::Button.new(variant: :secondary) do %>secondary<% end %> diff --git a/gem/test/golden_test.rb b/gem/test/golden_test.rb index 44b87afd..e705eefb 100644 --- a/gem/test/golden_test.rb +++ b/gem/test/golden_test.rb @@ -21,27 +21,30 @@ class GoldenSuiteTest < Minitest::Test UPDATE = ENV["UPDATE_GOLDEN_SNAPSHOTS"] == "1" Golden::Catalog.scenarios.each do |scenario| - define_method(scenario.test_name) { assert_golden(scenario) } + scenario.lanes.each do |lane| + define_method(scenario.test_name(lane)) { assert_golden(scenario, lane) } + end + end + + def self.recorded + @recorded ||= {} end private - def assert_golden(scenario) - canonical = canonicalize(scenario) + def assert_golden(scenario, lane) + canonical = canonicalize(scenario, lane) # Rendering twice catches any source of non-determinism the harness has not # pinned, wherever it lives, before it can be baked into a snapshot. - assert_equal canonical, canonicalize(scenario), + assert_equal canonical, canonicalize(scenario, lane), "#{scenario.slug} does not render deterministically; pin the new source in test/golden/harness.rb" # A pending scenario has been rendered — it must not raise, and must be # stable within one Ruby — but its markup is not pinned. See the reason. skip "#{scenario.slug} is not pinned: #{scenario.pending}" unless scenario.pinned? - if UPDATE - FileUtils.mkdir_p(File.dirname(scenario.snapshot_path)) - File.write(scenario.snapshot_path, canonical) - end + record!(scenario) if UPDATE assert_path_exists scenario.snapshot_path, "no snapshot for #{scenario.slug} — run `bundle exec rake golden:update` and review the diff" @@ -60,8 +63,24 @@ def assert_golden(scenario) "HTML for #{scenario.slug} no longer matches the recorded 1.6 snapshot" end - def canonicalize(scenario) - Golden::CanonicalHtml.call(Golden::Harness.render(&scenario.block)) + def canonicalize(scenario, lane) + Golden::CanonicalHtml.call(render(scenario, lane)) + end + + def render(scenario, lane) + (lane == :erb) ? Golden::Harness.render_erb(scenario) : Golden::Harness.render(&scenario.block) + end + + # In update mode the authoritative lane writes the snapshot before either + # lane compares, once per scenario per process, so the order Minitest picks + # for a scenario's lane tests cannot make one of them read a stale or absent + # file. + def record!(scenario) + self.class.recorded[scenario.slug] ||= begin + FileUtils.mkdir_p(File.dirname(scenario.snapshot_path)) + File.write(scenario.snapshot_path, Golden::CanonicalHtml.call(render(scenario, scenario.recording_lane))) + true + end end end @@ -92,12 +111,30 @@ def test_no_orphan_snapshot_files "snapshot files with no scenario (delete them): #{orphans.map { |path| path.delete_prefix("#{Golden::Catalog::SNAPSHOT_ROOT}/") }.join(", ")}" end + def test_every_scenario_has_at_least_one_lane + laneless = Golden::Catalog.scenarios.reject { |scenario| scenario.lanes.any? }.map(&:slug) + + assert_empty laneless, + "scenarios with neither a Phlex block nor an ERB fixture (they would define no test): #{laneless.join(", ")}" + end + + def test_no_orphan_fixture_files + expected = Golden::Catalog.scenarios.map(&:fixture_path).sort + orphans = Golden::Catalog.fixture_files - expected + + assert_empty orphans, + "fixture files with no scenario (delete them): #{orphans.map { |path| path.delete_prefix("#{Golden::Catalog::VIEWS_ROOT}/") }.join(", ")}" + end + # Rendering the whole catalog once, memoized, because Minitest runs tests in # a random order and the coverage check cannot rely on the scenario tests # having run first. def self.rendered_classes @rendered_classes ||= begin - Golden::Catalog.scenarios.each { |scenario| Golden::Harness.render(&scenario.block) } + Golden::Catalog.scenarios.each do |scenario| + Golden::Harness.render(&scenario.block) if scenario.block + Golden::Harness.render_erb(scenario) if scenario.fixture? + end Golden::Harness.classes_rendered.keys.sort end end From 3cf6e9737c9e048fb59fcb9cb118c618f4eaa362 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 13:36:49 -0300 Subject: [PATCH 11/20] [Feature] Golden suite: a strict lane for every scenario The canonical form is blind to whitespace between siblings and at text boundaries by design. Every scenario now also keeps the preserve-mode form: text verbatim, attributes sorted, the fragment's own edges trimmed. 188 strict snapshots recorded from the Phlex lane while Phlex still renders; a fixture or a migrated component that adds a newline where Phlex emitted none fails here even though the canonical form cannot see it. The fixed-point fix already on the branch (64cf273) is what makes the strict form a fixed point too. Co-Authored-By: Claude Sonnet 5 --- gem/test/golden/canonical_html.rb | 10 +- gem/test/golden/canonical_html_test.rb | 49 ++++++++ gem/test/golden/catalog.rb | 13 ++ .../accordion/custom_trigger_with_icon.html | 1 + .../default_trigger_and_content.html | 1 + gem/test/golden/strict/alert/default.html | 1 + gem/test/golden/strict/alert/destructive.html | 1 + gem/test/golden/strict/alert/success.html | 1 + gem/test/golden/strict/alert/warning.html | 1 + .../golden/strict/alert_dialog/default.html | 1 + gem/test/golden/strict/alert_dialog/open.html | 1 + .../golden/strict/aspect_ratio/default.html | 1 + .../golden/strict/aspect_ratio/square.html | 1 + .../strict/avatar/image_with_fallback.html | 1 + gem/test/golden/strict/avatar/size_lg.html | 1 + gem/test/golden/strict/avatar/size_md.html | 1 + gem/test/golden/strict/avatar/size_sm.html | 1 + gem/test/golden/strict/avatar/size_xl.html | 1 + .../golden/strict/badge/all_variants.html | 1 + gem/test/golden/strict/badge/size_lg.html | 1 + gem/test/golden/strict/badge/size_md.html | 1 + gem/test/golden/strict/badge/size_sm.html | 1 + .../golden/strict/breadcrumb/default.html | 1 + .../strict/bubble/content_as_anchor.html | 1 + gem/test/golden/strict/bubble/default.html | 1 + .../muted_aligned_end_with_reactions.html | 1 + .../strict/bubble/reactions_top_start.html | 1 + .../golden/strict/button/icon_size_lg.html | 1 + .../golden/strict/button/icon_size_md.html | 1 + .../golden/strict/button/icon_size_sm.html | 1 + .../golden/strict/button/icon_size_xl.html | 1 + gem/test/golden/strict/button/size_lg.html | 1 + gem/test/golden/strict/button/size_md.html | 1 + gem/test/golden/strict/button/size_sm.html | 1 + gem/test/golden/strict/button/size_xl.html | 1 + .../golden/strict/button/submit_disabled.html | 1 + .../strict/button/variant_destructive.html | 1 + .../golden/strict/button/variant_ghost.html | 1 + .../golden/strict/button/variant_link.html | 1 + .../golden/strict/button/variant_outline.html | 1 + .../golden/strict/button/variant_primary.html | 1 + .../strict/button/variant_secondary.html | 1 + .../strict/calendar/bound_to_input.html | 1 + gem/test/golden/strict/calendar/default.html | 1 + .../golden/strict/calendar/header_parts.html | 1 + gem/test/golden/strict/card/default.html | 1 + .../golden/strict/carousel/horizontal.html | 1 + gem/test/golden/strict/carousel/vertical.html | 1 + .../golden/strict/carousel/with_options.html | 1 + gem/test/golden/strict/chart/bar.html | 1 + .../strict/checkbox/checked_disabled.html | 1 + gem/test/golden/strict/checkbox/default.html | 1 + gem/test/golden/strict/checkbox/group.html | 1 + .../clipboard/custom_source_and_trigger.html | 1 + gem/test/golden/strict/clipboard/default.html | 1 + .../strict/clipboard/popover_error.html | 1 + .../strict/clipboard/popover_success.html | 1 + .../strict/codeblock/ruby_with_clipboard.html | 119 ++++++++++++++++++ .../codeblock/ruby_without_clipboard.html | 117 +++++++++++++++++ .../golden/strict/collapsible/closed.html | 1 + gem/test/golden/strict/collapsible/open.html | 1 + gem/test/golden/strict/combobox/badge.html | 1 + .../golden/strict/combobox/clear_button.html | 1 + .../golden/strict/combobox/input_trigger.html | 1 + .../strict/combobox/multiple_with_badges.html | 1 + .../golden/strict/combobox/radio_items.html | 1 + gem/test/golden/strict/command/dialog.html | 1 + .../strict/command/dialog_content_lg.html | 1 + .../strict/command/dialog_content_md.html | 1 + .../strict/command/dialog_content_sm.html | 1 + .../trigger_with_custom_keybindings.html | 1 + .../golden/strict/context_menu/default.html | 1 + .../strict/context_menu/label_flush.html | 1 + .../strict/context_menu/label_inset.html | 1 + .../strict/context_menu/with_options.html | 1 + .../data_table/expand_toggle_expanded.html | 1 + .../golden/strict/data_table/full_frame.html | 1 + .../data_table/pagination_first_page.html | 1 + .../data_table/pagination_manual_adapter.html | 1 + .../data_table/pagination_wide_window.html | 1 + .../data_table/search_without_debounce.html | 1 + .../strict/data_table/sort_head_unsorted.html | 1 + .../golden/strict/date_picker/default.html | 1 + .../strict/date_picker/generated_id.html | 1 + .../strict/date_picker/without_label.html | 1 + gem/test/golden/strict/dialog/content_lg.html | 1 + gem/test/golden/strict/dialog/content_md.html | 1 + gem/test/golden/strict/dialog/content_sm.html | 1 + gem/test/golden/strict/dialog/content_xl.html | 1 + gem/test/golden/strict/dialog/default.html | 1 + gem/test/golden/strict/dialog/open.html | 1 + .../golden/strict/dropdown_menu/default.html | 1 + .../strict/dropdown_menu/fixed_strategy.html | 1 + gem/test/golden/strict/empty/default.html | 1 + .../golden/strict/empty/media_default.html | 1 + gem/test/golden/strict/form/default.html | 1 + .../golden/strict/hover_card/default.html | 1 + .../strict/hover_card/with_options.html | 1 + gem/test/golden/strict/input/default.html | 1 + .../strict/input/typed_and_disabled.html | 1 + .../input_otp/alphanumeric_pattern.html | 1 + gem/test/golden/strict/input_otp/default.html | 1 + gem/test/golden/strict/link/icon.html | 1 + gem/test/golden/strict/link/size_lg.html | 1 + gem/test/golden/strict/link/size_md.html | 1 + gem/test/golden/strict/link/size_sm.html | 1 + gem/test/golden/strict/link/size_xl.html | 1 + .../strict/link/variant_destructive.html | 1 + .../golden/strict/link/variant_ghost.html | 1 + gem/test/golden/strict/link/variant_link.html | 1 + .../golden/strict/link/variant_outline.html | 1 + .../golden/strict/link/variant_primary.html | 1 + .../golden/strict/link/variant_secondary.html | 1 + .../golden/strict/masked_input/default.html | 1 + .../group_with_avatar_header_footer.html | 1 + .../strict/message_scroller/button_start.html | 1 + .../strict/message_scroller/default.html | 1 + .../provider_custom_values.html | 1 + .../golden/strict/native_select/default.html | 1 + .../golden/strict/native_select/icon.html | 1 + .../golden/strict/native_select/small.html | 1 + .../golden/strict/pagination/default.html | 1 + gem/test/golden/strict/popover/default.html | 1 + .../golden/strict/popover/with_options.html | 1 + gem/test/golden/strict/progress/value_0.html | 1 + .../golden/strict/progress/value_100.html | 1 + .../golden/strict/progress/value_33_5.html | 1 + .../golden/strict/radio_button/checked.html | 1 + .../golden/strict/radio_button/default.html | 1 + gem/test/golden/strict/select/default.html | 1 + .../value_falls_back_to_placeholder.html | 1 + gem/test/golden/strict/separator/as_hr.html | 1 + gem/test/golden/strict/separator/default.html | 1 + .../strict/separator/not_decorative.html | 1 + .../golden/strict/separator/vertical.html | 1 + .../golden/strict/sheet/content_bottom.html | 1 + .../golden/strict/sheet/content_left.html | 1 + .../golden/strict/sheet/content_right.html | 1 + gem/test/golden/strict/sheet/content_top.html | 1 + gem/test/golden/strict/sheet/default.html | 1 + gem/test/golden/strict/sheet/open.html | 1 + .../golden/strict/shortcut_key/default.html | 1 + .../collapsible_icon_right_floating.html | 1 + .../strict/sidebar/collapsible_offcanvas.html | 1 + gem/test/golden/strict/sidebar/mobile.html | 1 + .../strict/sidebar/non_collapsible.html | 1 + gem/test/golden/strict/skeleton/default.html | 1 + .../switch/checked_without_hidden_input.html | 1 + gem/test/golden/strict/switch/default.html | 1 + gem/test/golden/strict/table/default.html | 1 + .../golden/strict/table/detached_row.html | 1 + gem/test/golden/strict/tabs/default.html | 1 + gem/test/golden/strict/textarea/default.html | 1 + .../strict/textarea/rows_and_content.html | 1 + .../golden/strict/theme_toggle/default.html | 1 + .../strict/toast/item_with_all_slots.html | 1 + .../region_top_center_with_close_button.html | 1 + .../strict/toast/region_with_flash.html | 1 + gem/test/golden/strict/toggle/default.html | 1 + .../golden/strict/toggle/disabled_small.html | 1 + .../toggle/pressed_outline_with_name.html | 1 + .../golden/strict/toggle_group/disabled.html | 1 + .../multiple_outline_spaced_vertical.html | 1 + .../golden/strict/toggle_group/single.html | 1 + gem/test/golden/strict/tooltip/default.html | 1 + .../strict/tooltip/placement_right.html | 1 + .../golden/strict/typography/blockquote.html | 1 + .../typography/heading_custom_size.html | 1 + .../strict/typography/heading_level_1.html | 1 + .../strict/typography/heading_level_2.html | 1 + .../strict/typography/heading_level_3.html | 1 + .../strict/typography/heading_level_4.html | 1 + .../golden/strict/typography/inline_code.html | 1 + .../golden/strict/typography/inline_link.html | 1 + .../golden/strict/typography/text_as_div.html | 1 + .../strict/typography/text_as_label.html | 1 + .../golden/strict/typography/text_as_p.html | 1 + .../strict/typography/text_as_span.html | 1 + .../golden/strict/typography/text_size_1.html | 1 + .../golden/strict/typography/text_size_2.html | 1 + .../golden/strict/typography/text_size_3.html | 1 + .../golden/strict/typography/text_size_4.html | 1 + .../golden/strict/typography/text_size_5.html | 1 + .../golden/strict/typography/text_size_6.html | 1 + .../golden/strict/typography/text_size_7.html | 1 + .../golden/strict/typography/text_size_8.html | 1 + .../golden/strict/typography/text_size_9.html | 1 + .../strict/typography/text_weight_bold.html | 1 + .../strict/typography/text_weight_light.html | 1 + .../strict/typography/text_weight_medium.html | 1 + .../typography/text_weight_regular.html | 1 + gem/test/golden_test.rb | 35 +++++- 192 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 gem/test/golden/strict/accordion/custom_trigger_with_icon.html create mode 100644 gem/test/golden/strict/accordion/default_trigger_and_content.html create mode 100644 gem/test/golden/strict/alert/default.html create mode 100644 gem/test/golden/strict/alert/destructive.html create mode 100644 gem/test/golden/strict/alert/success.html create mode 100644 gem/test/golden/strict/alert/warning.html create mode 100644 gem/test/golden/strict/alert_dialog/default.html create mode 100644 gem/test/golden/strict/alert_dialog/open.html create mode 100644 gem/test/golden/strict/aspect_ratio/default.html create mode 100644 gem/test/golden/strict/aspect_ratio/square.html create mode 100644 gem/test/golden/strict/avatar/image_with_fallback.html create mode 100644 gem/test/golden/strict/avatar/size_lg.html create mode 100644 gem/test/golden/strict/avatar/size_md.html create mode 100644 gem/test/golden/strict/avatar/size_sm.html create mode 100644 gem/test/golden/strict/avatar/size_xl.html create mode 100644 gem/test/golden/strict/badge/all_variants.html create mode 100644 gem/test/golden/strict/badge/size_lg.html create mode 100644 gem/test/golden/strict/badge/size_md.html create mode 100644 gem/test/golden/strict/badge/size_sm.html create mode 100644 gem/test/golden/strict/breadcrumb/default.html create mode 100644 gem/test/golden/strict/bubble/content_as_anchor.html create mode 100644 gem/test/golden/strict/bubble/default.html create mode 100644 gem/test/golden/strict/bubble/muted_aligned_end_with_reactions.html create mode 100644 gem/test/golden/strict/bubble/reactions_top_start.html create mode 100644 gem/test/golden/strict/button/icon_size_lg.html create mode 100644 gem/test/golden/strict/button/icon_size_md.html create mode 100644 gem/test/golden/strict/button/icon_size_sm.html create mode 100644 gem/test/golden/strict/button/icon_size_xl.html create mode 100644 gem/test/golden/strict/button/size_lg.html create mode 100644 gem/test/golden/strict/button/size_md.html create mode 100644 gem/test/golden/strict/button/size_sm.html create mode 100644 gem/test/golden/strict/button/size_xl.html create mode 100644 gem/test/golden/strict/button/submit_disabled.html create mode 100644 gem/test/golden/strict/button/variant_destructive.html create mode 100644 gem/test/golden/strict/button/variant_ghost.html create mode 100644 gem/test/golden/strict/button/variant_link.html create mode 100644 gem/test/golden/strict/button/variant_outline.html create mode 100644 gem/test/golden/strict/button/variant_primary.html create mode 100644 gem/test/golden/strict/button/variant_secondary.html create mode 100644 gem/test/golden/strict/calendar/bound_to_input.html create mode 100644 gem/test/golden/strict/calendar/default.html create mode 100644 gem/test/golden/strict/calendar/header_parts.html create mode 100644 gem/test/golden/strict/card/default.html create mode 100644 gem/test/golden/strict/carousel/horizontal.html create mode 100644 gem/test/golden/strict/carousel/vertical.html create mode 100644 gem/test/golden/strict/carousel/with_options.html create mode 100644 gem/test/golden/strict/chart/bar.html create mode 100644 gem/test/golden/strict/checkbox/checked_disabled.html create mode 100644 gem/test/golden/strict/checkbox/default.html create mode 100644 gem/test/golden/strict/checkbox/group.html create mode 100644 gem/test/golden/strict/clipboard/custom_source_and_trigger.html create mode 100644 gem/test/golden/strict/clipboard/default.html create mode 100644 gem/test/golden/strict/clipboard/popover_error.html create mode 100644 gem/test/golden/strict/clipboard/popover_success.html create mode 100644 gem/test/golden/strict/codeblock/ruby_with_clipboard.html create mode 100644 gem/test/golden/strict/codeblock/ruby_without_clipboard.html create mode 100644 gem/test/golden/strict/collapsible/closed.html create mode 100644 gem/test/golden/strict/collapsible/open.html create mode 100644 gem/test/golden/strict/combobox/badge.html create mode 100644 gem/test/golden/strict/combobox/clear_button.html create mode 100644 gem/test/golden/strict/combobox/input_trigger.html create mode 100644 gem/test/golden/strict/combobox/multiple_with_badges.html create mode 100644 gem/test/golden/strict/combobox/radio_items.html create mode 100644 gem/test/golden/strict/command/dialog.html create mode 100644 gem/test/golden/strict/command/dialog_content_lg.html create mode 100644 gem/test/golden/strict/command/dialog_content_md.html create mode 100644 gem/test/golden/strict/command/dialog_content_sm.html create mode 100644 gem/test/golden/strict/command/trigger_with_custom_keybindings.html create mode 100644 gem/test/golden/strict/context_menu/default.html create mode 100644 gem/test/golden/strict/context_menu/label_flush.html create mode 100644 gem/test/golden/strict/context_menu/label_inset.html create mode 100644 gem/test/golden/strict/context_menu/with_options.html create mode 100644 gem/test/golden/strict/data_table/expand_toggle_expanded.html create mode 100644 gem/test/golden/strict/data_table/full_frame.html create mode 100644 gem/test/golden/strict/data_table/pagination_first_page.html create mode 100644 gem/test/golden/strict/data_table/pagination_manual_adapter.html create mode 100644 gem/test/golden/strict/data_table/pagination_wide_window.html create mode 100644 gem/test/golden/strict/data_table/search_without_debounce.html create mode 100644 gem/test/golden/strict/data_table/sort_head_unsorted.html create mode 100644 gem/test/golden/strict/date_picker/default.html create mode 100644 gem/test/golden/strict/date_picker/generated_id.html create mode 100644 gem/test/golden/strict/date_picker/without_label.html create mode 100644 gem/test/golden/strict/dialog/content_lg.html create mode 100644 gem/test/golden/strict/dialog/content_md.html create mode 100644 gem/test/golden/strict/dialog/content_sm.html create mode 100644 gem/test/golden/strict/dialog/content_xl.html create mode 100644 gem/test/golden/strict/dialog/default.html create mode 100644 gem/test/golden/strict/dialog/open.html create mode 100644 gem/test/golden/strict/dropdown_menu/default.html create mode 100644 gem/test/golden/strict/dropdown_menu/fixed_strategy.html create mode 100644 gem/test/golden/strict/empty/default.html create mode 100644 gem/test/golden/strict/empty/media_default.html create mode 100644 gem/test/golden/strict/form/default.html create mode 100644 gem/test/golden/strict/hover_card/default.html create mode 100644 gem/test/golden/strict/hover_card/with_options.html create mode 100644 gem/test/golden/strict/input/default.html create mode 100644 gem/test/golden/strict/input/typed_and_disabled.html create mode 100644 gem/test/golden/strict/input_otp/alphanumeric_pattern.html create mode 100644 gem/test/golden/strict/input_otp/default.html create mode 100644 gem/test/golden/strict/link/icon.html create mode 100644 gem/test/golden/strict/link/size_lg.html create mode 100644 gem/test/golden/strict/link/size_md.html create mode 100644 gem/test/golden/strict/link/size_sm.html create mode 100644 gem/test/golden/strict/link/size_xl.html create mode 100644 gem/test/golden/strict/link/variant_destructive.html create mode 100644 gem/test/golden/strict/link/variant_ghost.html create mode 100644 gem/test/golden/strict/link/variant_link.html create mode 100644 gem/test/golden/strict/link/variant_outline.html create mode 100644 gem/test/golden/strict/link/variant_primary.html create mode 100644 gem/test/golden/strict/link/variant_secondary.html create mode 100644 gem/test/golden/strict/masked_input/default.html create mode 100644 gem/test/golden/strict/message/group_with_avatar_header_footer.html create mode 100644 gem/test/golden/strict/message_scroller/button_start.html create mode 100644 gem/test/golden/strict/message_scroller/default.html create mode 100644 gem/test/golden/strict/message_scroller/provider_custom_values.html create mode 100644 gem/test/golden/strict/native_select/default.html create mode 100644 gem/test/golden/strict/native_select/icon.html create mode 100644 gem/test/golden/strict/native_select/small.html create mode 100644 gem/test/golden/strict/pagination/default.html create mode 100644 gem/test/golden/strict/popover/default.html create mode 100644 gem/test/golden/strict/popover/with_options.html create mode 100644 gem/test/golden/strict/progress/value_0.html create mode 100644 gem/test/golden/strict/progress/value_100.html create mode 100644 gem/test/golden/strict/progress/value_33_5.html create mode 100644 gem/test/golden/strict/radio_button/checked.html create mode 100644 gem/test/golden/strict/radio_button/default.html create mode 100644 gem/test/golden/strict/select/default.html create mode 100644 gem/test/golden/strict/select/value_falls_back_to_placeholder.html create mode 100644 gem/test/golden/strict/separator/as_hr.html create mode 100644 gem/test/golden/strict/separator/default.html create mode 100644 gem/test/golden/strict/separator/not_decorative.html create mode 100644 gem/test/golden/strict/separator/vertical.html create mode 100644 gem/test/golden/strict/sheet/content_bottom.html create mode 100644 gem/test/golden/strict/sheet/content_left.html create mode 100644 gem/test/golden/strict/sheet/content_right.html create mode 100644 gem/test/golden/strict/sheet/content_top.html create mode 100644 gem/test/golden/strict/sheet/default.html create mode 100644 gem/test/golden/strict/sheet/open.html create mode 100644 gem/test/golden/strict/shortcut_key/default.html create mode 100644 gem/test/golden/strict/sidebar/collapsible_icon_right_floating.html create mode 100644 gem/test/golden/strict/sidebar/collapsible_offcanvas.html create mode 100644 gem/test/golden/strict/sidebar/mobile.html create mode 100644 gem/test/golden/strict/sidebar/non_collapsible.html create mode 100644 gem/test/golden/strict/skeleton/default.html create mode 100644 gem/test/golden/strict/switch/checked_without_hidden_input.html create mode 100644 gem/test/golden/strict/switch/default.html create mode 100644 gem/test/golden/strict/table/default.html create mode 100644 gem/test/golden/strict/table/detached_row.html create mode 100644 gem/test/golden/strict/tabs/default.html create mode 100644 gem/test/golden/strict/textarea/default.html create mode 100644 gem/test/golden/strict/textarea/rows_and_content.html create mode 100644 gem/test/golden/strict/theme_toggle/default.html create mode 100644 gem/test/golden/strict/toast/item_with_all_slots.html create mode 100644 gem/test/golden/strict/toast/region_top_center_with_close_button.html create mode 100644 gem/test/golden/strict/toast/region_with_flash.html create mode 100644 gem/test/golden/strict/toggle/default.html create mode 100644 gem/test/golden/strict/toggle/disabled_small.html create mode 100644 gem/test/golden/strict/toggle/pressed_outline_with_name.html create mode 100644 gem/test/golden/strict/toggle_group/disabled.html create mode 100644 gem/test/golden/strict/toggle_group/multiple_outline_spaced_vertical.html create mode 100644 gem/test/golden/strict/toggle_group/single.html create mode 100644 gem/test/golden/strict/tooltip/default.html create mode 100644 gem/test/golden/strict/tooltip/placement_right.html create mode 100644 gem/test/golden/strict/typography/blockquote.html create mode 100644 gem/test/golden/strict/typography/heading_custom_size.html create mode 100644 gem/test/golden/strict/typography/heading_level_1.html create mode 100644 gem/test/golden/strict/typography/heading_level_2.html create mode 100644 gem/test/golden/strict/typography/heading_level_3.html create mode 100644 gem/test/golden/strict/typography/heading_level_4.html create mode 100644 gem/test/golden/strict/typography/inline_code.html create mode 100644 gem/test/golden/strict/typography/inline_link.html create mode 100644 gem/test/golden/strict/typography/text_as_div.html create mode 100644 gem/test/golden/strict/typography/text_as_label.html create mode 100644 gem/test/golden/strict/typography/text_as_p.html create mode 100644 gem/test/golden/strict/typography/text_as_span.html create mode 100644 gem/test/golden/strict/typography/text_size_1.html create mode 100644 gem/test/golden/strict/typography/text_size_2.html create mode 100644 gem/test/golden/strict/typography/text_size_3.html create mode 100644 gem/test/golden/strict/typography/text_size_4.html create mode 100644 gem/test/golden/strict/typography/text_size_5.html create mode 100644 gem/test/golden/strict/typography/text_size_6.html create mode 100644 gem/test/golden/strict/typography/text_size_7.html create mode 100644 gem/test/golden/strict/typography/text_size_8.html create mode 100644 gem/test/golden/strict/typography/text_size_9.html create mode 100644 gem/test/golden/strict/typography/text_weight_bold.html create mode 100644 gem/test/golden/strict/typography/text_weight_light.html create mode 100644 gem/test/golden/strict/typography/text_weight_medium.html create mode 100644 gem/test/golden/strict/typography/text_weight_regular.html diff --git a/gem/test/golden/canonical_html.rb b/gem/test/golden/canonical_html.rb index 5cee60b7..f843db49 100644 --- a/gem/test/golden/canonical_html.rb +++ b/gem/test/golden/canonical_html.rb @@ -65,10 +65,14 @@ module CanonicalHtml HTML_WHITESPACE = /[\t\n\f\r ]+/ class << self - def call(html) + # `strict: true` is the preserve-mode form: text verbatim, whitespace + # kept, only the fragment's own edges trimmed. The normal form is blind to + # whitespace between siblings and at text boundaries by design; the + # strict form is for the components whose output is text. + def call(html, strict: false) out = +"" - parse(html).each { |node| emit(node, 0, out, :normal) } - out + parse(html).each { |node| emit(node, 0, out, strict ? :preserve : :normal) } + strict ? out.strip : out end # Every fragment is parsed inside a `