Skip to content

[Feature] RubyUI 2.0 — Phase 2.0b: the other 173 ERB fixtures - #554

Open
cirdes wants to merge 13 commits into
v2/foundationfrom
v2/fixtures
Open

cirdes wants to merge 13 commits into
v2/foundationfrom
v2/fixtures

Conversation

@cirdes

@cirdes cirdes commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

What

Stacked on #548. Every one of the 188 golden scenarios now has an ERB
fixture under gem/test/golden/views/, rendered through the harness
(ReActionView → Herb) and compared against the canonical and strict
snapshots Phase 2.0a froze from Phlex — with every component still Phlex,
through phlex-rails. Button's 15 were in #548; the other 173 are here, in
seven batches, each green before the next.

  • A coverage test, every scenario has a fixture, red from the first commit
    (173 missing) to the last (0). Its message was the inventory.

  • Every fixture is one line, plus a trailing newline. Decision 10 records why:
    measured through Herb 0.10.4, the end closing a <%= … do %> block
    ignores its own trim markers and the indentation before an output tag is
    emitted, so an indented one-render-per-line template is not
    whitespace-tight — and the strict lane would see every such character.
    The plan's Appendix A has each probe's source and output.

  • The shapes the catalog uses — a block parameter writing raw HTML,
    yield(self) with group.ToggleGroupItem, DataTableForm reaching for
    the view context, a block yielding nothing, a Ruby loop, a heredoc — were
    each measured strict-identical before the plan was written; the plan's
    evidence table has the list.

  • One harness fix the lane exposed (Task 7b). ActionView instruments every
    render, and the first instrumentation on a thread creates ActiveSupport's
    Instrumenter, whose id is SecureRandom.hex(10) — inside the harness pin,
    on the first ERB render of a process, that call took the counter's first
    value and shifted every generated id in that render by one, so
    tooltip/default, select/default or date_picker/generated_id failed
    their own determinism check when one of them ran first (about 3 runs in
    190). render_erb now creates the instrumenter before the pin; a test
    renders twice on a fresh thread and asserts identical output. This amended
    the plan's "harness does not change" boundary by a ruling taken during
    execution — please ratify or revert it explicitly in review.

No component changes, no snapshot changes. Nothing under gem/lib/, docs/
or mcp/ moves; the harness changes by that one line.

Why

Decision 7: with every fixture written before any component migrates, a
migration from 2.1 on changes only an implementation, never the ruler — "did I
write the fixture right" and "did I port the component right" stop being one
failure. Plan: design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md.

Test steps

cd gem
bundle exec rake            # 776 runs, 0 failures, 0 skips; 423 files, no offenses
bundle exec rake golden     # 410 runs: 188 Phlex-lane + 188 ERB-lane + 7 coverage + 27 of the ruler's own

To see a fixture fail, add a space before <% end %> in
test/golden/views/shortcut_key/default.html.erb and re-run rake golden:
the canonical form passes, the strict form fails — whitespace only, exactly
what the strict lane exists for.

🤖 Generated with Claude Code


Summary by cubic

Adds the remaining 173 ERB fixtures so all 188 golden scenarios render through the harness and compare against the snapshots Phase 2.0a froze from Phlex. Components stay on Phlex this phase, so a later migration changes only an implementation, never the test baseline.

A coverage test enforces that every scenario has a fixture; it was red at 173 missing and is green now.

Harness fix

  • The first ERB render on a thread could shift every generated id by one: ActionView's first instrumentation creates an Instrumenter whose SecureRandom.hex(10) id landed inside the harness pin. render_erb now creates the instrumenter before the pin, with a regression test rendering twice on a fresh thread.

All fixtures are one line because measured Herb 0.10.4 trim behavior keeps the newline after a block-closing end and emits indentation before output tags; recorded as decision 10.

Written for commit 8ac23ac. Summary will update on new commits.

Review in cubic

cirdes and others added 13 commits September 20, 2026 16:15
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…-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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
empty, table, tabs, pagination, carousel, chart, clipboard,
theme_toggle, bubble, message, message_scroller — 23 fixtures, green
against the frozen snapshots. 36 scenarios remain: link and
typography.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d the instrumenter id

A controller run after Task 7 caught tooltip/default's ERB lane failing
its own determinism check, once in fourteen runs. The cause is in the
harness pin, not in a fixture: ActiveSupport's per-thread Instrumenter
mints its id with SecureRandom.hex on the first instrumented render,
inside the pin window. Task 7b records the evidence and the ruling that
amends the plan's "harness does not change" boundary for one line and
one test; the end-state counts move to 410 golden runs and 776 total.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rumenter id to the pin

ActionView instruments every render, and the first instrumentation on a
thread creates ActiveSupport's Instrumenter, whose id is
SecureRandom.hex(10). That call landed inside the harness pin on the
first ERB render of a process, took the counter's first value, and
shifted every generated id in that one render by one — so
tooltip/default, select/default or date_picker/generated_id failed
their own determinism check whenever one of them was the first ERB-lane
test to run (about 3 runs in 190). render_erb now creates the
instrumenter before activating the pin; a test renders twice on a fresh
thread, where the registry is empty, and asserts identical output.

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

The last 36: link's six variants, four sizes and icon; typography's
headings, nine text sizes, four weights, four elements, inline code,
inline link and blockquote. 188 scenarios, 188 fixtures, 188 canonical
and 188 strict snapshots, every component still Phlex. From here a
migration changes an implementation, never the ruler.

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

Through Herb 0.10.4, `-%>` removes the newline after an opening tag and
`<%-` the indentation before one, but the `end` closing a `<%= … do %>`
block ignores its own trim markers and the indentation before an output
tag is emitted — so an indented one-render-per-line template is not
whitespace-tight, and decision 8's "or <%-/-%>" does not hold for it.
Fixtures are one line; the two layouts that do emit nothing are the
candidates for sidecars, decided in 2.1. Spec §6 Phase 2.0, §6.2 and
§9.1 amended; the catalog header points at the fixtures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… wording, plan counts

The final review found two spec passages the Task 9 amendments left
inconsistent (§7's testing table and §9.4 still named a text-only strict
lane and trim mode), decision 10's first bullet crediting `<%-` for what
Herb's line-start rule does with or without markers, a reversal condition
that named one of two needed changes, an overstated cost, and two counts
in the plan (Task 7b's assertion count, the PR body's test breakdown).
All prose; no code, fixture or snapshot changes; the coverage test's
comment moves to the past tense.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 180 files

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

Prompt for AI agents (unresolved issues)

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


<file name="gem/test/golden/views/clipboard/popover_success.html.erb">

<violation number="1" location="gem/test/golden/views/clipboard/popover_success.html.erb:1">
P2: This fixture renders a ClipboardPopover whose panel is hidden at initial render (parent div has class="hidden"), but the recorded snapshot freezes the panel in data-state="open". ContextMenu, HoverCard, and Popover all rest hidden panels in data-state="closed" (see context_menu_content.rb, hover_card_content.rb, popover_content.rb), and starting "open" leaves the animate-in classes active at rest when the panel is not visible. Since this PR records the golden snapshot for this scenario, set ClipboardPopover#default_attrs state to :closed so hidden popovers start closed, then re-record the snapshot.</violation>
</file>

<file name="gem/test/golden/views/switch/checked_without_hidden_input.html.erb">

<violation number="1" location="gem/test/golden/views/switch/checked_without_hidden_input.html.erb:1">
P2: This new fixture's golden snapshot locks in an inaccessible switch: the label carries role="switch" but no aria-checked, and the actual control (checkbox class="hidden peer") is display:none, so it is not keyboard-focusable. The fixture faithfully records the component's current behavior, so the fix belongs in lib/ruby_ui/switch/switch.rb: mirror the checked state onto aria-checked on the label and make the checkbox visually hidden but focusable (e.g. replace the "hidden" class with "sr-only peer") instead of display:none. Re-record this snapshot (and the switch default snapshot) once the component is updated.</violation>
</file>

<file name="gem/test/golden/harness.rb">

<violation number="1" location="gem/test/golden/harness.rb:39">
P3: The fix makes the Instrumenter deterministic on a single thread, but the pin state it relies on is not thread-local: `@active`, `@hex_calls`, and `@rand_calls` are instance variables on the `Golden::Harness` module, shared by every thread and reset by each render. If two threads render concurrently (the fresh-thread test in `gem/test/golden/harness_test.rb` is the first code path that exercises threads), the second thread's `ActiveSupport::Notifications.instrumenter` call can run while another thread has `@active == true`; its `SecureRandom.hex(10)` then calls `next_hex` and consumes a shared counter slot, shifting the ids of the in-flight render and of the new render. Each render also rewrites the shared counters and toggles `@active`, so one thread's `ensure` turns the pin off while another thread is still rendering — letting real randomness leak into a pinned render. The claimed "identical output on a fresh thread" property therefore holds only when renders across threads never overlap. Make the counters and `@active` thread-local (Thread.current) or serialize renders with a Mutex, or document that the harness is single-threaded-only.</violation>
</file>

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

Re-trigger cubic

@@ -0,0 +1 @@
<%= render RubyUI::ClipboardPopover.new(type: :success) do %>success<% end %>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This fixture renders a ClipboardPopover whose panel is hidden at initial render (parent div has class="hidden"), but the recorded snapshot freezes the panel in data-state="open". ContextMenu, HoverCard, and Popover all rest hidden panels in data-state="closed" (see context_menu_content.rb, hover_card_content.rb, popover_content.rb), and starting "open" leaves the animate-in classes active at rest when the panel is not visible. Since this PR records the golden snapshot for this scenario, set ClipboardPopover#default_attrs state to :closed so hidden popovers start closed, then re-record the snapshot.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/test/golden/views/clipboard/popover_success.html.erb, line 1:

<comment>This fixture renders a ClipboardPopover whose panel is hidden at initial render (parent div has class="hidden"), but the recorded snapshot freezes the panel in data-state="open". ContextMenu, HoverCard, and Popover all rest hidden panels in data-state="closed" (see context_menu_content.rb, hover_card_content.rb, popover_content.rb), and starting "open" leaves the animate-in classes active at rest when the panel is not visible. Since this PR records the golden snapshot for this scenario, set ClipboardPopover#default_attrs state to :closed so hidden popovers start closed, then re-record the snapshot.</comment>

<file context>
@@ -0,0 +1 @@
+<%= render RubyUI::ClipboardPopover.new(type: :success) do %>success<% end %>
</file context>

@@ -0,0 +1 @@
<%= render RubyUI::Switch.new(name: "notifications", include_hidden: false, checked: true, checked_value: "yes", unchecked_value: "no") %>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This new fixture's golden snapshot locks in an inaccessible switch: the label carries role="switch" but no aria-checked, and the actual control (checkbox class="hidden peer") is display:none, so it is not keyboard-focusable. The fixture faithfully records the component's current behavior, so the fix belongs in lib/ruby_ui/switch/switch.rb: mirror the checked state onto aria-checked on the label and make the checkbox visually hidden but focusable (e.g. replace the "hidden" class with "sr-only peer") instead of display:none. Re-record this snapshot (and the switch default snapshot) once the component is updated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/test/golden/views/switch/checked_without_hidden_input.html.erb, line 1:

<comment>This new fixture's golden snapshot locks in an inaccessible switch: the label carries role="switch" but no aria-checked, and the actual control (checkbox class="hidden peer") is display:none, so it is not keyboard-focusable. The fixture faithfully records the component's current behavior, so the fix belongs in lib/ruby_ui/switch/switch.rb: mirror the checked state onto aria-checked on the label and make the checkbox visually hidden but focusable (e.g. replace the "hidden" class with "sr-only peer") instead of display:none. Re-record this snapshot (and the switch default snapshot) once the component is updated.</comment>

<file context>
@@ -0,0 +1 @@
+<%= render RubyUI::Switch.new(name: "notifications", include_hidden: false, checked: true, checked_value: "yes", unchecked_value: "no") %>
</file context>

# thread creates the Instrumenter, whose id is SecureRandom.hex(10).
# Inside the pin that call would take the counter's first value and
# shift every generated id in that one render by one. Create it first.
ActiveSupport::Notifications.instrumenter

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The fix makes the Instrumenter deterministic on a single thread, but the pin state it relies on is not thread-local: @active, @hex_calls, and @rand_calls are instance variables on the Golden::Harness module, shared by every thread and reset by each render. If two threads render concurrently (the fresh-thread test in gem/test/golden/harness_test.rb is the first code path that exercises threads), the second thread's ActiveSupport::Notifications.instrumenter call can run while another thread has @active == true; its SecureRandom.hex(10) then calls next_hex and consumes a shared counter slot, shifting the ids of the in-flight render and of the new render. Each render also rewrites the shared counters and toggles @active, so one thread's ensure turns the pin off while another thread is still rendering — letting real randomness leak into a pinned render. The claimed "identical output on a fresh thread" property therefore holds only when renders across threads never overlap. Make the counters and @active thread-local (Thread.current) or serialize renders with a Mutex, or document that the harness is single-threaded-only.

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

<comment>The fix makes the Instrumenter deterministic on a single thread, but the pin state it relies on is not thread-local: `@active`, `@hex_calls`, and `@rand_calls` are instance variables on the `Golden::Harness` module, shared by every thread and reset by each render. If two threads render concurrently (the fresh-thread test in `gem/test/golden/harness_test.rb` is the first code path that exercises threads), the second thread's `ActiveSupport::Notifications.instrumenter` call can run while another thread has `@active == true`; its `SecureRandom.hex(10)` then calls `next_hex` and consumes a shared counter slot, shifting the ids of the in-flight render and of the new render. Each render also rewrites the shared counters and toggles `@active`, so one thread's `ensure` turns the pin off while another thread is still rendering — letting real randomness leak into a pinned render. The claimed "identical output on a fresh thread" property therefore holds only when renders across threads never overlap. Make the counters and `@active` thread-local (Thread.current) or serialize renders with a Mutex, or document that the harness is single-threaded-only.</comment>

<file context>
@@ -32,6 +32,11 @@ def render(&block)
+        # thread creates the Instrumenter, whose id is SecureRandom.hex(10).
+        # Inside the pin that call would take the counter's first value and
+        # shift every generated id in that one render by one. Create it first.
+        ActiveSupport::Notifications.instrumenter
         @active = true
         @hex_calls = 0
</file context>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant