From ba778c1d7df46e0c09342c039742a06e89f08770 Mon Sep 17 00:00:00 2001 From: Cirdes Date: Sun, 20 Sep 2026 16:15:57 -0300 Subject: [PATCH 01/13] [Documentation] Plan for Phase 2.0b: the other 173 ERB fixtures Co-Authored-By: Claude Fable 5.1 --- ...9-20-phase-2-0b-fixtures-implementation.md | 1647 +++++++++++++++++ 1 file changed, 1647 insertions(+) create mode 100644 design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md diff --git a/design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md b/design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md new file mode 100644 index 00000000..e3abb97a --- /dev/null +++ b/design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md @@ -0,0 +1,1647 @@ +# Phase 2.0b — ERB Fixtures 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:** Give every one of the 188 golden scenarios an ERB fixture — the 173 that Button's 15 did not cover — each green against the frozen canonical and strict snapshots while every component is still Phlex, so that from Phase 2.1 on a migration changes only an implementation and never the ruler. + +**Architecture:** A fixture is one line of ERB under `gem/test/golden/views//.html.erb` that renders the same composition as the scenario's Phlex block, through `render RubyUI::X.new(...)`. The suite already compiles it through the inline `Rails::Application` (ReActionView → Herb), renders the still-Phlex component through `phlex-rails`, and compares canonical and strict forms against the snapshots recorded from Phlex in Phase 2.0a. Nothing renders differently; this plan only writes what the lane compares. A coverage test, red from the first task to the last batch, is the inventory of what remains. + +**Tech Stack:** Ruby 3.3 and 3.4 in CI (4.0.2 locally), Minitest, ActionView / Railties 8.1, ReActionView 0.4.1, Herb 0.10.4, phlex-rails 2.4.0 (development only), Nokogiri. + +**Spec:** `design/2026-09-19-rubyui-2-0-design.md` — §6 "Phase 2.0 Foundation", the ERB-lane bullet; §6.2's definition of done; §9.1 parts 2–3. Decisions 7 and 8 in `design/v2/decisions.md` are the two this plan executes; decision 10 is the one it records. The "Not in this plan" section of `design/plans/2026-09-20-phase-2-0a-foundation-implementation.md` is where this plan was scoped. Read the catalog (`gem/test/golden/scenarios.rb`) before writing a fixture: the fixture is a translation of the scenario block, nothing more. + +## Global Constraints + +- Branch `v2/fixtures`, created from `v2/foundation` (PR #548, unmerged, tip `f855423`); the PR for this plan targets `v2/foundation`. Never branch from, rebase onto, or push to `main`. Never push before Task 10 asks. +- Work in `gem/`. Run every command from `gem/` unless the step says otherwise (the `git status` guards run from the repo root). `ENV["RAILS_ENV"]` is `test` for every test run (the helper sets it). +- **No file under `gem/lib/` changes.** Not a component, not `component.rb`, not `attributes.rb`. This plan touches only `gem/test/golden/views/**`, `gem/test/golden_test.rb` (one test), the header comment of `gem/test/golden/scenarios.rb`, `design/v2/decisions.md` and the spec. +- **No snapshot changes.** Never run `bundle exec rake golden:update` in this plan. `git status --porcelain gem/test/golden/snapshots gem/test/golden/strict` (from the repo root) is empty at the end of every task. Never hand-edit a file under either directory. +- **The harness does not change.** `gem/test/golden/harness.rb`, `catalog.rb`, `canonical_html.rb` and `test_helper.rb` are not touched. If a fixture cannot be made green without touching one of them, that is a STOP (see "When an ERB test fails" below), not a change. +- **A fixture is one line plus a trailing newline** and emits no whitespace of its own — no leading whitespace, no newline between siblings, no indentation. The strict lane sees every character a fixture adds (decision 8), and under Herb 0.10.4 trim mode keeps the newline after an `end` that follows content on its line and emits the indentation before an output tag (decision 10, measured below). Button's 15 fixtures are the model. +- **Exactly one test may fail between Task 1 and Task 8**: `GoldenCoverageTest#test_every_scenario_has_a_fixture`, and each task states the count it must report. Any other failure — an `__erb` scenario test, a `__phlex` one, a coverage test, a canonicalizer test — blocks the commit. From Task 8 on, nothing fails. +- StandardRB stays at `423 files inspected, no offenses detected`: this plan adds no Ruby file. Fixtures are `.html.erb` and are not inspected. +- `mcp/data/registry.json` embeds the source of every file under `gem/lib/ruby_ui//`; nothing there changes, so `cd mcp && bundle exec exe/ruby-ui-mcp-build && git diff --exit-code data/registry.json` passes at the end (checked once, in Task 10). Do not touch `docs/`. +- Every commit message ends with `Co-Authored-By: Claude Fable 5.1 `. + +--- + +## What the harness was measured to do — 2026-09-20 + +Every shape below was rendered through the real path — a fixture under `Rails.root`, compiled by ReActionView's handler, so by Herb (a `
` probe raised `ActionView::SyntaxErrorInTemplate`, which Erubi would not) — with the harness pins active, and its strict form compared byte-for-byte to the strict snapshot Phase 2.0a recorded from Phlex. On `v2/foundation` at `f855423`. + +| Shape in the scenario block | Probe | Result | +| --- | --- | --- | +| A block parameter used to write raw elements (`aspect.img(...)`, `trigger.p { }`, `content.p { }`) → literal HTML in the ERB block, parameter not named | `accordion/custom_trigger_with_icon`, `aspect_ratio/default` | strict-identical | +| `key.plain "K"` — text beside an element | `shortcut_key/default` | strict-identical | +| `yield(self)` with a component-scoped method on the parameter (`group.ToggleGroupItem(...) { "L" }`) → the ERB block names the parameter, `<%= group.ToggleGroupItem(...) { "L" } %>` (the `<% … %>` statement form also passes) | `toggle_group/single`, `toggle_group/multiple_outline_spaced_vertical` | strict-identical | +| A block yielding nothing (`{ nil }`) → `do %><% end %>` (and `{ nil }` inside the tag also passes) | `select/value_falls_back_to_placeholder` | strict-identical | +| A Ruby loop around renders (`RubyUI::Badge::COLORS.each_key`) → `<% … do |v| %>…<% end %>` with `<%= v %>` for `v.to_s` | `badge/all_variants` | strict-identical | +| A heredoc argument → a double-quoted string with `\n` | `codeblock/ruby_with_clipboard` | strict-identical | +| `%w[]`, a nested `.new`, nested hashes, `[` inside a string, `=>` | `toast/region_top_center_with_close_button`, `data_table/pagination_manual_adapter`, `chart/bar`, `date_picker/default` | strict-identical | +| `DataTableForm#csrf_token` through phlex-rails' `helpers` with a view context and no controller | `DataTableForm` alone | `csrf-token-placeholder`, as recorded; the phlex-rails `helpers` deprecation warning prints, as it already does in the Phlex lane | +| The pins — `SecureRandom.hex` (`TooltipContent`, `SelectContent`, `DatePicker` without an `id:`) and `rand(Range)` (`SidebarMenuSkeleton`) — while `render_erb` is active | `date_picker/generated_id`, `select/default`, `tooltip/default`; two `SidebarMenuSkeleton` | strict-identical; identical across two renders; the minted ids are `date-picker-00000001`, `content00000001`, `tooltip00000001`, as recorded | +| **Trim mode.** `-%>` on an opening tag (`<%= render X.new do -%>`, `<%- if true -%>`) | Appendix A: p1, p4, p5 | the newline after the tag is removed; `<%-` also removes the indentation before the tag on the same line | +| **Trim mode.** `-%>` or `<%- … -%>` on the `end` that closes a `<%= … do %>` block, written after content on the same line (`Body<% end -%>`) | Appendix A: p1, p7 | **the newline after it is kept** — `visit_erb_block_end_node` in Herb's compiler never reads the end tag's own trim markers | +| **Trim mode.** That same `end` alone on its own line | Appendix A: p2, p8 | the indentation before it and the newline after it are removed, Erubi-style; the newline *before* it, ending the content line, stays | +| **Trim mode.** Indentation before an output tag (` <%= render … -%>`) | Appendix A: p3, p8 | emitted as text | +| A layout that is whitespace-tight under those rules: a break only after each opening tag, every `end` adjacent to its content, siblings on one line | Appendix A: p5 versus p9 | p5 emits nothing extra; p9, with a sibling render on its own line after `<% end %>`, emits the newline | +| A line break *inside* a tag (`<%=\n render … %>` … `<%\n end %>`) | Appendix A: p6 | no output whitespace, at any indentation | + +Consequences: the one-line translation rule holds for every shape the catalog uses. Under the pinned ReActionView 0.4.1 + Herb 0.10.4, trim mode does not reliably make an indented, one-render-per-line layout whitespace-tight: the newline after a content-terminated `end` and the indentation before an output tag both reach the output, and the strict lane sees both. Two layouts do emit nothing — breaks only after opening tags with every `end` kept adjacent (p5), and breaks inside tags (p6) — and neither reads better than one line for a fixture. Task 9 records this as decision 10; Appendix A has every probe's source and output so plan 2.1 can reproduce the conclusion under whatever Herb it pins. + +## Translation rules + +Applied to every scenario block in `gem/test/golden/scenarios.rb`. The fixture for `component "c" … scenario "n"` is `gem/test/golden/views/c/n.html.erb`. + +1. `RubyUI.X(args) { "text" }` → `<%= render RubyUI::X.new(args) do %>text<% end %>`. Arguments verbatim Ruby; block text verbatim, no added spaces. +2. `RubyUI.X(args)` with no block → `<%= render RubyUI::X.new(args) %>`. `RubyUI.X` alone → `<%= render RubyUI::X.new %>`. +3. Nested components are nested `<%= render … %>` tags inside the parent's block, siblings adjacent with nothing between them. +4. A block parameter the scenario uses only to write elements or text (`aspect.img`, `trigger.p`, `content.p`, `key.span`, `key.plain`) becomes literal HTML or text in the ERB block; the ERB block does not name the parameter. `key.plain "K"` is `K`. An `img` call becomes `` with the same attributes. +5. A block parameter the scenario uses to call a component method (`group.ToggleGroupItem`) is named in the ERB block — `do |group| %>` — and the call is `<%= group.ToggleGroupItem(args) { "text" } %>`. +6. A Ruby loop becomes `<% collection.each do |v| %>…<% end %>`; `v.to_s` as content becomes `<%= v %>`. +7. A heredoc becomes a double-quoted Ruby string with `\n` for each newline and `\"` for each quote. +8. A local variable defined at the `component` level (`columns` in `data_table`) is inlined where it is used. +9. Symbols stay Symbols (`variant: :outline`), Strings stay Strings, Integers and Floats stay as written (`value: 33.5`); a generated scenario's interpolated value is written out (`level: level.to_s` for level 1 → `level: "1"`; `variant: variant` → `variant: :primary`; `inset: style == :inset` → `inset: true` / `inset: false`). +10. One line, then one `\n`. Nothing else. + +A quoted heredoc writes a fixture exactly, with no shell expansion and exactly one trailing newline; every fixture step below is written that way and can be run verbatim from the repo root. (The example is `alert/warning`, which Task 6 writes — do not run it ahead of Task 6, or Task 2's file count is off by one.) + +```bash +mkdir -p gem/test/golden/views/alert +cat > gem/test/golden/views/alert/warning.html.erb <<'ERB' +<%= render RubyUI::Alert.new(variant: :warning) do %><%= render RubyUI::AlertTitle.new do %>Careful<% end %><% end %> +ERB +``` + +## When an ERB test fails + +The suite is the arbiter; the plan does not diagnose for it. An `__erb` test that fails is handled in this order: + +1. **Compare the fixture with its scenario block** under the translation rules, character by character. If they differ, fix the fixture and re-run. The comparison is also what a *green* test does not replace: an argument the component ignores renders identical HTML, so a passing fixture is not proof of a faithful one. +2. **If the fixture is a faithful translation and the test still fails — STOP.** Report, without asserting a cause: the scenario slug and the fixture's content; the exception class and full message if it raised (`ActionView::SyntaxErrorInTemplate` carries Herb's annotated message); otherwise the raw ERB-lane output and the raw Phlex-lane output, and the canonical and strict diffs (the failure message prints both forms); the gem versions (`grep -E "^ (phlex|phlex-rails|reactionview|herb|actionview|railties) \(" Gemfile.lock`); and the command that reproduces it (`bundle exec rake test N=/test_____erb/`). A compile error may or may not be malformed literal HTML; a faithful-fixture mismatch may or may not be phlex-rails rendering differently from a direct Phlex call — the evidence says, not the plan. +3. **The boundary holds either way.** Do not edit the component, either snapshot, `harness.rb`, `catalog.rb`, `canonical_html.rb`, `test_helper.rb`, or the scenario block. If the maintainer's decision is to mark the scenario `pending:`, note that `pending:` is scenario-level: `assert_golden` skips the snapshot comparison for **both** lanes of that scenario (each render must still not raise and must be deterministic), so it is not an ERB-only exception and it costs the ruler a scenario — a decision recorded in `design/v2/decisions.md`, not a step here. + +A determinism failure ("does not render deterministically") is reported the same way; if a pin is missing it belongs in `harness.rb`, which this plan does not touch. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `gem/test/golden_test.rb` | Gains `test_every_scenario_has_a_fixture` in `GoldenCoverageTest`. Nothing else changes. | +| `gem/test/golden/views//.html.erb` | 173 new fixtures, one per scenario without one. One line each. | +| `gem/test/golden/scenarios.rb` | Header comment gains the paragraph that tells the next person where the fixtures are and why they are one line. No scenario changes. | +| `design/v2/decisions.md` | Entry 10: Herb's trim mode measured; fixtures are one line. | +| `design/2026-09-19-rubyui-2-0-design.md` | §6 Phase 2.0 ERB-lane bullet, §6.2 definition of done and §9.1 part 2 amended to what was measured. | + +## Progress table + +`bundle exec rake golden` today: `235 runs` (188 Phlex-lane, 15 ERB-lane, 7 coverage, 25 of the ruler's own). `bundle exec rake`: `601 runs`, `423 files inspected`. Each task adds one ERB-lane test per fixture; Task 1 adds one coverage test. + +| After | Fixtures on disk | `rake golden` runs | Failures | Remaining (the failure's count) | `rake` runs | +| --- | --- | --- | --- | --- | --- | +| Task 1 | 15 | 236 | 1 | 173 | 602 | +| Task 2 | 40 | 261 | 1 | 148 | 627 | +| Task 3 | 63 | 284 | 1 | 125 | 650 | +| Task 4 | 85 | 306 | 1 | 103 | 672 | +| Task 5 | 106 | 327 | 1 | 82 | 693 | +| Task 6 | 129 | 350 | 1 | 59 | 716 | +| Task 7 | 152 | 373 | 1 | 36 | 739 | +| Task 8 | 188 | 409 | 0 | 0 | 775 | + +The `rake` column is what `bundle exec rake test` reports. Until Task 8 the default `bundle exec rake` task stops at the one expected failure before it reaches StandardRB, so the tasks that change a Ruby file (1 and 9) run `bundle exec standardrb` on its own. + +The check that every batch task runs, from `gem/`: + +```bash +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected for a batch task: one `runs,` line with the task's numbers and `1 failures, 0 errors, 0 skips`; exactly one `Failure:` line; exactly one test name, `GoldenCoverageTest#test_every_scenario_has_a_fixture`; and one `N scenarios without an ERB fixture` line with the task's remaining count. Any `__erb` or `__phlex` name in the output is a failing scenario — see "When an ERB test fails". + +Batch order is by risk, as spec §6.2 orders the migration: the shapes the probes had to prove first (block parameters, `yield(self)`, the view context, `{ nil }`, loops, heredocs), then the components with pinned randomness and generated ids, then menus and overlays, form controls, content and layout, and the two enumerative families last. + +--- + +## Task 1: The branch, the plan, and the red coverage test + +The coverage test is the inventory of this plan: red with 173 slugs at the start, green at the end. It is written first so that every batch commit reports, by name, what is still missing — and so that the suite cannot quietly call the lane complete while a scenario has no fixture. + +**Files:** +- Create: `design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md` (this file — committed first, alone) +- Modify: `gem/test/golden_test.rb` (`GoldenCoverageTest`) + +**Interfaces:** +- Consumes: `Golden::Catalog.scenarios`, `Scenario#fixture?`, `Scenario#slug` (Phase 2.0a). +- Produces: `GoldenCoverageTest#test_every_scenario_has_a_fixture`, whose failure message begins `N scenarios without an ERB fixture under test/golden/views:` followed by the slugs. + +- [ ] **Step 1: Confirm the branch** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git branch --show-current +git log --oneline -1 v2/foundation +git status --porcelain +``` + +Expected: `v2/fixtures`; `f855423 [Bug Fix] Golden suite: strict snapshot follows the custom-keybindings scenario fix`; and only this plan file untracked (`?? design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md`). If the branch is not `v2/fixtures`, create it: `git checkout -b v2/fixtures v2/foundation`. + +- [ ] **Step 2: Commit the plan** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md +git commit -m "$(cat <<'MSG' +[Documentation] Plan for Phase 2.0b: the other 173 ERB fixtures + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +- [ ] **Step 3: Write the failing coverage test** + +In `gem/test/golden_test.rb`, inside `class GoldenCoverageTest`, after `test_every_scenario_has_at_least_one_lane` and before `test_no_orphan_fixture_files`, add: + +```ruby + # Decision 7: every scenario has an ERB fixture before any component + # migrates, so a migration can only ever change an implementation, never the + # ruler. Red until the last batch of plan 2.0b lands; its message is the + # remaining work, by slug. + def test_every_scenario_has_a_fixture + missing = Golden::Catalog.scenarios.reject(&:fixture?).map(&:slug) + + assert_empty missing, + "#{missing.size} scenarios without an ERB fixture under test/golden/views: #{missing.join(", ")}" + end +``` + +- [ ] **Step 4: Run the suite and confirm it fails on exactly that test** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected: + +``` +Failure: +GoldenCoverageTest#test_every_scenario_has_a_fixture +173 scenarios without an ERB fixture +236 runs, 1693 assertions, 1 failures, 0 errors, 0 skips +``` + +(Order of the lines may differ; the assertion count is what a measured run reported and may drift — the four facts that matter are `236 runs`, `1 failures`, the one test name, and `173`.) Then confirm the message lists no Button scenario — the pattern needs a component boundary, because `radio_button/default` and `radio_button/checked` are rightly in the list and a bare `button/` would count them: + +```bash +bundle exec rake golden 2>&1 | grep -cE '(^|[^[:alnum:]_])button/' +``` + +Expected: `0` (and `grep -c` exits 1 when it prints `0`; that is the wanted outcome, not an error). + +- [ ] **Step 5: Lint and commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec standardrb +``` + +Expected: `423 files inspected, no offenses detected`. + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden_test.rb +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: every scenario must have an ERB fixture + +Red by design: 173 scenarios have no fixture yet. Plan 2.0b writes them +in seven batches; this test's message is the list of what remains, and +the suite cannot call the ERB lane complete while it is non-empty. + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +--- + +## Task 2: Batch 1 — the shapes the probes proved (25 fixtures) + +accordion (2), aspect_ratio (2), shortcut_key (1), toggle (3), toggle_group (3), toast (3), data_table (7), select (2), codeblock (2). Every shape here — a block parameter writing raw HTML, text beside an element, `yield(self)` with `group.ToggleGroupItem`, `ToastRegion`'s `yield(self) if block`, `DataTableForm`'s view-context reach, `{ nil }`, a heredoc, a nested `.new` — was measured strict-identical on 2026-09-20 (table above). If one of these fails now, follow "When an ERB test fails": compare the fixture with its scenario block first; if it is faithful, STOP and report with the evidence listed there, gem versions included — the plan does not know why. + +**Files:** +- Create: 25 files under `gem/test/golden/views/{accordion,aspect_ratio,shortcut_key,toggle,toggle_group,toast,data_table,select,codeblock}/` + +**Interfaces:** +- Consumes: `Golden::Harness.render_erb(scenario)`, `RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT)` (Phase 2.0a). +- Produces: 25 fixtures, each defining a `test_____erb` test. + +- [ ] **Step 1: Write the fixtures** + +From the repo root. The `data_table/full_frame` fixture renders `DataTableForm`, whose `csrf_token` calls phlex-rails' deprecated `helpers` and prints a deprecation notice on every render; the Phlex lane already prints it. It is not a failure. + +```bash +mkdir -p gem/test/golden/views/accordion +cat > gem/test/golden/views/accordion/default_trigger_and_content.html.erb <<'ERB' +<%= render RubyUI::Accordion.new do %><%= render RubyUI::AccordionItem.new do %><%= render RubyUI::AccordionDefaultTrigger.new do %>Title<% end %><%= render RubyUI::AccordionDefaultContent.new do %>Content<% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/accordion/custom_trigger_with_icon.html.erb <<'ERB' +<%= render RubyUI::Accordion.new do %><%= render RubyUI::AccordionItem.new(open: true, rotate_icon: 90) do %><%= render RubyUI::AccordionTrigger.new do %>

What is RubyUI?

<%= render RubyUI::AccordionIcon.new %><% end %><%= render RubyUI::AccordionContent.new do %>

A UI component library for Ruby.

<% end %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/aspect_ratio +cat > gem/test/golden/views/aspect_ratio/default.html.erb <<'ERB' +<%= render RubyUI::AspectRatio.new do %>Placeholder<% end %> +ERB +cat > gem/test/golden/views/aspect_ratio/square.html.erb <<'ERB' +<%= render RubyUI::AspectRatio.new(aspect_ratio: "1/1", class: "rounded-md border") do %>Placeholder<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/shortcut_key +cat > gem/test/golden/views/shortcut_key/default.html.erb <<'ERB' +<%= render RubyUI::ShortcutKey.new do %>CmdK<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/toggle +cat > gem/test/golden/views/toggle/default.html.erb <<'ERB' +<%= render RubyUI::Toggle.new do %>B<% end %> +ERB +cat > gem/test/golden/views/toggle/pressed_outline_with_name.html.erb <<'ERB' +<%= render RubyUI::Toggle.new(pressed: true, name: "bold", value: "1", unpressed_value: "0", variant: :outline, size: :lg) do %>B<% end %> +ERB +cat > gem/test/golden/views/toggle/disabled_small.html.erb <<'ERB' +<%= render RubyUI::Toggle.new(disabled: true, size: :sm, wrapper: {class: "inline-flex"}) do %>B<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/toggle_group +cat > gem/test/golden/views/toggle_group/single.html.erb <<'ERB' +<%= render RubyUI::ToggleGroup.new(type: :single, name: "align", value: "right") do |group| %><%= group.ToggleGroupItem(value: "left") { "L" } %><%= group.ToggleGroupItem(value: "right") { "R" } %><% end %> +ERB +cat > gem/test/golden/views/toggle_group/multiple_outline_spaced_vertical.html.erb <<'ERB' +<%= render RubyUI::ToggleGroup.new(type: :multiple, name: "fmt", value: %w[bold italic], variant: :outline, size: :sm, spacing: 2, orientation: :vertical) do |group| %><%= group.ToggleGroupItem(value: "bold") { "B" } %><%= group.ToggleGroupItem(value: "italic") { "I" } %><%= group.ToggleGroupItem(value: "underline") { "U" } %><% end %> +ERB +cat > gem/test/golden/views/toggle_group/disabled.html.erb <<'ERB' +<%= render RubyUI::ToggleGroup.new(type: :multiple, name: "fmt", disabled: true) do |group| %><%= group.ToggleGroupItem(value: "bold") { "B" } %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/toast +cat > gem/test/golden/views/toast/region_with_flash.html.erb <<'ERB' +<%= render RubyUI::ToastRegion.new(flash: {"notice" => "Saved", "alert" => "Careful"}) %> +ERB +cat > gem/test/golden/views/toast/region_top_center_with_close_button.html.erb <<'ERB' +<%= render RubyUI::ToastRegion.new(position: :top_center, expand: true, max: 5, duration: 8000, theme: :dark, rich_colors: true, close_button: true, hotkey: %w[ctrl shift t], dir: :rtl) %> +ERB +cat > gem/test/golden/views/toast/item_with_all_slots.html.erb <<'ERB' +<%= render RubyUI::ToastRegion.new do %><%= render RubyUI::ToastItem.new(variant: :error, id: "t1", duration: 6000, dismissible: false, invert: true, on_dismiss: "log", on_auto_close: "log") do %><%= render RubyUI::ToastIcon.new(variant: :error) %><%= render RubyUI::ToastTitle.new do %>Upload failed<% end %><%= render RubyUI::ToastDescription.new do %>The file is too large.<% end %><%= render RubyUI::ToastAction.new(label: "Retry", on: "retry") %><%= render RubyUI::ToastCancel.new(label: "Dismiss") %><%= render RubyUI::ToastClose.new %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/data_table +cat > gem/test/golden/views/data_table/full_frame.html.erb <<'ERB' +<%= render RubyUI::DataTable.new(id: "employees") do %><%= render RubyUI::DataTableForm.new(action: "/employees/bulk", id: "employees_form") do %><%= render RubyUI::DataTableToolbar.new do %><%= render RubyUI::DataTableSearch.new(path: "/employees", value: "alice", frame_id: "employees") %><%= render RubyUI::DataTableColumnToggle.new(columns: [{key: :email, label: "Email"}, {key: :salary, label: "Salary", visible: false}]) %><%= render RubyUI::DataTableBulkActions.new do %><%= render RubyUI::Button.new(variant: :destructive) do %>Delete<% end %><% end %><% end %><%= render RubyUI::Table.new do %><%= render RubyUI::TableHeader.new do %><%= render RubyUI::TableRow.new do %><%= render RubyUI::TableHead.new do %><%= render RubyUI::DataTableSelectAllCheckbox.new %><% end %><%= render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/employees", query: {"search" => "alice"}) %><% end %><% end %><%= render RubyUI::TableBody.new do %><%= render RubyUI::TableRow.new do %><%= render RubyUI::TableCell.new do %><%= render RubyUI::DataTableRowCheckbox.new(value: 42, label: "Select Alice") %><% end %><%= render RubyUI::TableCell.new do %>Alice<% end %><%= render RubyUI::TableCell.new do %><%= render RubyUI::DataTableExpandToggle.new(controls: "employee-42-detail") %><% end %><% end %><% end %><% end %><%= render RubyUI::DataTablePaginationBar.new do %><%= render RubyUI::DataTableSelectionSummary.new(total_on_page: 10) %><%= render RubyUI::DataTablePerPageSelect.new(path: "/employees", value: 25) %><%= render RubyUI::DataTablePagination.new(page: 3, per_page: 10, total_count: 61, path: "/employees", query: {"search" => "alice"}) %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/data_table/pagination_first_page.html.erb <<'ERB' +<%= render RubyUI::DataTablePagination.new(page: 1, per_page: 10, total_count: 30, path: "/x", query: {}) %> +ERB +cat > gem/test/golden/views/data_table/pagination_wide_window.html.erb <<'ERB' +<%= render RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 2) %> +ERB +cat > gem/test/golden/views/data_table/pagination_manual_adapter.html.erb <<'ERB' +<%= render RubyUI::DataTablePagination.new(with: RubyUI::DataTableManualAdapter.new(page: 2, per_page: 5, total_count: 21), path: "/x", query: {}) %> +ERB +cat > gem/test/golden/views/data_table/sort_head_unsorted.html.erb <<'ERB' +<%= render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {}) %> +ERB +cat > gem/test/golden/views/data_table/expand_toggle_expanded.html.erb <<'ERB' +<%= render RubyUI::DataTableExpandToggle.new(controls: "row-1", expanded: true, label: "Toggle") %> +ERB +cat > gem/test/golden/views/data_table/search_without_debounce.html.erb <<'ERB' +<%= render RubyUI::DataTableSearch.new(path: "/x", debounce: false, preserved_params: {"sort" => "name"}) %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/select +cat > gem/test/golden/views/select/default.html.erb <<'ERB' +<%= render RubyUI::Select.new do %><%= render RubyUI::SelectInput.new(name: "person") %><%= render RubyUI::SelectTrigger.new do %><%= render RubyUI::SelectValue.new(placeholder: "Select a person") %><% end %><%= render RubyUI::SelectContent.new do %><%= render RubyUI::SelectGroup.new do %><%= render RubyUI::SelectLabel.new do %>People<% end %><%= render RubyUI::SelectItem.new(value: 1) do %>John Doe<% end %><%= render RubyUI::SelectItem.new(value: 2) do %>Jane Doe<% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/select/value_falls_back_to_placeholder.html.erb <<'ERB' +<%= render RubyUI::SelectValue.new(placeholder: "Placeholder") do %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/codeblock +cat > gem/test/golden/views/codeblock/ruby_with_clipboard.html.erb <<'ERB' +<%= render RubyUI::Codeblock.new("def hello_world\n puts \"Hello, world!\"\nend\n", syntax: :ruby) %> +ERB +cat > gem/test/golden/views/codeblock/ruby_without_clipboard.html.erb <<'ERB' +<%= render RubyUI::Codeblock.new("puts :ok\n", syntax: :ruby, clipboard: false) %> +ERB +``` + +- [ ] **Step 2: Run the suite** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected: `261 runs, … 1 failures, 0 errors, 0 skips`; one `Failure:`; only `GoldenCoverageTest#test_every_scenario_has_a_fixture`; `148 scenarios without an ERB fixture`. Any other test name → "When an ERB test fails". + +- [ ] **Step 3: Confirm the ruler did not move** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots gem/test/golden/strict +find gem/test/golden/views -name '*.html.erb' | wc -l +``` + +Expected: no output; `40`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden/views +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: ERB fixtures for the block-parameter and view-context shapes + +accordion, aspect_ratio, shortcut_key, toggle, toggle_group, toast, +data_table, select, codeblock — 25 fixtures, each green against the +frozen canonical and strict snapshots with the components still Phlex. +These are the shapes that had to be proved first: a block parameter +writing raw HTML, text beside an element, yield(self) with +group.ToggleGroupItem, DataTableForm reaching for the view context, a +block yielding nothing, a heredoc, a nested .new. 148 scenarios remain. + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +--- + +## Task 3: Batch 2 — generated ids, pinned randomness, and the overlays (23 fixtures) + +tooltip (2), date_picker (3), sidebar (4), dialog (6), alert_dialog (2), sheet (6). `TooltipContent`, `DatePicker` and `SelectContent` mint ids with `SecureRandom.hex`, `SidebarMenuSkeleton` picks a width with `rand(50..89)`; the harness pins both while `render_erb` is active, with counters that restart per render, so the ERB lane sees the same values the Phlex lane recorded. + +**Files:** +- Create: 23 files under `gem/test/golden/views/{tooltip,date_picker,sidebar,dialog,alert_dialog,sheet}/` + +**Interfaces:** +- Consumes: `Golden::Harness.render_erb(scenario)`, `RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT)` (Phase 2.0a). +- Produces: 23 fixtures. + +- [ ] **Step 1: Write the fixtures** + +```bash +mkdir -p gem/test/golden/views/tooltip +cat > gem/test/golden/views/tooltip/default.html.erb <<'ERB' +<%= render RubyUI::Tooltip.new do %><%= render RubyUI::TooltipTrigger.new do %><%= render RubyUI::Button.new(variant: :outline, icon: true) do %>?<% end %><% end %><%= render RubyUI::TooltipContent.new do %>Add to library<% end %><% end %> +ERB +cat > gem/test/golden/views/tooltip/placement_right.html.erb <<'ERB' +<%= render RubyUI::Tooltip.new(placement: "right") do %><%= render RubyUI::TooltipContent.new do %>Tip<% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/date_picker +cat > gem/test/golden/views/date_picker/default.html.erb <<'ERB' +<%= render RubyUI::DatePicker.new(id: "event-date", name: "event[date]", value: "2026-05-15") %> +ERB +cat > gem/test/golden/views/date_picker/without_label.html.erb <<'ERB' +<%= render RubyUI::DatePicker.new(id: "event-date", label: nil) %> +ERB +cat > gem/test/golden/views/date_picker/generated_id.html.erb <<'ERB' +<%= render RubyUI::DatePicker.new %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/sidebar +cat > gem/test/golden/views/sidebar/collapsible_offcanvas.html.erb <<'ERB' +<%= render RubyUI::SidebarWrapper.new do %><%= render RubyUI::Sidebar.new do %><%= render RubyUI::SidebarHeader.new do %><%= render RubyUI::SidebarGroup.new do %><%= render RubyUI::SidebarGroupContent.new do %><%= render RubyUI::SidebarInput.new(id: "search", placeholder: "Search the docs") %><% end %><% end %><% end %><%= render RubyUI::SidebarContent.new do %><%= render RubyUI::SidebarGroup.new do %><%= render RubyUI::SidebarGroupLabel.new do %>Application<% end %><%= render RubyUI::SidebarGroupAction.new do %>Add<% end %><%= render RubyUI::SidebarGroupContent.new do %><%= render RubyUI::SidebarMenu.new do %><%= render RubyUI::SidebarMenuItem.new do %><%= render RubyUI::SidebarMenuButton.new(as: :a, href: "/settings", active: true) do %>Settings<% end %><%= render RubyUI::SidebarMenuAction.new(show_on_hover: true) do %>More<% end %><%= render RubyUI::SidebarMenuBadge.new do %>3<% end %><%= render RubyUI::SidebarMenuSub.new do %><%= render RubyUI::SidebarMenuSubItem.new do %><%= render RubyUI::SidebarMenuSubButton.new(as: :a, href: "/settings/team") do %>Team<% end %><% end %><% end %><% end %><%= render RubyUI::SidebarMenuItem.new do %><%= render RubyUI::SidebarMenuSkeleton.new(show_icon: true) %><% end %><%= render RubyUI::SidebarMenuItem.new do %><%= render RubyUI::SidebarMenuSkeleton.new %><% end %><% end %><% end %><% end %><%= render RubyUI::SidebarSeparator.new %><% end %><%= render RubyUI::SidebarFooter.new do %>Footer<% end %><%= render RubyUI::SidebarRail.new %><% end %><%= render RubyUI::SidebarInset.new do %><%= render RubyUI::SidebarTrigger.new %><% end %><% end %> +ERB +cat > gem/test/golden/views/sidebar/non_collapsible.html.erb <<'ERB' +<%= render RubyUI::SidebarWrapper.new do %><%= render RubyUI::Sidebar.new(collapsible: :none) do %><%= render RubyUI::SidebarContent.new do %>Body<% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/sidebar/collapsible_icon_right_floating.html.erb <<'ERB' +<%= render RubyUI::Sidebar.new(side: :right, variant: :floating, collapsible: :icon, open: false) do %><%= render RubyUI::SidebarContent.new do %>Body<% end %><% end %> +ERB +cat > gem/test/golden/views/sidebar/mobile.html.erb <<'ERB' +<%= render RubyUI::MobileSidebar.new(side: :right) do %>Body<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/dialog +cat > gem/test/golden/views/dialog/default.html.erb <<'ERB' +<%= render RubyUI::Dialog.new do %><%= render RubyUI::DialogTrigger.new do %><%= render RubyUI::Button.new do %>Open Dialog<% end %><% end %><%= render RubyUI::DialogContent.new do %><%= render RubyUI::DialogHeader.new do %><%= render RubyUI::DialogTitle.new do %>RubyUI to the rescue<% end %><%= render RubyUI::DialogDescription.new do %>Build accessible apps with ease.<% end %><% end %><%= render RubyUI::DialogMiddle.new do %>Body<% end %><%= render RubyUI::DialogFooter.new do %><%= render RubyUI::Button.new(variant: :outline) do %>Cancel<% end %><%= render RubyUI::Button.new do %>Save<% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/dialog/content_sm.html.erb <<'ERB' +<%= render RubyUI::DialogContent.new(size: :sm) do %>body<% end %> +ERB +cat > gem/test/golden/views/dialog/content_md.html.erb <<'ERB' +<%= render RubyUI::DialogContent.new(size: :md) do %>body<% end %> +ERB +cat > gem/test/golden/views/dialog/content_lg.html.erb <<'ERB' +<%= render RubyUI::DialogContent.new(size: :lg) do %>body<% end %> +ERB +cat > gem/test/golden/views/dialog/content_xl.html.erb <<'ERB' +<%= render RubyUI::DialogContent.new(size: :xl) do %>body<% end %> +ERB +cat > gem/test/golden/views/dialog/open.html.erb <<'ERB' +<%= render RubyUI::Dialog.new(open: true) do %><%= render RubyUI::DialogContent.new do %>body<% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/alert_dialog +cat > gem/test/golden/views/alert_dialog/default.html.erb <<'ERB' +<%= render RubyUI::AlertDialog.new do %><%= render RubyUI::AlertDialogTrigger.new do %><%= render RubyUI::Button.new do %>Show dialog<% end %><% end %><%= render RubyUI::AlertDialogContent.new do %><%= render RubyUI::AlertDialogHeader.new do %><%= render RubyUI::AlertDialogTitle.new do %>Are you absolutely sure?<% end %><%= render RubyUI::AlertDialogDescription.new do %>This action cannot be undone.<% end %><% end %><%= render RubyUI::AlertDialogFooter.new do %><%= render RubyUI::AlertDialogCancel.new do %>Cancel<% end %><%= render RubyUI::AlertDialogAction.new do %>Continue<% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/alert_dialog/open.html.erb <<'ERB' +<%= render RubyUI::AlertDialog.new(open: true) do %><%= render RubyUI::AlertDialogContent.new do %><%= render RubyUI::AlertDialogTitle.new do %>Open<% end %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/sheet +cat > gem/test/golden/views/sheet/default.html.erb <<'ERB' +<%= render RubyUI::Sheet.new do %><%= render RubyUI::SheetTrigger.new do %><%= render RubyUI::Button.new(variant: :outline) do %>Open Sheet<% end %><% end %><%= render RubyUI::SheetContent.new(class: "sm:max-w-sm") do %><%= render RubyUI::SheetHeader.new do %><%= render RubyUI::SheetTitle.new do %>Edit profile<% end %><%= render RubyUI::SheetDescription.new do %>Make changes to your profile here.<% end %><% end %><%= render RubyUI::SheetMiddle.new do %><%= render RubyUI::Input.new(name: "name") %><% end %><%= render RubyUI::SheetFooter.new do %><%= render RubyUI::Button.new(type: "submit") do %>Save<% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/sheet/content_top.html.erb <<'ERB' +<%= render RubyUI::SheetContent.new(side: :top) do %>body<% end %> +ERB +cat > gem/test/golden/views/sheet/content_right.html.erb <<'ERB' +<%= render RubyUI::SheetContent.new(side: :right) do %>body<% end %> +ERB +cat > gem/test/golden/views/sheet/content_bottom.html.erb <<'ERB' +<%= render RubyUI::SheetContent.new(side: :bottom) do %>body<% end %> +ERB +cat > gem/test/golden/views/sheet/content_left.html.erb <<'ERB' +<%= render RubyUI::SheetContent.new(side: :left) do %>body<% end %> +ERB +cat > gem/test/golden/views/sheet/open.html.erb <<'ERB' +<%= render RubyUI::Sheet.new(open: true) do %>content<% end %> +ERB +``` + +- [ ] **Step 2: Run the suite** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected: `284 runs, … 1 failures, 0 errors, 0 skips`; only `GoldenCoverageTest#test_every_scenario_has_a_fixture`; `125 scenarios without an ERB fixture`. + +- [ ] **Step 3: Confirm the ruler did not move** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots gem/test/golden/strict +find gem/test/golden/views -name '*.html.erb' | wc -l +``` + +Expected: no output; `63`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden/views +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: ERB fixtures for the overlays and the pinned-randomness components + +tooltip, date_picker, sidebar, dialog, alert_dialog, sheet — 23 +fixtures. The harness pins SecureRandom.hex and rand(Range) in the ERB +lane exactly as in the Phlex lane, so the generated ids and the +skeleton widths match the recorded snapshots. 125 scenarios remain. + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +--- + +## Task 4: Batch 3 — menus and overlays (22 fixtures) + +dropdown_menu (2), context_menu (4), popover (2), hover_card (2), command (5), combobox (5), collapsible (2). + +**Files:** +- Create: 22 files under `gem/test/golden/views/{dropdown_menu,context_menu,popover,hover_card,command,combobox,collapsible}/` + +**Interfaces:** +- Consumes: `Golden::Harness.render_erb(scenario)`, `RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT)` (Phase 2.0a). +- Produces: 22 fixtures. + +- [ ] **Step 1: Write the fixtures** + +Note `hover_card/with_options` passes `option:` (singular), exactly as the scenario does — the 1.6 constructor's keyword. + +```bash +mkdir -p gem/test/golden/views/dropdown_menu +cat > gem/test/golden/views/dropdown_menu/default.html.erb <<'ERB' +<%= render RubyUI::DropdownMenu.new do %><%= render RubyUI::DropdownMenuTrigger.new(class: "w-full") do %><%= render RubyUI::Button.new(variant: :outline) do %>Open<% end %><% end %><%= render RubyUI::DropdownMenuContent.new do %><%= render RubyUI::DropdownMenuLabel.new do %>My Account<% end %><%= render RubyUI::DropdownMenuSeparator.new %><%= render RubyUI::DropdownMenuItem.new(href: "/profile") do %>Profile<% end %><%= render RubyUI::DropdownMenuItem.new(as: :div) do %>Billing<% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/dropdown_menu/fixed_strategy.html.erb <<'ERB' +<%= render RubyUI::DropdownMenu.new(options: {strategy: "fixed"}) do %><%= render RubyUI::DropdownMenuContent.new do %><%= render RubyUI::DropdownMenuItem.new(href: "#") do %>Item<% end %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/context_menu +cat > gem/test/golden/views/context_menu/default.html.erb <<'ERB' +<%= render RubyUI::ContextMenu.new do %><%= render RubyUI::ContextMenuTrigger.new do %>Right click here<% end %><%= render RubyUI::ContextMenuContent.new(class: "w-64") do %><%= render RubyUI::ContextMenuItem.new(href: "#", shortcut: "[") do %>Back<% end %><%= render RubyUI::ContextMenuItem.new(href: "#", shortcut: "]", disabled: true) do %>Forward<% end %><%= render RubyUI::ContextMenuSeparator.new %><%= render RubyUI::ContextMenuItem.new(href: "#", checked: true) do %>Show Bookmarks Bar<% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/context_menu/with_options.html.erb <<'ERB' +<%= render RubyUI::ContextMenu.new(options: {placement: "right-start"}) do %><%= render RubyUI::ContextMenuTrigger.new do %>Target<% end %><% end %> +ERB +cat > gem/test/golden/views/context_menu/label_inset.html.erb <<'ERB' +<%= render RubyUI::ContextMenuLabel.new(inset: true) do %>More Tools<% end %> +ERB +cat > gem/test/golden/views/context_menu/label_flush.html.erb <<'ERB' +<%= render RubyUI::ContextMenuLabel.new(inset: false) do %>More Tools<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/popover +cat > gem/test/golden/views/popover/default.html.erb <<'ERB' +<%= render RubyUI::Popover.new do %><%= render RubyUI::PopoverTrigger.new(class: "w-full") do %><%= render RubyUI::Button.new(variant: :outline) do %>Open Popover<% end %><% end %><%= render RubyUI::PopoverContent.new(class: "w-40") do %><%= render RubyUI::Link.new(href: "/profile", variant: :ghost) do %>Profile<% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/popover/with_options.html.erb <<'ERB' +<%= render RubyUI::Popover.new(options: {trigger: "click", placement: "bottom-end"}) do %><%= render RubyUI::PopoverTrigger.new do %>T<% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/hover_card +cat > gem/test/golden/views/hover_card/default.html.erb <<'ERB' +<%= render RubyUI::HoverCard.new do %><%= render RubyUI::HoverCardTrigger.new do %><%= render RubyUI::Button.new(variant: :link) do %>@joeldrapper<% end %><% end %><%= render RubyUI::HoverCardContent.new do %><%= render RubyUI::Avatar.new do %><%= render RubyUI::AvatarFallback.new do %>JD<% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/hover_card/with_options.html.erb <<'ERB' +<%= render RubyUI::HoverCard.new(option: {placement: "bottom"}) do %><%= render RubyUI::HoverCardTrigger.new do %>T<% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/command +cat > gem/test/golden/views/command/dialog.html.erb <<'ERB' +<%= render RubyUI::CommandDialog.new do %><%= render RubyUI::CommandDialogTrigger.new do %><%= render RubyUI::Button.new(variant: :outline) do %><%= render RubyUI::ShortcutKey.new do %>K<% end %><% end %><% end %><%= render RubyUI::CommandDialogContent.new do %><%= render RubyUI::Command.new do %><%= render RubyUI::CommandInput.new %><%= render RubyUI::CommandEmpty.new do %>No results found.<% end %><%= render RubyUI::CommandList.new do %><%= render RubyUI::CommandGroup.new(title: "Components") do %><%= render RubyUI::CommandItem.new(value: "Accordion", href: "/docs/accordion") do %>Accordion<% end %><%= render RubyUI::CommandItem.new(value: "Alert", href: "/docs/alert") do %>Alert<% end %><% end %><% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/command/dialog_content_sm.html.erb <<'ERB' +<%= render RubyUI::CommandDialogContent.new(size: :sm) do %>body<% end %> +ERB +cat > gem/test/golden/views/command/dialog_content_md.html.erb <<'ERB' +<%= render RubyUI::CommandDialogContent.new(size: :md) do %>body<% end %> +ERB +cat > gem/test/golden/views/command/dialog_content_lg.html.erb <<'ERB' +<%= render RubyUI::CommandDialogContent.new(size: :lg) do %>body<% end %> +ERB +cat > gem/test/golden/views/command/trigger_with_custom_keybindings.html.erb <<'ERB' +<%= render RubyUI::CommandDialogTrigger.new(keybindings: ["keydown.ctrl+p@window"]) do %>Open<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/combobox +cat > gem/test/golden/views/combobox/radio_items.html.erb <<'ERB' +<%= render RubyUI::Combobox.new(term: "frameworks") do %><%= render RubyUI::ComboboxTrigger.new(placeholder: "Select your framework") %><%= render RubyUI::ComboboxPopover.new do %><%= render RubyUI::ComboboxSearchInput.new(placeholder: "Type the framework name") %><%= render RubyUI::ComboboxList.new do %><%= render RubyUI::ComboboxEmptyState.new do %>No results<% end %><%= render RubyUI::ComboboxListGroup.new(label: "Ruby") do %><%= render RubyUI::ComboboxItem.new do %><%= render RubyUI::ComboboxRadio.new(name: "Rails", value: "rails") %><% end %><%= render RubyUI::ComboboxItem.new do %><%= render RubyUI::ComboboxRadio.new(name: "Hanami", value: "hanami") %><% end %><% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/combobox/multiple_with_badges.html.erb <<'ERB' +<%= render RubyUI::Combobox.new(multiple: true, term: "frameworks", placement: "top-start") do %><%= render RubyUI::ComboboxBadgeTrigger.new(placeholder: "Select", clear_button: true) %><%= render RubyUI::ComboboxPopover.new do %><%= render RubyUI::ComboboxList.new do %><%= render RubyUI::ComboboxToggleAllCheckbox.new %><%= render RubyUI::ComboboxItem.new do %><%= render RubyUI::ComboboxCheckbox.new(name: "Rails", value: "rails") %><%= render RubyUI::ComboboxItemIndicator.new %><% end %><% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/combobox/input_trigger.html.erb <<'ERB' +<%= render RubyUI::ComboboxInputTrigger.new(placeholder: "Pick one") %> +ERB +cat > gem/test/golden/views/combobox/clear_button.html.erb <<'ERB' +<%= render RubyUI::ComboboxClearButton.new %> +ERB +cat > gem/test/golden/views/combobox/badge.html.erb <<'ERB' +<%= render RubyUI::ComboboxBadge.new do %>Rails<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/collapsible +cat > gem/test/golden/views/collapsible/closed.html.erb <<'ERB' +<%= render RubyUI::Collapsible.new do %><%= render RubyUI::CollapsibleTrigger.new do %><%= render RubyUI::Button.new(variant: :ghost) do %>Toggle<% end %><% end %><%= render RubyUI::CollapsibleContent.new do %>Hidden body<% end %><% end %> +ERB +cat > gem/test/golden/views/collapsible/open.html.erb <<'ERB' +<%= render RubyUI::Collapsible.new(open: true) do %><%= render RubyUI::CollapsibleTrigger.new do %><%= render RubyUI::Button.new(variant: :ghost) do %>Toggle<% end %><% end %><%= render RubyUI::CollapsibleContent.new do %>Visible body<% end %><% end %> +ERB +``` + +- [ ] **Step 2: Run the suite** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected: `306 runs, … 1 failures, 0 errors, 0 skips`; only `GoldenCoverageTest#test_every_scenario_has_a_fixture`; `103 scenarios without an ERB fixture`. + +- [ ] **Step 3: Confirm the ruler did not move** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots gem/test/golden/strict +find gem/test/golden/views -name '*.html.erb' | wc -l +``` + +Expected: no output; `85`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden/views +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: ERB fixtures for the menus and overlays + +dropdown_menu, context_menu, popover, hover_card, command, combobox, +collapsible — 22 fixtures, green against the frozen snapshots. 103 +scenarios remain. + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +--- + +## Task 5: Batch 4 — form controls (21 fixtures) + +checkbox (3), radio_button (2), switch (2), input (2), textarea (2), masked_input (1), native_select (3), input_otp (2), form (1), calendar (3). + +**Files:** +- Create: 21 files under `gem/test/golden/views/{checkbox,radio_button,switch,input,textarea,masked_input,native_select,input_otp,form,calendar}/` + +**Interfaces:** +- Consumes: `Golden::Harness.render_erb(scenario)`, `RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT)` (Phase 2.0a). +- Produces: 21 fixtures. + +- [ ] **Step 1: Write the fixtures** + +```bash +mkdir -p gem/test/golden/views/checkbox +cat > gem/test/golden/views/checkbox/default.html.erb <<'ERB' +<%= render RubyUI::Checkbox.new(name: "terms", value: "1") %> +ERB +cat > gem/test/golden/views/checkbox/checked_disabled.html.erb <<'ERB' +<%= render RubyUI::Checkbox.new(name: "terms", checked: true, disabled: true) %> +ERB +cat > gem/test/golden/views/checkbox/group.html.erb <<'ERB' +<%= render RubyUI::CheckboxGroup.new do %><%= render RubyUI::Checkbox.new(name: "colors[]", value: "red") %><%= render RubyUI::Checkbox.new(name: "colors[]", value: "blue") %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/radio_button +cat > gem/test/golden/views/radio_button/default.html.erb <<'ERB' +<%= render RubyUI::RadioButton.new(name: "plan", value: "pro") %> +ERB +cat > gem/test/golden/views/radio_button/checked.html.erb <<'ERB' +<%= render RubyUI::RadioButton.new(name: "plan", value: "pro", checked: true) %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/switch +cat > gem/test/golden/views/switch/default.html.erb <<'ERB' +<%= render RubyUI::Switch.new(name: "notifications") %> +ERB +cat > gem/test/golden/views/switch/checked_without_hidden_input.html.erb <<'ERB' +<%= render RubyUI::Switch.new(name: "notifications", include_hidden: false, checked: true, checked_value: "yes", unchecked_value: "no") %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/input +cat > gem/test/golden/views/input/default.html.erb <<'ERB' +<%= render RubyUI::Input.new(name: "email", placeholder: "jane@example.com") %> +ERB +cat > gem/test/golden/views/input/typed_and_disabled.html.erb <<'ERB' +<%= render RubyUI::Input.new(type: :email, name: "email", value: "jane@example.com", disabled: true) %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/textarea +cat > gem/test/golden/views/textarea/default.html.erb <<'ERB' +<%= render RubyUI::Textarea.new(name: "bio", placeholder: "Tell us about yourself") %> +ERB +cat > gem/test/golden/views/textarea/rows_and_content.html.erb <<'ERB' +<%= render RubyUI::Textarea.new(name: "bio", rows: 8) do %>existing content<% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/masked_input +cat > gem/test/golden/views/masked_input/default.html.erb <<'ERB' +<%= render RubyUI::MaskedInput.new(name: "phone", data: {ruby_ui__masked_input_mask_value: "(00) 00000-0000"}) %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/native_select +cat > gem/test/golden/views/native_select/default.html.erb <<'ERB' +<%= render RubyUI::NativeSelect.new(name: "country") do %><%= render RubyUI::NativeSelectOption.new(value: "") do %>Select a country<% end %><%= render RubyUI::NativeSelectGroup.new(label: "Americas") do %><%= render RubyUI::NativeSelectOption.new(value: "br", selected: true) do %>Brazil<% end %><%= render RubyUI::NativeSelectOption.new(value: "us") do %>United States<% end %><% end %><% end %> +ERB +cat > gem/test/golden/views/native_select/small.html.erb <<'ERB' +<%= render RubyUI::NativeSelect.new(size: :sm, name: "country") do %><%= render RubyUI::NativeSelectOption.new(value: "br") do %>Brazil<% end %><% end %> +ERB +cat > gem/test/golden/views/native_select/icon.html.erb <<'ERB' +<%= render RubyUI::NativeSelectIcon.new %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/input_otp +cat > gem/test/golden/views/input_otp/default.html.erb <<'ERB' +<%= render RubyUI::InputOtp.new(length: 6, name: "code") do %><%= render RubyUI::InputOtpGroup.new do %><%= render RubyUI::InputOtpSlot.new(index: 0) %><%= render RubyUI::InputOtpSlot.new(index: 1) %><%= render RubyUI::InputOtpSlot.new(index: 2) %><% end %><%= render RubyUI::InputOtpSeparator.new %><%= render RubyUI::InputOtpGroup.new do %><%= render RubyUI::InputOtpSlot.new(index: 3) %><%= render RubyUI::InputOtpSlot.new(index: 4) %><%= render RubyUI::InputOtpSlot.new(index: 5) %><% end %><% end %> +ERB +cat > gem/test/golden/views/input_otp/alphanumeric_pattern.html.erb <<'ERB' +<%= render RubyUI::InputOtp.new(length: 4, pattern: "[a-zA-Z0-9]", name: "code") do %><%= render RubyUI::InputOtpGroup.new do %><%= render RubyUI::InputOtpSlot.new(index: 0) %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/form +cat > gem/test/golden/views/form/default.html.erb <<'ERB' +<%= render RubyUI::Form.new(action: "/users", method: "post") do %><%= render RubyUI::FormField.new do %><%= render RubyUI::FormFieldLabel.new(for: "name") do %>Name<% end %><%= render RubyUI::Input.new(id: "name", name: "name", placeholder: "Jane Doe", required: true, minlength: "3") %><%= render RubyUI::FormFieldHint.new do %>At least 3 characters.<% end %><%= render RubyUI::FormFieldError.new do %>Name is required.<% end %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/calendar +cat > gem/test/golden/views/calendar/default.html.erb <<'ERB' +<%= render RubyUI::Calendar.new %> +ERB +cat > gem/test/golden/views/calendar/bound_to_input.html.erb <<'ERB' +<%= render RubyUI::Calendar.new(input_id: "#event-date", selected_date: "2026-05-15", min_date: "2026-05-07", date_format: "dd/MM/yyyy", class: "rounded-md border shadow") %> +ERB +cat > gem/test/golden/views/calendar/header_parts.html.erb <<'ERB' +<%= render RubyUI::CalendarHeader.new do %><%= render RubyUI::CalendarTitle.new(default: "May 2026") %><%= render RubyUI::CalendarPrev.new %><%= render RubyUI::CalendarNext.new %><% end %> +ERB +``` + +- [ ] **Step 2: Run the suite** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected: `327 runs, … 1 failures, 0 errors, 0 skips`; only `GoldenCoverageTest#test_every_scenario_has_a_fixture`; `82 scenarios without an ERB fixture`. + +- [ ] **Step 3: Confirm the ruler did not move** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots gem/test/golden/strict +find gem/test/golden/views -name '*.html.erb' | wc -l +``` + +Expected: no output; `106`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden/views +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: ERB fixtures for the form controls + +checkbox, radio_button, switch, input, textarea, masked_input, +native_select, input_otp, form, calendar — 21 fixtures, green against +the frozen snapshots. 82 scenarios remain. + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +--- + +## Task 6: Batch 5 — layout and status (23 fixtures) + +alert (4), badge (4), avatar (5), breadcrumb (1), card (1), separator (4), skeleton (1), progress (3). `badge/all_variants` is the one loop in the catalog: 28 badges side by side with nothing between them, which is exactly what the strict lane checks. + +**Files:** +- Create: 23 files under `gem/test/golden/views/{alert,badge,avatar,breadcrumb,card,separator,skeleton,progress}/` + +**Interfaces:** +- Consumes: `Golden::Harness.render_erb(scenario)`, `RubyUI::TestApp.view(Golden::Catalog::VIEWS_ROOT)` (Phase 2.0a). +- Produces: 23 fixtures. + +- [ ] **Step 1: Write the fixtures** + +```bash +mkdir -p gem/test/golden/views/alert +cat > gem/test/golden/views/alert/default.html.erb <<'ERB' +<%= render RubyUI::Alert.new do %><%= render RubyUI::AlertTitle.new do %>Heads up!<% end %><%= render RubyUI::AlertDescription.new do %>You can add components to your app.<% end %><% end %> +ERB +cat > gem/test/golden/views/alert/destructive.html.erb <<'ERB' +<%= render RubyUI::Alert.new(variant: :destructive) do %><%= render RubyUI::AlertTitle.new do %>Error<% end %><%= render RubyUI::AlertDescription.new do %>Your session expired.<% end %><% end %> +ERB +cat > gem/test/golden/views/alert/warning.html.erb <<'ERB' +<%= render RubyUI::Alert.new(variant: :warning) do %><%= render RubyUI::AlertTitle.new do %>Careful<% end %><% end %> +ERB +cat > gem/test/golden/views/alert/success.html.erb <<'ERB' +<%= render RubyUI::Alert.new(variant: :success) do %><%= render RubyUI::AlertTitle.new do %>Done<% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/badge +cat > gem/test/golden/views/badge/size_sm.html.erb <<'ERB' +<%= render RubyUI::Badge.new(size: :sm) do %>Badge<% end %> +ERB +cat > gem/test/golden/views/badge/size_md.html.erb <<'ERB' +<%= render RubyUI::Badge.new(size: :md) do %>Badge<% end %> +ERB +cat > gem/test/golden/views/badge/size_lg.html.erb <<'ERB' +<%= render RubyUI::Badge.new(size: :lg) do %>Badge<% end %> +ERB +cat > gem/test/golden/views/badge/all_variants.html.erb <<'ERB' +<% RubyUI::Badge::COLORS.each_key do |variant| %><%= render RubyUI::Badge.new(variant: variant) do %><%= variant %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/avatar +cat > gem/test/golden/views/avatar/image_with_fallback.html.erb <<'ERB' +<%= render RubyUI::Avatar.new do %><%= render RubyUI::AvatarImage.new(src: "/avatar.png", alt: "Jane Doe") %><%= render RubyUI::AvatarFallback.new do %>JD<% end %><% end %> +ERB +cat > gem/test/golden/views/avatar/size_sm.html.erb <<'ERB' +<%= render RubyUI::Avatar.new(size: :sm) do %><%= render RubyUI::AvatarFallback.new do %>JD<% end %><% end %> +ERB +cat > gem/test/golden/views/avatar/size_md.html.erb <<'ERB' +<%= render RubyUI::Avatar.new(size: :md) do %><%= render RubyUI::AvatarFallback.new do %>JD<% end %><% end %> +ERB +cat > gem/test/golden/views/avatar/size_lg.html.erb <<'ERB' +<%= render RubyUI::Avatar.new(size: :lg) do %><%= render RubyUI::AvatarFallback.new do %>JD<% end %><% end %> +ERB +cat > gem/test/golden/views/avatar/size_xl.html.erb <<'ERB' +<%= render RubyUI::Avatar.new(size: :xl) do %><%= render RubyUI::AvatarFallback.new do %>JD<% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/breadcrumb +cat > gem/test/golden/views/breadcrumb/default.html.erb <<'ERB' +<%= render RubyUI::Breadcrumb.new do %><%= render RubyUI::BreadcrumbList.new do %><%= render RubyUI::BreadcrumbItem.new do %><%= render RubyUI::BreadcrumbLink.new(href: "/") do %>Home<% end %><% end %><%= render RubyUI::BreadcrumbSeparator.new %><%= render RubyUI::BreadcrumbItem.new do %><%= render RubyUI::BreadcrumbEllipsis.new %><% end %><%= render RubyUI::BreadcrumbSeparator.new %><%= render RubyUI::BreadcrumbItem.new do %><%= render RubyUI::BreadcrumbPage.new do %>Current<% end %><% end %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/card +cat > gem/test/golden/views/card/default.html.erb <<'ERB' +<%= render RubyUI::Card.new do %><%= render RubyUI::CardHeader.new do %><%= render RubyUI::CardTitle.new do %>Create project<% end %><%= render RubyUI::CardDescription.new do %>Deploy your new project in one click.<% end %><% end %><%= render RubyUI::CardContent.new do %>Body<% end %><%= render RubyUI::CardFooter.new do %><%= render RubyUI::Button.new do %>Deploy<% end %><% end %><% end %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/separator +cat > gem/test/golden/views/separator/default.html.erb <<'ERB' +<%= render RubyUI::Separator.new %> +ERB +cat > gem/test/golden/views/separator/vertical.html.erb <<'ERB' +<%= render RubyUI::Separator.new(orientation: :vertical) %> +ERB +cat > gem/test/golden/views/separator/not_decorative.html.erb <<'ERB' +<%= render RubyUI::Separator.new(decorative: false) %> +ERB +cat > gem/test/golden/views/separator/as_hr.html.erb <<'ERB' +<%= render RubyUI::Separator.new(as: :hr) %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/skeleton +cat > gem/test/golden/views/skeleton/default.html.erb <<'ERB' +<%= render RubyUI::Skeleton.new(class: "w-14 h-14") %> +ERB +``` + +```bash +mkdir -p gem/test/golden/views/progress +cat > gem/test/golden/views/progress/value_0.html.erb <<'ERB' +<%= render RubyUI::Progress.new(value: 0) %> +ERB +cat > gem/test/golden/views/progress/value_33_5.html.erb <<'ERB' +<%= render RubyUI::Progress.new(value: 33.5) %> +ERB +cat > gem/test/golden/views/progress/value_100.html.erb <<'ERB' +<%= render RubyUI::Progress.new(value: 100) %> +ERB +``` + +- [ ] **Step 2: Run the suite** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui/gem +bundle exec rake golden 2>&1 | grep -oE "[0-9]+ runs, [0-9]+ assertions, [0-9]+ failures, [0-9]+ errors, [0-9]+ skips|[0-9]+ scenarios without an ERB fixture|(Failure|Error):.*|Golden[A-Za-z]+Test#test_[a-z_0-9]+" +``` + +Expected: `350 runs, … 1 failures, 0 errors, 0 skips`; only `GoldenCoverageTest#test_every_scenario_has_a_fixture`; `59 scenarios without an ERB fixture`. + +- [ ] **Step 3: Confirm the ruler did not move** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git status --porcelain gem/test/golden/snapshots gem/test/golden/strict +find gem/test/golden/views -name '*.html.erb' | wc -l +``` + +Expected: no output; `129`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/cirdes/Workspaces/ruby_ui +git add gem/test/golden/views +git commit -m "$(cat <<'MSG' +[Feature] Golden suite: ERB fixtures for layout and status + +alert, badge, avatar, breadcrumb, card, separator, skeleton, progress — +23 fixtures, green against the frozen snapshots; badge/all_variants is +the catalog's one loop, 28 badges with nothing between them. 59 +scenarios remain. + +Co-Authored-By: Claude Fable 5.1 +MSG +)" +``` + +--- + +## Task 7: Batch 6 — content and composition (23 fixtures) + +empty (2), table (2), tabs (1), pagination (1), carousel (3), chart (1), clipboard (4), theme_toggle (1), bubble (4), message (1), message_scroller (3). `table/detached_row` renders a bare ``; the canonical form parses inside a `