diff --git a/design/2026-09-19-rubyui-2-0-design.md b/design/2026-09-19-rubyui-2-0-design.md index 9ee6bdc5..7b625b1d 100644 --- a/design/2026-09-19-rubyui-2-0-design.md +++ b/design/2026-09-19-rubyui-2-0-design.md @@ -129,12 +129,15 @@ they were written and proved: | File | Responsibility | | --- | --- | -| `base.rb` | `initialize(**attrs)` → `attrs`; `render_in(view_context, **, &block)` → capture into `content`, render the sidecar; `template_path` derived from the class file; `helpers` (view context, render-time only) | +| `base.rb` | `initialize(**attrs)` → `attrs`; `render_in(view_context, **, &block)` → capture into `content`, render the sidecar; `template` resolved from `source_file` under the class's `component_root`; `helpers` (view context, render-time only) | | `attributes.rb` | `mix` (Phlex `Helpers#mix` semantics), `merge_classes` (tailwind_merge), `flat` (Phlex 2.4.1 serialization → flat string-keyed hash for `tag.attributes`) | 226 lines at the end of the gate, against a 500-line ceiling. The ceiling stands for 2.0. `base.rb` and `attributes.rb` are copied into the host app by -the installer, so every line is a line the user reads. +the installer, so every line is a line the user reads. During Phase 2 the +class is named `RubyUI::Component`, because `RubyUI::Base` is still the Phlex +base the unmigrated components inherit; it takes the name `Base` in Phase 2.4 +(decision 6). Differences from 1.6's `Base` to carry into the documentation: @@ -156,12 +159,14 @@ Differences from 1.6's `Base` to carry into the documentation: - `render_in` assigns `content` on every call — `nil` when there is no block. The gate's version assigned it only with a block, so an instance rendered twice repeated its first content. -- The sidecar is found through a lookup **scoped to the directory that holds - `ruby_ui/`**, not through the application's view-path chain, and two - candidates for one component is an error. Review showed that with the - chain, `append` lets a host `ruby_ui/…` template silently replace the - sidecar and `prepend` lets the sidecar silently shadow the host — the - ordering only picks which side loses quietly. +- The sidecar is found through a lookup **scoped to the root that holds the + class file** (`RubyUI.component_roots`, §4.4), not through the application's + view-path chain: the host's view paths are never consulted, so a host + template at the same virtual path is not picked (tested; decision 9), and a + class has exactly one sidecar — one class file, one directory. Review showed + that with the chain, `append` lets a host `ruby_ui/…` template silently + replace the sidecar and `prepend` lets the sidecar silently shadow the host + — the ordering only picks which side loses quietly. - The development-only `` comment is dropped. ### 4.4 Distribution and install @@ -178,16 +183,17 @@ Rails.autoloaders.main.inflector.inflect("ruby_ui" => "RubyUI") Rails.autoloaders.main.push_dir(Rails.root.join("app/components/ruby_ui"), namespace: RubyUI) Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*")) -RubyUI.component_root = Rails.root.join("app/components") +RubyUI.component_roots = [Rails.root.join("app/components")] ReActionView.config.intercept_erb = true ReActionView.config.validation_mode = :raise ``` `extend Phlex::Kit` is removed. Zeitwerk ignores `.html.erb`, so the sidecar -living in an autoloaded directory is inert. `component_root` is the one -directory the sidecar lookup searches (§4.3); `app/components` is **not** -added to the application's view paths, so nothing about how the host resolves +living in an autoloaded directory is inert. `component_roots` lists the +directories the sidecar lookup searches — one, in a host app (§4.3); +`app/components` is **not** added to the application's view paths, so nothing +about how the host resolves its own templates changes, and ViewComponent — whose home directory this is — is untouched. @@ -222,7 +228,7 @@ execution go in `design/v2/decisions.md`, one line each, with the reason. | 6 | The golden suite lands on `main` in its own PR, before any 2.0 work | It protects 1.6 today — it catches regressions in ordinary bug-fix PRs. On a v2 branch it would protect nothing, and `main` and the branch would diverge in exactly the file that defines what "identical" means. `f7cbeda` is self-contained and cherry-picks cleanly. | | 7 | **A.** Test harness is `actionview` + `reactionview`, no controller, no dummy app | Exactly one component touches the view context — `DataTableForm`, for CSRF — and it already falls back to the literal `"csrf-token-placeholder"` that the snapshots recorded. `DataTableSortHead`, the obvious candidate for needing routes, builds its URL with `CGI` from an explicit `path:`. `reactionview` is included because decision D makes Herb what users compile with; testing on Erubi would test something we do not ship, and it puts the Herb validators over every sidecar on every CI run. | | 8 | **B.** A coercion helper in `Base`; not the `literal` gem | ~14 components index Symbol-keyed hashes with a user-supplied value and 11 already call `.to_sym`. `DialogContent` and `Badge` do not: `SIZES["lg"]` is `nil`, the class is dropped, nothing is raised. This is a 1.6 bug reachable from `params`, independent of any tag syntax. `Literal::Enum#coerce` looks up by member value and never treats `"lg"` and `:lg` as equivalent, so it does not remove the coercion — it would earn its place only as a full object model (`Base < Literal::Object`, `prop` replacing every constructor), which is a second large migration stacked on the first. Recorded as a legitimate 3.0 direction. | -| 9 | **C.** Sidecar next to the class, found by a lookup scoped to the component root | Keeps class, template and Stimulus controller in one directory, as 1.6 already keeps class and controller. The gate proved the sidecar-next-to-class shape in development with reloading and in production with eager loading, but resolved it through the application's view-path chain; review showed that both `prepend` and `append` merely choose which side of a name collision loses silently. A lookup scoped to one root has no other side: a collision with the host is impossible, and two sidecars for one class is an error. | +| 9 | **C.** Sidecar next to the class, found by a lookup scoped to the component root | Keeps class, template and Stimulus controller in one directory, as 1.6 already keeps class and controller. The gate proved the sidecar-next-to-class shape in development with reloading and in production with eager loading, but resolved it through the application's view-path chain; review showed that both `prepend` and `append` merely choose which side of a name collision loses silently. A lookup scoped to one root has no other side: a collision with the host is impossible, and a class has exactly one sidecar (one class file, one directory). | | 10 | **D.** Herb is required from 2.0.0, through ReActionView | Verified on released Rails (§8): `rails 8.1.3.1 + reactionview 0.4.1 + herb 0.10.4` resolves, boots, renders, runs custom transform visitors, and rejects malformed HTML at compile time. Every user gets validation from day one, and when herb ships component tags every user gets the tag syntax through `bundle update` — no reinstall, no migration. The accepted cost: two pre-1.0 gems become required, and `intercept_erb` compiles the whole host app through Herb, so a user with malformed HTML anywhere sees it on install day. `validation_mode` does not soften this — `:none` empties the template and `:overlay` replaces it — so the installer preflights the host's templates (§4.4), and the only opt-out is `intercept_erb = false`, which also disables the tag syntax. | ## 6. Phases @@ -279,49 +285,49 @@ code. - Promote `Base` and `Attributes` into `gem/lib/ruby_ui/`, with tests of their own and the differential test against Phlex 2.4.1 kept. -- Replace the test harness: `actionview` as a development dependency - (`reactionview` is already a runtime one, §4.4); a minimal `ActionView::Base` - with a view path into `gem/lib/ruby_ui`, with ReActionView's handler - registered so tests compile exactly as users will. - `ComponentTest#phlex { }` is replaced by rendering an ERB fixture. -- Add the **ERB lane** to the golden suite: the 188 scenarios become - `.html.erb` fixtures under `gem/test/golden/views/`, rendered through the 2.0 - component and compared against the frozen snapshot. +- Replace the test harness: `railties`, `actionview`, `reactionview` and + `phlex-rails` as development dependencies, and an inline `Rails::Application` + in `test_helper.rb` with the gem as `Rails.root` — ReActionView registers its ERB + handler only when an application boots; without one Erubi compiles every + template and Herb validates nothing (decision 5). `ComponentTest#phlex { }` stays for the + Phlex-lane tests until the last Phlex component goes; `render_erb` renders a + test view through the harness. +- Add the **ERB lane** to the golden suite: a scenario may have an ERB fixture + under `gem/test/golden/views/`, rendered through the harness and compared + against the same frozen snapshot as its own test. With `phlex-rails` loaded a + fixture renders a component that is still Phlex, so all 188 fixtures are + written before any migration — Button's 15 in plan 2.0a, the rest in 2.0b + (decision 7). - Implement the `enum` coercion helper in `Base`. - Port Phlex's attribute guards into `Attributes` (§4.3), with unit tests that assert an unsafe name raises and a `javascript:` reference is dropped. - `render_in` assigns `content` on every call; test that an instance rendered twice, the second time without a block, renders empty content. -- Implement the scoped sidecar lookup (§4.3, §4.4) and test it against a - host template at the same virtual path, against two overlapping roots, and - across two view contexts. The gate's `@template_path ||=` cache is keyed per - class and ignored the view context; the scoped lookup must not. -- Define the **strict lane**. The canonical form is, by design, blind to - whitespace at a text–element boundary and between inline siblings (§9.1). - Sidecars are therefore written in ERB trim mode (`<%-` / `-%>`) so they emit - no whitespace Phlex did not, and the components that carry text — - Typography, InlineCode, InlineLink, ShortcutKey, Badge, FormFieldError and - the others the Phase 1 inventory names — are additionally compared **raw**, - with only attribute order normalized. A strict-lane failure is a real +- Implement the scoped sidecar lookup: `RubyUI.component_roots`, a + `LookupContext` per root, the sidecar found under the root that holds the + class file and nowhere else; the `Template` is not cached on the class, so + Rails' reloader reaches it (decision 9). Tested against a host template at + the same virtual path, a class under no root, and a class with no sidecar. +- Define the **strict lane**: `CanonicalHtml.call(html, strict: true)`, the + preserve-mode form, with its own snapshot for every scenario under + `gem/test/golden/strict/`, recorded from Phlex (decision 8). Sidecars and + fixtures are written whitespace-tight. A strict-lane failure is a real difference, not noise. - **MCP.** `mcp/data/registry.json` embeds the source of every component file and CI rebuilds it and fails on any diff. Every Phase 2 PR that touches `gem/lib/ruby_ui` rebuilds it (`cd mcp && bundle exec exe/ruby-ui-mcp-build`) and commits the result. `RegistryBuilder` also extracts examples from `*_docs.rb`; decision 11 (Phase 2.3) says what happens to those. -- **Fresh-app install test.** A script, run in CI, that does `rails new`, adds - the gem, runs the installer and the preflight, generates one component and - renders it through a request. The golden suite renders without Rails and - cannot see installation, reloading, CSRF (`DataTableForm` falls back to a - placeholder outside a request) or assets. +- **Fresh-app install test** — moved to Phase 2.4 (decision 9): it only means + something once the installer writes the 2.0 initializer. - Point `docs/Gemfile` at the published `ruby_ui` 1.6 instead of `path: "../gem"`, so the site keeps building and the CI Docs job stays green while the gem is mid-migration. Phase 3 reverts it. **Acceptance.** The layer is in the gem with its own tests, guards included. -The ERB lane runs with at least one component at parity, and the strict lane -with at least one text-bearing component. The fresh-app script passes. All -three CI jobs are green with the registry rebuilt. +The ERB lane is green for Button's 15 scenarios against the frozen snapshots +with Button still Phlex, and the strict lane holds one recorded snapshot per +scenario (188). All five CI jobs are green with the registry unchanged. #### 2.1 The hard components first @@ -341,6 +347,11 @@ stays available and documented. This is item 2 of §9.2. **Acceptance.** The 18 snapshots of these four components identical to the frozen contract; the 1.6 Stimulus controllers unedited. +Decide whether a subclass inherits its parent's sidecar: a host `class +MyButton < RubyUI::Button` has no sidecar of its own, and 1.6 inherited +`view_template`. Either `template` walks `ancestors` to the first class with +a sidecar, or the difference is listed in §4.3. + #### 2.2 The bulk The remaining ~50 components, in batches. @@ -390,7 +401,7 @@ What that pulls into Phase 2: The doc pages are not components and the golden suite does not cover them; Phase 3 is where they are first looked at on screen, in the site. -**Acceptance.** In the fresh-app install script (Phase 2.0), +**Acceptance.** In the fresh-app install script (Phase 2.4), `rails g ruby_ui:install:docs` copies pages and example files that render through a request; every example file compiles through Herb; the golden suite is unaffected; no `*_docs.rb` remains in the gem. @@ -403,6 +414,15 @@ and `attributes.rb`. `dependencies.yml` is unchanged — it describes JS packages. The gemspec drops `phlex` and gains `tailwind_merge` and `reactionview` as runtime dependencies. +`component_root` compares `const_source_location` with `component_roots` by +prefix; normalise both with `File.expand_path` so a symlinked `Rails.root` (a +Capistrano `current/`) cannot make them disagree. + +`attributes.rb` defines its constants unguarded; 1.6's `Base` guards +`TAILWIND_MERGER` with `unless defined?` for the gem-plus-copy case. Decide +whether the installer copies `attributes.rb` at all or the gem's copy is +authoritative, and guard or not accordingly. + #### 2.5 Release Version, CHANGELOG, and the manual migration guide. At this point the @@ -617,10 +637,11 @@ count never named. 3. **Phase 2.0 defines a strict lane** — raw output, attribute order normalized, nothing else — for components that carry text. -What the canonical form still does not see, stated as the contract's -exclusion: whitespace between two element siblings, and whitespace at a -text–element boundary, in `:normal` mode. The inventory script stays in the -tree as a way to find candidates, not as a criterion. +As of Phase 2.0a the strict lane closes this for every scenario: each also +keeps its preserve-mode form, so whitespace between element siblings and at +text–element boundaries is part of the contract, not an exclusion (decision 8). +The inventory script stays in the tree as a way to read the catalog, not as a +criterion. ### 9.2 Three questions for upstream @@ -660,10 +681,10 @@ behaviour on an empty-versus-whitespace-only element (§9.1), which no inline-adjacency measure names. What bounds it now is §9.1's three-part resolution — the hardened canonical form catches the empty/whitespace-only case for every component, trim mode stops the ERB lane emitting what Phlex did -not, and the strict lane compares text-bearing components raw. Accepted -knowingly, with that shape: what remains unseen is whitespace between element -siblings and at text boundaries in components the strict lane does not cover, -and Phase 3 is where a browser first looks. +not, and the strict lane compares every scenario in preserve mode (decision 8). +Accepted knowingly, with that shape: what remains unseen is what no HTML +comparison can see — layout, focus, the controllers' behaviour — and Phase 3 +is where a browser first looks. ## 10. Risks @@ -673,8 +694,8 @@ and Phase 3 is where a browser first looks. | `intercept_erb` validates the host app's own templates | A user with malformed HTML anywhere sees errors on install day — and `validation_mode` does not soften it: `:none` empties the template, `:overlay` replaces it (§8) | The installer preflights every host template through `Herb::Engine` before enabling interception and lists what would break; the only opt-out is `intercept_erb = false`, documented as also disabling the tag syntax | | herb 0.11 requires a new ReActionView release too | The tag syntax waits on two projects, not one | Decision D ships 2.0.0 without depending on either date | | The documentation site runs Phlex while the library no longer does | Between 2.5 and Phase 3 | Acknowledged in the release announcement rather than discovered by readers | -| `app/components` is also ViewComponent's directory | ViewComponent users | `app/components` is not added to the view paths; the sidecar lookup is scoped to `component_root` (§4.4) | -| Sidecar name collision with a host template | Silent replacement in either direction with a view-path lookup | Lookup scoped to the component root; collision is an error (§4.3) | +| `app/components` is also ViewComponent's directory | ViewComponent users | `app/components` is not added to the view paths; the sidecar lookup is scoped to `component_roots` (§4.4) | +| Sidecar name collision with a host template | Silent replacement in either direction with a view-path lookup | Lookup scoped to the component root; the host's view paths are never consulted, so a host template at the same virtual path is not picked (§4.3, tested) | | Removing Phlex's attribute guards | Untrusted attribute values reach the page as `javascript:` URLs or `on*` handlers | Guards ported into `Attributes` with their own tests (§4.3) | | `mcp/data/registry.json` embeds component source | Every gem change; CI fails on a stale registry | Rebuilt and committed in every PR that touches `gem/lib/ruby_ui` (Phase 2.0) | | Single-maintainer upstream | Herb and ReActionView | With `intercept_erb = false` the components render through Erubi unchanged — the gate proved byte parity on both lanes — so the library still functions if upstream stalls, minus validation and the tag syntax | 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..b00bea8b --- /dev/null +++ b/design/plans/2026-09-20-phase-2-0a-foundation-implementation.md @@ -0,0 +1,2130 @@ +# 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 every scenario). 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, one per scenario (188), 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 504 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 + assert_kind_of Class, ActionView::Base # loading it is what swaps the handler in + 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, ... 0 failures, 0 errors`. + +- [ ] **Step 6: Run everything and confirm nothing else moved** + +```bash +cd gem +bundle exec rake +``` + +Expected: `506 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_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 +``` + +- [ ] **Step 3: Run them and confirm the guard tests fail** + +```bash +cd gem +bundle exec rake test N=/AttributesTest/ +``` + +Expected: 10 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 6 FAIL — nothing raises, nothing is dropped, and the javascript-spelling Symbol and the out-of-range reference come through as ordinary strings. + +- [ ] **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 serialized value, character references decoded, starts with + # `javascript:` is dropped — serialized first, as Phlex does, so `href: 1` + # renders and `href: :"javascript:x"` does not. 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 named character references Phlex decodes before the `javascript:` + # check — exactly these three; every other named reference decodes to + # nothing, as in Phlex. Numeric references are decoded in full. + NAMED_REFERENCES = {"colon" => ":", "tab" => "\t", "newline" => "\n"}.freeze +``` + +**(c)** Replace the whole `flat` method with: + +```ruby + def flat(attributes) + attributes.each_with_object({}) do |(key, value), out| + next unless value + + name = key_name(key) + case value + when Hash + (name == "style") ? emit(out, name, styles(value)) : nested(value, "#{name}-", out) + when Array, Set + emit(out, name, (name == "style") ? styles(value) : tokens(value)) + else + emit(out, name, scalar(value)) + end + end + end +``` + +**(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 four private methods after `key_name`: + +```ruby + # Phlex serializes first and guards the serialized value, so `href: 1` + # renders `href="1"` and `href: :"javascript:x"` is dropped. A nil here + # is an empty token list, which omits the attribute. + def emit(out, name, serialized) + return if serialized.nil? + + out[name] = serialized unless guard(name, serialized) == :drop + 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) + + 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 REF_ATTRIBUTES.include?(normalized) + + decode_references(serialized).downcase.delete("^a-z:").start_with?("javascript:") ? :drop : :keep + end + + def decode_references(value) + value + .gsub(/&#x([0-9a-f]+);?/i) { codepoint($1.to_i(16)) } + .gsub(/&#(\d+);?/) { codepoint($1.to_i) } + .gsub(/&([a-z][a-z0-9]+);?/i) { NAMED_REFERENCES[$1.downcase] || "" } + end + + # Phlex swallows a reference it cannot pack; so does this. + def codepoint(number) + [number].pack("U*") + rescue RangeError + "" + end +``` + +- [ ] **Step 5: Run the guard tests and confirm all nine pass** + +```bash +cd gem +bundle exec rake test N=/AttributesTest/ +``` + +Expected: `10 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 + ``` + +- Add three cases to `FLAT_CASES`, after the `"empty token list omits the attribute"` entry — the URL-value shapes the guards must serialize before checking, compared against Phlex like every other case: + + ```ruby + "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)"} + ``` + +- [ ] **Step 7: Run the differential test and confirm it passes** + +```bash +cd gem +bundle exec rake test N=/AttributesDifferentialTest/ +``` + +Expected: `27 runs, ... 0 failures, 0 errors` — 18 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: `543 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 + 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 +# 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 } +``` + +- [ ] **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: `558 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 + 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 +``` + +- [ ] **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: `563 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; the ERB lane only compares. When the last block goes, the ERB lane records. Because Minitest runs a scenario's two lane tests in random order, the recording is done once per scenario **before either lane compares**, not inside the recording lane's own test — otherwise `golden:update` could read a stale or absent file in the lane that happened to run first. + +**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 + + # 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 +``` + +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 whole `if UPDATE … end` block (three lines) with + +```ruby + record!(scenario) if UPDATE +``` + +**(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 + + # 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 + + def self.recorded + @recorded ||= {} + 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 two coverage tests to `GoldenCoverageTest`. A scenario with neither a block nor a fixture would define no test at all and pass by absence; the first test makes that a failure: + +```ruby + 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 +``` + +- [ ] **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: `210 runs, ... 0 failures, 0 errors, 0 skips` — 188 Phlex-lane scenarios, 5 coverage tests, 12 canonicalizer tests, 5 harness tests. + +- [ ] **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: `225 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: `580 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). Every scenario now 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 — for every component, not a chosen list (decision 8: an audit of the catalog found text inside inline elements in 38 of 54 components, and any list is one review away from missing one). + +**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): 188 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`; `Scenario#strict_snapshot_path`; `Golden::Catalog.strict_files`; `record!` writes both forms. + +- [ ] **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 + + def test_strict_restores_the_newline_the_parser_drops_after_pre_and_textarea + %w[pre textarea].each do |tag| + once = strict("<#{tag}>\n\nx") + + assert_equal "<#{tag}>\n\nx", once + assert_equal once, strict(once) + end + end + + def test_strict_restores_it_for_a_nested_pre_too + once = strict("
\n\nx
") + + assert_equal "
\n\nx
", once + assert_equal once, strict(once) + end +``` + +- [ ] **Step 2: Run them and confirm they fail** + +```bash +cd gem +bundle exec rake test N=/GoldenCanonicalHtmlTest#test_strict/ +``` + +Expected: 8 runs, 8 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 +``` + +The two things strict mode needs from `emit_element` and `child_mode` — the newline the parser drops after `
`/`"
   end
+
+  def strict(html)
+    Golden::CanonicalHtml.call(html, strict: true)
+  end
+
+  def test_strict_trims_only_html_whitespace_at_the_edges
+    assert_equal "\vx\v", strict("\n\vx\v\n")
+  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 + + def test_strict_restores_the_newline_the_parser_drops_after_pre_and_textarea + %w[pre textarea].each do |tag| + once = strict("<#{tag}>\n\nx") + + assert_equal "<#{tag}>\n\nx", once + assert_equal once, strict(once) + end + end + + def test_strict_restores_it_for_a_nested_pre_too + once = strict("
\n\nx
") + + assert_equal "
\n\nx
", once + assert_equal once, strict(once) + end end diff --git a/gem/test/golden/catalog.rb b/gem/test/golden/catalog.rb index d4555fad..5f4f3321 100644 --- a/gem/test/golden/catalog.rb +++ b/gem/test/golden/catalog.rb @@ -6,6 +6,12 @@ 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__) + + # Every scenario also keeps its strict form — the same fragment in + # preserve mode — so whitespace between siblings and at text boundaries is + # part of the contract for every component (spec §9.1, decision 8). + STRICT_ROOT = File.expand_path("strict", __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 +30,32 @@ 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 strict_snapshot_path + File.join(STRICT_ROOT, component, "#{name}.html") + end + + def test_name(lane = nil) + lane ? :"test_#{component}__#{name}__#{lane}" : :"test_#{component}__#{name}" end end @@ -51,6 +81,16 @@ 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 + + def strict_files + Dir.glob(File.join(STRICT_ROOT, "**", "*.html")).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. @@ -61,15 +101,16 @@ def component_directories .sort end - # Every RubyUI::Base subclass shipped by the gem. Non-Base classes in the - # same directories (the DataTable pagination adapters, the Toast flash - # helper module) emit no HTML and are covered by their own unit tests. + # Every RubyUI::Base or RubyUI::Component subclass shipped by the gem. + # Other classes in the same directories (the DataTable pagination + # adapters, the Toast flash helper module) emit no HTML and are covered + # by their own unit tests. def component_classes component_directories .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/strict/accordion/custom_trigger_with_icon.html b/gem/test/golden/strict/accordion/custom_trigger_with_icon.html new file mode 100644 index 00000000..9629ef54 --- /dev/null +++ b/gem/test/golden/strict/accordion/custom_trigger_with_icon.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/accordion/default_trigger_and_content.html b/gem/test/golden/strict/accordion/default_trigger_and_content.html new file mode 100644 index 00000000..bb00c873 --- /dev/null +++ b/gem/test/golden/strict/accordion/default_trigger_and_content.html @@ -0,0 +1 @@ +

Title

Content
\ No newline at end of file diff --git a/gem/test/golden/strict/alert/default.html b/gem/test/golden/strict/alert/default.html new file mode 100644 index 00000000..21e571fc --- /dev/null +++ b/gem/test/golden/strict/alert/default.html @@ -0,0 +1 @@ +
Heads up!
You can add components to your app.
\ No newline at end of file diff --git a/gem/test/golden/strict/alert/destructive.html b/gem/test/golden/strict/alert/destructive.html new file mode 100644 index 00000000..189e6541 --- /dev/null +++ b/gem/test/golden/strict/alert/destructive.html @@ -0,0 +1 @@ +
Error
Your session expired.
\ No newline at end of file diff --git a/gem/test/golden/strict/alert/success.html b/gem/test/golden/strict/alert/success.html new file mode 100644 index 00000000..f2823239 --- /dev/null +++ b/gem/test/golden/strict/alert/success.html @@ -0,0 +1 @@ +
Done
\ No newline at end of file diff --git a/gem/test/golden/strict/alert/warning.html b/gem/test/golden/strict/alert/warning.html new file mode 100644 index 00000000..711df188 --- /dev/null +++ b/gem/test/golden/strict/alert/warning.html @@ -0,0 +1 @@ +
Careful
\ No newline at end of file diff --git a/gem/test/golden/strict/alert_dialog/default.html b/gem/test/golden/strict/alert_dialog/default.html new file mode 100644 index 00000000..b631fb64 --- /dev/null +++ b/gem/test/golden/strict/alert_dialog/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/alert_dialog/open.html b/gem/test/golden/strict/alert_dialog/open.html new file mode 100644 index 00000000..d4bcabec --- /dev/null +++ b/gem/test/golden/strict/alert_dialog/open.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/aspect_ratio/default.html b/gem/test/golden/strict/aspect_ratio/default.html new file mode 100644 index 00000000..17a16ae5 --- /dev/null +++ b/gem/test/golden/strict/aspect_ratio/default.html @@ -0,0 +1 @@ +
Placeholder
\ No newline at end of file diff --git a/gem/test/golden/strict/aspect_ratio/square.html b/gem/test/golden/strict/aspect_ratio/square.html new file mode 100644 index 00000000..108b2b4b --- /dev/null +++ b/gem/test/golden/strict/aspect_ratio/square.html @@ -0,0 +1 @@ +
Placeholder
\ No newline at end of file diff --git a/gem/test/golden/strict/avatar/image_with_fallback.html b/gem/test/golden/strict/avatar/image_with_fallback.html new file mode 100644 index 00000000..c6bc74e0 --- /dev/null +++ b/gem/test/golden/strict/avatar/image_with_fallback.html @@ -0,0 +1 @@ +Jane DoeJD \ No newline at end of file diff --git a/gem/test/golden/strict/avatar/size_lg.html b/gem/test/golden/strict/avatar/size_lg.html new file mode 100644 index 00000000..8e5272fe --- /dev/null +++ b/gem/test/golden/strict/avatar/size_lg.html @@ -0,0 +1 @@ +JD \ No newline at end of file diff --git a/gem/test/golden/strict/avatar/size_md.html b/gem/test/golden/strict/avatar/size_md.html new file mode 100644 index 00000000..043db484 --- /dev/null +++ b/gem/test/golden/strict/avatar/size_md.html @@ -0,0 +1 @@ +JD \ No newline at end of file diff --git a/gem/test/golden/strict/avatar/size_sm.html b/gem/test/golden/strict/avatar/size_sm.html new file mode 100644 index 00000000..7d17179f --- /dev/null +++ b/gem/test/golden/strict/avatar/size_sm.html @@ -0,0 +1 @@ +JD \ No newline at end of file diff --git a/gem/test/golden/strict/avatar/size_xl.html b/gem/test/golden/strict/avatar/size_xl.html new file mode 100644 index 00000000..7e10639a --- /dev/null +++ b/gem/test/golden/strict/avatar/size_xl.html @@ -0,0 +1 @@ +JD \ No newline at end of file diff --git a/gem/test/golden/strict/badge/all_variants.html b/gem/test/golden/strict/badge/all_variants.html new file mode 100644 index 00000000..df40428a --- /dev/null +++ b/gem/test/golden/strict/badge/all_variants.html @@ -0,0 +1 @@ +primarysecondaryoutlinedestructivesuccesswarningslategrayzincneutralstoneredorangeamberyellowlimegreenemeraldtealcyanskyblueindigovioletpurplefuchsiapinkrose \ No newline at end of file diff --git a/gem/test/golden/strict/badge/size_lg.html b/gem/test/golden/strict/badge/size_lg.html new file mode 100644 index 00000000..dbcafe81 --- /dev/null +++ b/gem/test/golden/strict/badge/size_lg.html @@ -0,0 +1 @@ +Badge \ No newline at end of file diff --git a/gem/test/golden/strict/badge/size_md.html b/gem/test/golden/strict/badge/size_md.html new file mode 100644 index 00000000..501e45ac --- /dev/null +++ b/gem/test/golden/strict/badge/size_md.html @@ -0,0 +1 @@ +Badge \ No newline at end of file diff --git a/gem/test/golden/strict/badge/size_sm.html b/gem/test/golden/strict/badge/size_sm.html new file mode 100644 index 00000000..a50063f6 --- /dev/null +++ b/gem/test/golden/strict/badge/size_sm.html @@ -0,0 +1 @@ +Badge \ No newline at end of file diff --git a/gem/test/golden/strict/breadcrumb/default.html b/gem/test/golden/strict/breadcrumb/default.html new file mode 100644 index 00000000..4f9e4b8c --- /dev/null +++ b/gem/test/golden/strict/breadcrumb/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/bubble/content_as_anchor.html b/gem/test/golden/strict/bubble/content_as_anchor.html new file mode 100644 index 00000000..dd14651a --- /dev/null +++ b/gem/test/golden/strict/bubble/content_as_anchor.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/bubble/default.html b/gem/test/golden/strict/bubble/default.html new file mode 100644 index 00000000..65b48928 --- /dev/null +++ b/gem/test/golden/strict/bubble/default.html @@ -0,0 +1 @@ +
Hi
\ No newline at end of file diff --git a/gem/test/golden/strict/bubble/muted_aligned_end_with_reactions.html b/gem/test/golden/strict/bubble/muted_aligned_end_with_reactions.html new file mode 100644 index 00000000..edbc4b3a --- /dev/null +++ b/gem/test/golden/strict/bubble/muted_aligned_end_with_reactions.html @@ -0,0 +1 @@ +
Hi
OK
\ No newline at end of file diff --git a/gem/test/golden/strict/bubble/reactions_top_start.html b/gem/test/golden/strict/bubble/reactions_top_start.html new file mode 100644 index 00000000..87f0b743 --- /dev/null +++ b/gem/test/golden/strict/bubble/reactions_top_start.html @@ -0,0 +1 @@ +
OK
\ No newline at end of file diff --git a/gem/test/golden/strict/button/icon_size_lg.html b/gem/test/golden/strict/button/icon_size_lg.html new file mode 100644 index 00000000..faf57d0d --- /dev/null +++ b/gem/test/golden/strict/button/icon_size_lg.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/icon_size_md.html b/gem/test/golden/strict/button/icon_size_md.html new file mode 100644 index 00000000..c9e1ef6b --- /dev/null +++ b/gem/test/golden/strict/button/icon_size_md.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/icon_size_sm.html b/gem/test/golden/strict/button/icon_size_sm.html new file mode 100644 index 00000000..e3cf2737 --- /dev/null +++ b/gem/test/golden/strict/button/icon_size_sm.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/icon_size_xl.html b/gem/test/golden/strict/button/icon_size_xl.html new file mode 100644 index 00000000..cf65daf7 --- /dev/null +++ b/gem/test/golden/strict/button/icon_size_xl.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/size_lg.html b/gem/test/golden/strict/button/size_lg.html new file mode 100644 index 00000000..6bd70af0 --- /dev/null +++ b/gem/test/golden/strict/button/size_lg.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/size_md.html b/gem/test/golden/strict/button/size_md.html new file mode 100644 index 00000000..c3051d85 --- /dev/null +++ b/gem/test/golden/strict/button/size_md.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/size_sm.html b/gem/test/golden/strict/button/size_sm.html new file mode 100644 index 00000000..740457ef --- /dev/null +++ b/gem/test/golden/strict/button/size_sm.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/size_xl.html b/gem/test/golden/strict/button/size_xl.html new file mode 100644 index 00000000..be968d0e --- /dev/null +++ b/gem/test/golden/strict/button/size_xl.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/submit_disabled.html b/gem/test/golden/strict/button/submit_disabled.html new file mode 100644 index 00000000..940e2a3b --- /dev/null +++ b/gem/test/golden/strict/button/submit_disabled.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/variant_destructive.html b/gem/test/golden/strict/button/variant_destructive.html new file mode 100644 index 00000000..060c2eb4 --- /dev/null +++ b/gem/test/golden/strict/button/variant_destructive.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/variant_ghost.html b/gem/test/golden/strict/button/variant_ghost.html new file mode 100644 index 00000000..859aede4 --- /dev/null +++ b/gem/test/golden/strict/button/variant_ghost.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/variant_link.html b/gem/test/golden/strict/button/variant_link.html new file mode 100644 index 00000000..5f2d8961 --- /dev/null +++ b/gem/test/golden/strict/button/variant_link.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/variant_outline.html b/gem/test/golden/strict/button/variant_outline.html new file mode 100644 index 00000000..b3fdadd5 --- /dev/null +++ b/gem/test/golden/strict/button/variant_outline.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/variant_primary.html b/gem/test/golden/strict/button/variant_primary.html new file mode 100644 index 00000000..3871cb83 --- /dev/null +++ b/gem/test/golden/strict/button/variant_primary.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/button/variant_secondary.html b/gem/test/golden/strict/button/variant_secondary.html new file mode 100644 index 00000000..9973762a --- /dev/null +++ b/gem/test/golden/strict/button/variant_secondary.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/calendar/bound_to_input.html b/gem/test/golden/strict/calendar/bound_to_input.html new file mode 100644 index 00000000..fa963cf4 --- /dev/null +++ b/gem/test/golden/strict/calendar/bound_to_input.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/calendar/default.html b/gem/test/golden/strict/calendar/default.html new file mode 100644 index 00000000..cf201c9a --- /dev/null +++ b/gem/test/golden/strict/calendar/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/calendar/header_parts.html b/gem/test/golden/strict/calendar/header_parts.html new file mode 100644 index 00000000..4d4307b8 --- /dev/null +++ b/gem/test/golden/strict/calendar/header_parts.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/card/default.html b/gem/test/golden/strict/card/default.html new file mode 100644 index 00000000..60be025e --- /dev/null +++ b/gem/test/golden/strict/card/default.html @@ -0,0 +1 @@ +

Create project

Deploy your new project in one click.

Body
\ No newline at end of file diff --git a/gem/test/golden/strict/carousel/horizontal.html b/gem/test/golden/strict/carousel/horizontal.html new file mode 100644 index 00000000..af15eb6d --- /dev/null +++ b/gem/test/golden/strict/carousel/horizontal.html @@ -0,0 +1 @@ +
1
2
\ No newline at end of file diff --git a/gem/test/golden/strict/carousel/vertical.html b/gem/test/golden/strict/carousel/vertical.html new file mode 100644 index 00000000..84af8e33 --- /dev/null +++ b/gem/test/golden/strict/carousel/vertical.html @@ -0,0 +1 @@ +
1
2
\ No newline at end of file diff --git a/gem/test/golden/strict/carousel/with_options.html b/gem/test/golden/strict/carousel/with_options.html new file mode 100644 index 00000000..e0c5d7d0 --- /dev/null +++ b/gem/test/golden/strict/carousel/with_options.html @@ -0,0 +1 @@ +
1
\ No newline at end of file diff --git a/gem/test/golden/strict/chart/bar.html b/gem/test/golden/strict/chart/bar.html new file mode 100644 index 00000000..0256ed20 --- /dev/null +++ b/gem/test/golden/strict/chart/bar.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/checkbox/checked_disabled.html b/gem/test/golden/strict/checkbox/checked_disabled.html new file mode 100644 index 00000000..d51e6ad4 --- /dev/null +++ b/gem/test/golden/strict/checkbox/checked_disabled.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/checkbox/default.html b/gem/test/golden/strict/checkbox/default.html new file mode 100644 index 00000000..b4a22b5e --- /dev/null +++ b/gem/test/golden/strict/checkbox/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/checkbox/group.html b/gem/test/golden/strict/checkbox/group.html new file mode 100644 index 00000000..e5977fd8 --- /dev/null +++ b/gem/test/golden/strict/checkbox/group.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/clipboard/custom_source_and_trigger.html b/gem/test/golden/strict/clipboard/custom_source_and_trigger.html new file mode 100644 index 00000000..b8d8432e --- /dev/null +++ b/gem/test/golden/strict/clipboard/custom_source_and_trigger.html @@ -0,0 +1 @@ +
gem install ruby_ui
\ No newline at end of file diff --git a/gem/test/golden/strict/clipboard/default.html b/gem/test/golden/strict/clipboard/default.html new file mode 100644 index 00000000..52bf67a2 --- /dev/null +++ b/gem/test/golden/strict/clipboard/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/clipboard/popover_error.html b/gem/test/golden/strict/clipboard/popover_error.html new file mode 100644 index 00000000..1df96ed3 --- /dev/null +++ b/gem/test/golden/strict/clipboard/popover_error.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/clipboard/popover_success.html b/gem/test/golden/strict/clipboard/popover_success.html new file mode 100644 index 00000000..0dcaecf6 --- /dev/null +++ b/gem/test/golden/strict/clipboard/popover_success.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/codeblock/ruby_with_clipboard.html b/gem/test/golden/strict/codeblock/ruby_with_clipboard.html new file mode 100644 index 00000000..6fe64683 --- /dev/null +++ b/gem/test/golden/strict/codeblock/ruby_with_clipboard.html @@ -0,0 +1,119 @@ +
def hello_world
+	puts "Hello, world!"
+end
+
\ No newline at end of file diff --git a/gem/test/golden/strict/codeblock/ruby_without_clipboard.html b/gem/test/golden/strict/codeblock/ruby_without_clipboard.html new file mode 100644 index 00000000..be01b9bf --- /dev/null +++ b/gem/test/golden/strict/codeblock/ruby_without_clipboard.html @@ -0,0 +1,117 @@ +
puts :ok
+
\ No newline at end of file diff --git a/gem/test/golden/strict/collapsible/closed.html b/gem/test/golden/strict/collapsible/closed.html new file mode 100644 index 00000000..2bcf163f --- /dev/null +++ b/gem/test/golden/strict/collapsible/closed.html @@ -0,0 +1 @@ +
Hidden body
\ No newline at end of file diff --git a/gem/test/golden/strict/collapsible/open.html b/gem/test/golden/strict/collapsible/open.html new file mode 100644 index 00000000..f76439c2 --- /dev/null +++ b/gem/test/golden/strict/collapsible/open.html @@ -0,0 +1 @@ +
Visible body
\ No newline at end of file diff --git a/gem/test/golden/strict/combobox/badge.html b/gem/test/golden/strict/combobox/badge.html new file mode 100644 index 00000000..297f6d71 --- /dev/null +++ b/gem/test/golden/strict/combobox/badge.html @@ -0,0 +1 @@ +Rails \ No newline at end of file diff --git a/gem/test/golden/strict/combobox/clear_button.html b/gem/test/golden/strict/combobox/clear_button.html new file mode 100644 index 00000000..8b843d2f --- /dev/null +++ b/gem/test/golden/strict/combobox/clear_button.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/combobox/input_trigger.html b/gem/test/golden/strict/combobox/input_trigger.html new file mode 100644 index 00000000..b431f85d --- /dev/null +++ b/gem/test/golden/strict/combobox/input_trigger.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/combobox/multiple_with_badges.html b/gem/test/golden/strict/combobox/multiple_with_badges.html new file mode 100644 index 00000000..f3243286 --- /dev/null +++ b/gem/test/golden/strict/combobox/multiple_with_badges.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/combobox/radio_items.html b/gem/test/golden/strict/combobox/radio_items.html new file mode 100644 index 00000000..68fb45f3 --- /dev/null +++ b/gem/test/golden/strict/combobox/radio_items.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/command/dialog.html b/gem/test/golden/strict/command/dialog.html new file mode 100644 index 00000000..913b43a9 --- /dev/null +++ b/gem/test/golden/strict/command/dialog.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/command/dialog_content_lg.html b/gem/test/golden/strict/command/dialog_content_lg.html new file mode 100644 index 00000000..17cbd4ea --- /dev/null +++ b/gem/test/golden/strict/command/dialog_content_lg.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/command/dialog_content_md.html b/gem/test/golden/strict/command/dialog_content_md.html new file mode 100644 index 00000000..a6abdfad --- /dev/null +++ b/gem/test/golden/strict/command/dialog_content_md.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/command/dialog_content_sm.html b/gem/test/golden/strict/command/dialog_content_sm.html new file mode 100644 index 00000000..ad601268 --- /dev/null +++ b/gem/test/golden/strict/command/dialog_content_sm.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/command/trigger_with_custom_keybindings.html b/gem/test/golden/strict/command/trigger_with_custom_keybindings.html new file mode 100644 index 00000000..45d5e8d6 --- /dev/null +++ b/gem/test/golden/strict/command/trigger_with_custom_keybindings.html @@ -0,0 +1 @@ +
Open
\ No newline at end of file diff --git a/gem/test/golden/strict/context_menu/default.html b/gem/test/golden/strict/context_menu/default.html new file mode 100644 index 00000000..ddd839eb --- /dev/null +++ b/gem/test/golden/strict/context_menu/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/context_menu/label_flush.html b/gem/test/golden/strict/context_menu/label_flush.html new file mode 100644 index 00000000..6b7d94f2 --- /dev/null +++ b/gem/test/golden/strict/context_menu/label_flush.html @@ -0,0 +1 @@ +
More Tools
\ No newline at end of file diff --git a/gem/test/golden/strict/context_menu/label_inset.html b/gem/test/golden/strict/context_menu/label_inset.html new file mode 100644 index 00000000..ec5c5766 --- /dev/null +++ b/gem/test/golden/strict/context_menu/label_inset.html @@ -0,0 +1 @@ +
More Tools
\ No newline at end of file diff --git a/gem/test/golden/strict/context_menu/with_options.html b/gem/test/golden/strict/context_menu/with_options.html new file mode 100644 index 00000000..b8215077 --- /dev/null +++ b/gem/test/golden/strict/context_menu/with_options.html @@ -0,0 +1 @@ +
Target
\ No newline at end of file diff --git a/gem/test/golden/strict/data_table/expand_toggle_expanded.html b/gem/test/golden/strict/data_table/expand_toggle_expanded.html new file mode 100644 index 00000000..bfcccc15 --- /dev/null +++ b/gem/test/golden/strict/data_table/expand_toggle_expanded.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/data_table/full_frame.html b/gem/test/golden/strict/data_table/full_frame.html new file mode 100644 index 00000000..336fd5d3 --- /dev/null +++ b/gem/test/golden/strict/data_table/full_frame.html @@ -0,0 +1 @@ +
Name
Alice
0 of 10 row(s) selected.
\ No newline at end of file diff --git a/gem/test/golden/strict/data_table/pagination_first_page.html b/gem/test/golden/strict/data_table/pagination_first_page.html new file mode 100644 index 00000000..ac2783c9 --- /dev/null +++ b/gem/test/golden/strict/data_table/pagination_first_page.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/data_table/pagination_manual_adapter.html b/gem/test/golden/strict/data_table/pagination_manual_adapter.html new file mode 100644 index 00000000..9039e5f2 --- /dev/null +++ b/gem/test/golden/strict/data_table/pagination_manual_adapter.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/data_table/pagination_wide_window.html b/gem/test/golden/strict/data_table/pagination_wide_window.html new file mode 100644 index 00000000..1a00fa9b --- /dev/null +++ b/gem/test/golden/strict/data_table/pagination_wide_window.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/data_table/search_without_debounce.html b/gem/test/golden/strict/data_table/search_without_debounce.html new file mode 100644 index 00000000..42f1c874 --- /dev/null +++ b/gem/test/golden/strict/data_table/search_without_debounce.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/data_table/sort_head_unsorted.html b/gem/test/golden/strict/data_table/sort_head_unsorted.html new file mode 100644 index 00000000..6682f31b --- /dev/null +++ b/gem/test/golden/strict/data_table/sort_head_unsorted.html @@ -0,0 +1 @@ +Name \ No newline at end of file diff --git a/gem/test/golden/strict/date_picker/default.html b/gem/test/golden/strict/date_picker/default.html new file mode 100644 index 00000000..abb5aa1a --- /dev/null +++ b/gem/test/golden/strict/date_picker/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/date_picker/generated_id.html b/gem/test/golden/strict/date_picker/generated_id.html new file mode 100644 index 00000000..511b97c4 --- /dev/null +++ b/gem/test/golden/strict/date_picker/generated_id.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/date_picker/without_label.html b/gem/test/golden/strict/date_picker/without_label.html new file mode 100644 index 00000000..a9b6afb2 --- /dev/null +++ b/gem/test/golden/strict/date_picker/without_label.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/dialog/content_lg.html b/gem/test/golden/strict/dialog/content_lg.html new file mode 100644 index 00000000..ffd0a856 --- /dev/null +++ b/gem/test/golden/strict/dialog/content_lg.html @@ -0,0 +1 @@ +body \ No newline at end of file diff --git a/gem/test/golden/strict/dialog/content_md.html b/gem/test/golden/strict/dialog/content_md.html new file mode 100644 index 00000000..c77b7280 --- /dev/null +++ b/gem/test/golden/strict/dialog/content_md.html @@ -0,0 +1 @@ +body \ No newline at end of file diff --git a/gem/test/golden/strict/dialog/content_sm.html b/gem/test/golden/strict/dialog/content_sm.html new file mode 100644 index 00000000..2dce2cb2 --- /dev/null +++ b/gem/test/golden/strict/dialog/content_sm.html @@ -0,0 +1 @@ +body \ No newline at end of file diff --git a/gem/test/golden/strict/dialog/content_xl.html b/gem/test/golden/strict/dialog/content_xl.html new file mode 100644 index 00000000..17ca2d59 --- /dev/null +++ b/gem/test/golden/strict/dialog/content_xl.html @@ -0,0 +1 @@ +body \ No newline at end of file diff --git a/gem/test/golden/strict/dialog/default.html b/gem/test/golden/strict/dialog/default.html new file mode 100644 index 00000000..450f2d98 --- /dev/null +++ b/gem/test/golden/strict/dialog/default.html @@ -0,0 +1 @@ +

RubyUI to the rescue

Build accessible apps with ease.

Body
\ No newline at end of file diff --git a/gem/test/golden/strict/dialog/open.html b/gem/test/golden/strict/dialog/open.html new file mode 100644 index 00000000..f282701d --- /dev/null +++ b/gem/test/golden/strict/dialog/open.html @@ -0,0 +1 @@ +
body
\ No newline at end of file diff --git a/gem/test/golden/strict/dropdown_menu/default.html b/gem/test/golden/strict/dropdown_menu/default.html new file mode 100644 index 00000000..289c2caf --- /dev/null +++ b/gem/test/golden/strict/dropdown_menu/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/dropdown_menu/fixed_strategy.html b/gem/test/golden/strict/dropdown_menu/fixed_strategy.html new file mode 100644 index 00000000..ede82fb6 --- /dev/null +++ b/gem/test/golden/strict/dropdown_menu/fixed_strategy.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/empty/default.html b/gem/test/golden/strict/empty/default.html new file mode 100644 index 00000000..dbcb6cf5 --- /dev/null +++ b/gem/test/golden/strict/empty/default.html @@ -0,0 +1 @@ +
I
Nothing here
No content yet.
\ No newline at end of file diff --git a/gem/test/golden/strict/empty/media_default.html b/gem/test/golden/strict/empty/media_default.html new file mode 100644 index 00000000..6a00c99c --- /dev/null +++ b/gem/test/golden/strict/empty/media_default.html @@ -0,0 +1 @@ +
M
\ No newline at end of file diff --git a/gem/test/golden/strict/form/default.html b/gem/test/golden/strict/form/default.html new file mode 100644 index 00000000..07125eed --- /dev/null +++ b/gem/test/golden/strict/form/default.html @@ -0,0 +1 @@ +

At least 3 characters.

Name is required.

\ No newline at end of file diff --git a/gem/test/golden/strict/hover_card/default.html b/gem/test/golden/strict/hover_card/default.html new file mode 100644 index 00000000..e0ae8533 --- /dev/null +++ b/gem/test/golden/strict/hover_card/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/hover_card/with_options.html b/gem/test/golden/strict/hover_card/with_options.html new file mode 100644 index 00000000..5bbd0924 --- /dev/null +++ b/gem/test/golden/strict/hover_card/with_options.html @@ -0,0 +1 @@ +
T
\ No newline at end of file diff --git a/gem/test/golden/strict/input/default.html b/gem/test/golden/strict/input/default.html new file mode 100644 index 00000000..252451ed --- /dev/null +++ b/gem/test/golden/strict/input/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/input/typed_and_disabled.html b/gem/test/golden/strict/input/typed_and_disabled.html new file mode 100644 index 00000000..c34a7333 --- /dev/null +++ b/gem/test/golden/strict/input/typed_and_disabled.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/input_otp/alphanumeric_pattern.html b/gem/test/golden/strict/input_otp/alphanumeric_pattern.html new file mode 100644 index 00000000..1bdb7f32 --- /dev/null +++ b/gem/test/golden/strict/input_otp/alphanumeric_pattern.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/input_otp/default.html b/gem/test/golden/strict/input_otp/default.html new file mode 100644 index 00000000..6c8a1cc5 --- /dev/null +++ b/gem/test/golden/strict/input_otp/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/link/icon.html b/gem/test/golden/strict/link/icon.html new file mode 100644 index 00000000..2d311b48 --- /dev/null +++ b/gem/test/golden/strict/link/icon.html @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/gem/test/golden/strict/link/size_lg.html b/gem/test/golden/strict/link/size_lg.html new file mode 100644 index 00000000..3317a4b7 --- /dev/null +++ b/gem/test/golden/strict/link/size_lg.html @@ -0,0 +1 @@ +lg \ No newline at end of file diff --git a/gem/test/golden/strict/link/size_md.html b/gem/test/golden/strict/link/size_md.html new file mode 100644 index 00000000..d9d66096 --- /dev/null +++ b/gem/test/golden/strict/link/size_md.html @@ -0,0 +1 @@ +md \ No newline at end of file diff --git a/gem/test/golden/strict/link/size_sm.html b/gem/test/golden/strict/link/size_sm.html new file mode 100644 index 00000000..a59e8a6a --- /dev/null +++ b/gem/test/golden/strict/link/size_sm.html @@ -0,0 +1 @@ +sm \ No newline at end of file diff --git a/gem/test/golden/strict/link/size_xl.html b/gem/test/golden/strict/link/size_xl.html new file mode 100644 index 00000000..656c1fd9 --- /dev/null +++ b/gem/test/golden/strict/link/size_xl.html @@ -0,0 +1 @@ +xl \ No newline at end of file diff --git a/gem/test/golden/strict/link/variant_destructive.html b/gem/test/golden/strict/link/variant_destructive.html new file mode 100644 index 00000000..08b8136c --- /dev/null +++ b/gem/test/golden/strict/link/variant_destructive.html @@ -0,0 +1 @@ +destructive \ No newline at end of file diff --git a/gem/test/golden/strict/link/variant_ghost.html b/gem/test/golden/strict/link/variant_ghost.html new file mode 100644 index 00000000..611fe4c0 --- /dev/null +++ b/gem/test/golden/strict/link/variant_ghost.html @@ -0,0 +1 @@ +ghost \ No newline at end of file diff --git a/gem/test/golden/strict/link/variant_link.html b/gem/test/golden/strict/link/variant_link.html new file mode 100644 index 00000000..c10334c7 --- /dev/null +++ b/gem/test/golden/strict/link/variant_link.html @@ -0,0 +1 @@ +link \ No newline at end of file diff --git a/gem/test/golden/strict/link/variant_outline.html b/gem/test/golden/strict/link/variant_outline.html new file mode 100644 index 00000000..dc2da2c6 --- /dev/null +++ b/gem/test/golden/strict/link/variant_outline.html @@ -0,0 +1 @@ +outline \ No newline at end of file diff --git a/gem/test/golden/strict/link/variant_primary.html b/gem/test/golden/strict/link/variant_primary.html new file mode 100644 index 00000000..458ec68a --- /dev/null +++ b/gem/test/golden/strict/link/variant_primary.html @@ -0,0 +1 @@ +primary \ No newline at end of file diff --git a/gem/test/golden/strict/link/variant_secondary.html b/gem/test/golden/strict/link/variant_secondary.html new file mode 100644 index 00000000..b18aa704 --- /dev/null +++ b/gem/test/golden/strict/link/variant_secondary.html @@ -0,0 +1 @@ +secondary \ No newline at end of file diff --git a/gem/test/golden/strict/masked_input/default.html b/gem/test/golden/strict/masked_input/default.html new file mode 100644 index 00000000..153e9344 --- /dev/null +++ b/gem/test/golden/strict/masked_input/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/message/group_with_avatar_header_footer.html b/gem/test/golden/strict/message/group_with_avatar_header_footer.html new file mode 100644 index 00000000..90469ddd --- /dev/null +++ b/gem/test/golden/strict/message/group_with_avatar_header_footer.html @@ -0,0 +1 @@ +
OL
Oliver
Hi
Delivered
Hey
\ No newline at end of file diff --git a/gem/test/golden/strict/message_scroller/button_start.html b/gem/test/golden/strict/message_scroller/button_start.html new file mode 100644 index 00000000..a362d9a4 --- /dev/null +++ b/gem/test/golden/strict/message_scroller/button_start.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/message_scroller/default.html b/gem/test/golden/strict/message_scroller/default.html new file mode 100644 index 00000000..d2dc13a2 --- /dev/null +++ b/gem/test/golden/strict/message_scroller/default.html @@ -0,0 +1 @@ +
first
last
\ No newline at end of file diff --git a/gem/test/golden/strict/message_scroller/provider_custom_values.html b/gem/test/golden/strict/message_scroller/provider_custom_values.html new file mode 100644 index 00000000..11965d17 --- /dev/null +++ b/gem/test/golden/strict/message_scroller/provider_custom_values.html @@ -0,0 +1 @@ +
x
\ No newline at end of file diff --git a/gem/test/golden/strict/native_select/default.html b/gem/test/golden/strict/native_select/default.html new file mode 100644 index 00000000..5dc7ae72 --- /dev/null +++ b/gem/test/golden/strict/native_select/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/native_select/icon.html b/gem/test/golden/strict/native_select/icon.html new file mode 100644 index 00000000..74b8337e --- /dev/null +++ b/gem/test/golden/strict/native_select/icon.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/native_select/small.html b/gem/test/golden/strict/native_select/small.html new file mode 100644 index 00000000..81655c8c --- /dev/null +++ b/gem/test/golden/strict/native_select/small.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/pagination/default.html b/gem/test/golden/strict/pagination/default.html new file mode 100644 index 00000000..ab835426 --- /dev/null +++ b/gem/test/golden/strict/pagination/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/popover/default.html b/gem/test/golden/strict/popover/default.html new file mode 100644 index 00000000..3389388c --- /dev/null +++ b/gem/test/golden/strict/popover/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/popover/with_options.html b/gem/test/golden/strict/popover/with_options.html new file mode 100644 index 00000000..26bb6015 --- /dev/null +++ b/gem/test/golden/strict/popover/with_options.html @@ -0,0 +1 @@ +
T
\ No newline at end of file diff --git a/gem/test/golden/strict/progress/value_0.html b/gem/test/golden/strict/progress/value_0.html new file mode 100644 index 00000000..92a4e5ac --- /dev/null +++ b/gem/test/golden/strict/progress/value_0.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/progress/value_100.html b/gem/test/golden/strict/progress/value_100.html new file mode 100644 index 00000000..74d3ffbb --- /dev/null +++ b/gem/test/golden/strict/progress/value_100.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/progress/value_33_5.html b/gem/test/golden/strict/progress/value_33_5.html new file mode 100644 index 00000000..d1d84129 --- /dev/null +++ b/gem/test/golden/strict/progress/value_33_5.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/radio_button/checked.html b/gem/test/golden/strict/radio_button/checked.html new file mode 100644 index 00000000..a11fab5c --- /dev/null +++ b/gem/test/golden/strict/radio_button/checked.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/radio_button/default.html b/gem/test/golden/strict/radio_button/default.html new file mode 100644 index 00000000..24369b20 --- /dev/null +++ b/gem/test/golden/strict/radio_button/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/select/default.html b/gem/test/golden/strict/select/default.html new file mode 100644 index 00000000..6a3810a9 --- /dev/null +++ b/gem/test/golden/strict/select/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/select/value_falls_back_to_placeholder.html b/gem/test/golden/strict/select/value_falls_back_to_placeholder.html new file mode 100644 index 00000000..e8d97e1a --- /dev/null +++ b/gem/test/golden/strict/select/value_falls_back_to_placeholder.html @@ -0,0 +1 @@ +Placeholder \ No newline at end of file diff --git a/gem/test/golden/strict/separator/as_hr.html b/gem/test/golden/strict/separator/as_hr.html new file mode 100644 index 00000000..e63af339 --- /dev/null +++ b/gem/test/golden/strict/separator/as_hr.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/separator/default.html b/gem/test/golden/strict/separator/default.html new file mode 100644 index 00000000..7944b249 --- /dev/null +++ b/gem/test/golden/strict/separator/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/separator/not_decorative.html b/gem/test/golden/strict/separator/not_decorative.html new file mode 100644 index 00000000..1e492c83 --- /dev/null +++ b/gem/test/golden/strict/separator/not_decorative.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/separator/vertical.html b/gem/test/golden/strict/separator/vertical.html new file mode 100644 index 00000000..1a855b34 --- /dev/null +++ b/gem/test/golden/strict/separator/vertical.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/sheet/content_bottom.html b/gem/test/golden/strict/sheet/content_bottom.html new file mode 100644 index 00000000..e5bb9667 --- /dev/null +++ b/gem/test/golden/strict/sheet/content_bottom.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/sheet/content_left.html b/gem/test/golden/strict/sheet/content_left.html new file mode 100644 index 00000000..b04b702b --- /dev/null +++ b/gem/test/golden/strict/sheet/content_left.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/sheet/content_right.html b/gem/test/golden/strict/sheet/content_right.html new file mode 100644 index 00000000..08df6b68 --- /dev/null +++ b/gem/test/golden/strict/sheet/content_right.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/sheet/content_top.html b/gem/test/golden/strict/sheet/content_top.html new file mode 100644 index 00000000..88c3cd1d --- /dev/null +++ b/gem/test/golden/strict/sheet/content_top.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/sheet/default.html b/gem/test/golden/strict/sheet/default.html new file mode 100644 index 00000000..c34b5a4e --- /dev/null +++ b/gem/test/golden/strict/sheet/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/sheet/open.html b/gem/test/golden/strict/sheet/open.html new file mode 100644 index 00000000..30d947cc --- /dev/null +++ b/gem/test/golden/strict/sheet/open.html @@ -0,0 +1 @@ +
content
\ No newline at end of file diff --git a/gem/test/golden/strict/shortcut_key/default.html b/gem/test/golden/strict/shortcut_key/default.html new file mode 100644 index 00000000..52ba7456 --- /dev/null +++ b/gem/test/golden/strict/shortcut_key/default.html @@ -0,0 +1 @@ +CmdK \ No newline at end of file diff --git a/gem/test/golden/strict/sidebar/collapsible_icon_right_floating.html b/gem/test/golden/strict/sidebar/collapsible_icon_right_floating.html new file mode 100644 index 00000000..8df11625 --- /dev/null +++ b/gem/test/golden/strict/sidebar/collapsible_icon_right_floating.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/sidebar/collapsible_offcanvas.html b/gem/test/golden/strict/sidebar/collapsible_offcanvas.html new file mode 100644 index 00000000..1e8b9505 --- /dev/null +++ b/gem/test/golden/strict/sidebar/collapsible_offcanvas.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/sidebar/mobile.html b/gem/test/golden/strict/sidebar/mobile.html new file mode 100644 index 00000000..ae7ed256 --- /dev/null +++ b/gem/test/golden/strict/sidebar/mobile.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/sidebar/non_collapsible.html b/gem/test/golden/strict/sidebar/non_collapsible.html new file mode 100644 index 00000000..252598e2 --- /dev/null +++ b/gem/test/golden/strict/sidebar/non_collapsible.html @@ -0,0 +1 @@ +
Body
\ No newline at end of file diff --git a/gem/test/golden/strict/skeleton/default.html b/gem/test/golden/strict/skeleton/default.html new file mode 100644 index 00000000..a3316ce7 --- /dev/null +++ b/gem/test/golden/strict/skeleton/default.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/switch/checked_without_hidden_input.html b/gem/test/golden/strict/switch/checked_without_hidden_input.html new file mode 100644 index 00000000..abd2fef3 --- /dev/null +++ b/gem/test/golden/strict/switch/checked_without_hidden_input.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/switch/default.html b/gem/test/golden/strict/switch/default.html new file mode 100644 index 00000000..71bfe334 --- /dev/null +++ b/gem/test/golden/strict/switch/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/table/default.html b/gem/test/golden/strict/table/default.html new file mode 100644 index 00000000..ccbe62fe --- /dev/null +++ b/gem/test/golden/strict/table/default.html @@ -0,0 +1 @@ +
Employees at Acme inc.
NameAmount
INV-0001100
Total100
\ No newline at end of file diff --git a/gem/test/golden/strict/table/detached_row.html b/gem/test/golden/strict/table/detached_row.html new file mode 100644 index 00000000..f514a934 --- /dev/null +++ b/gem/test/golden/strict/table/detached_row.html @@ -0,0 +1 @@ +detached \ No newline at end of file diff --git a/gem/test/golden/strict/tabs/default.html b/gem/test/golden/strict/tabs/default.html new file mode 100644 index 00000000..505974f3 --- /dev/null +++ b/gem/test/golden/strict/tabs/default.html @@ -0,0 +1 @@ +
Password
\ No newline at end of file diff --git a/gem/test/golden/strict/textarea/default.html b/gem/test/golden/strict/textarea/default.html new file mode 100644 index 00000000..630b1d1e --- /dev/null +++ b/gem/test/golden/strict/textarea/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/textarea/rows_and_content.html b/gem/test/golden/strict/textarea/rows_and_content.html new file mode 100644 index 00000000..7daf43ba --- /dev/null +++ b/gem/test/golden/strict/textarea/rows_and_content.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/theme_toggle/default.html b/gem/test/golden/strict/theme_toggle/default.html new file mode 100644 index 00000000..fca5e855 --- /dev/null +++ b/gem/test/golden/strict/theme_toggle/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/toast/item_with_all_slots.html b/gem/test/golden/strict/toast/item_with_all_slots.html new file mode 100644 index 00000000..b3f1950e --- /dev/null +++ b/gem/test/golden/strict/toast/item_with_all_slots.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/gem/test/golden/strict/toast/region_top_center_with_close_button.html b/gem/test/golden/strict/toast/region_top_center_with_close_button.html new file mode 100644 index 00000000..eb86da06 --- /dev/null +++ b/gem/test/golden/strict/toast/region_top_center_with_close_button.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/gem/test/golden/strict/toast/region_with_flash.html b/gem/test/golden/strict/toast/region_with_flash.html new file mode 100644 index 00000000..1b002c4f --- /dev/null +++ b/gem/test/golden/strict/toast/region_with_flash.html @@ -0,0 +1 @@ +
    1. Saved
    2. Careful
    \ No newline at end of file diff --git a/gem/test/golden/strict/toggle/default.html b/gem/test/golden/strict/toggle/default.html new file mode 100644 index 00000000..5d4d2e7f --- /dev/null +++ b/gem/test/golden/strict/toggle/default.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/toggle/disabled_small.html b/gem/test/golden/strict/toggle/disabled_small.html new file mode 100644 index 00000000..a854a6ac --- /dev/null +++ b/gem/test/golden/strict/toggle/disabled_small.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/toggle/pressed_outline_with_name.html b/gem/test/golden/strict/toggle/pressed_outline_with_name.html new file mode 100644 index 00000000..652fd6cd --- /dev/null +++ b/gem/test/golden/strict/toggle/pressed_outline_with_name.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/toggle_group/disabled.html b/gem/test/golden/strict/toggle_group/disabled.html new file mode 100644 index 00000000..fdbe4505 --- /dev/null +++ b/gem/test/golden/strict/toggle_group/disabled.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/gem/test/golden/strict/toggle_group/multiple_outline_spaced_vertical.html b/gem/test/golden/strict/toggle_group/multiple_outline_spaced_vertical.html new file mode 100644 index 00000000..cb673114 --- /dev/null +++ b/gem/test/golden/strict/toggle_group/multiple_outline_spaced_vertical.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/gem/test/golden/strict/toggle_group/single.html b/gem/test/golden/strict/toggle_group/single.html new file mode 100644 index 00000000..728918c8 --- /dev/null +++ b/gem/test/golden/strict/toggle_group/single.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/gem/test/golden/strict/tooltip/default.html b/gem/test/golden/strict/tooltip/default.html new file mode 100644 index 00000000..06f64c6b --- /dev/null +++ b/gem/test/golden/strict/tooltip/default.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/gem/test/golden/strict/tooltip/placement_right.html b/gem/test/golden/strict/tooltip/placement_right.html new file mode 100644 index 00000000..06472a31 --- /dev/null +++ b/gem/test/golden/strict/tooltip/placement_right.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/blockquote.html b/gem/test/golden/strict/typography/blockquote.html new file mode 100644 index 00000000..df65b6ea --- /dev/null +++ b/gem/test/golden/strict/typography/blockquote.html @@ -0,0 +1 @@ +
    After all, we are all Rubyists.
    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/heading_custom_size.html b/gem/test/golden/strict/typography/heading_custom_size.html new file mode 100644 index 00000000..e3a126d1 --- /dev/null +++ b/gem/test/golden/strict/typography/heading_custom_size.html @@ -0,0 +1 @@ +

    Custom Heading

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/heading_level_1.html b/gem/test/golden/strict/typography/heading_level_1.html new file mode 100644 index 00000000..7fe3cfcd --- /dev/null +++ b/gem/test/golden/strict/typography/heading_level_1.html @@ -0,0 +1 @@ +

    H1

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/heading_level_2.html b/gem/test/golden/strict/typography/heading_level_2.html new file mode 100644 index 00000000..c3a9f0bc --- /dev/null +++ b/gem/test/golden/strict/typography/heading_level_2.html @@ -0,0 +1 @@ +

    H2

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/heading_level_3.html b/gem/test/golden/strict/typography/heading_level_3.html new file mode 100644 index 00000000..32e609ff --- /dev/null +++ b/gem/test/golden/strict/typography/heading_level_3.html @@ -0,0 +1 @@ +

    H3

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/heading_level_4.html b/gem/test/golden/strict/typography/heading_level_4.html new file mode 100644 index 00000000..041d8340 --- /dev/null +++ b/gem/test/golden/strict/typography/heading_level_4.html @@ -0,0 +1 @@ +

    H4

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/inline_code.html b/gem/test/golden/strict/typography/inline_code.html new file mode 100644 index 00000000..9b1a01e1 --- /dev/null +++ b/gem/test/golden/strict/typography/inline_code.html @@ -0,0 +1 @@ +RubyUI::VERSION \ No newline at end of file diff --git a/gem/test/golden/strict/typography/inline_link.html b/gem/test/golden/strict/typography/inline_link.html new file mode 100644 index 00000000..7ba50bc3 --- /dev/null +++ b/gem/test/golden/strict/typography/inline_link.html @@ -0,0 +1 @@ +the docs \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_as_div.html b/gem/test/golden/strict/typography/text_as_div.html new file mode 100644 index 00000000..1d2f6bae --- /dev/null +++ b/gem/test/golden/strict/typography/text_as_div.html @@ -0,0 +1 @@ +
    div
    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_as_label.html b/gem/test/golden/strict/typography/text_as_label.html new file mode 100644 index 00000000..e10e811e --- /dev/null +++ b/gem/test/golden/strict/typography/text_as_label.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_as_p.html b/gem/test/golden/strict/typography/text_as_p.html new file mode 100644 index 00000000..ba183b84 --- /dev/null +++ b/gem/test/golden/strict/typography/text_as_p.html @@ -0,0 +1 @@ +

    p

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_as_span.html b/gem/test/golden/strict/typography/text_as_span.html new file mode 100644 index 00000000..78fb952b --- /dev/null +++ b/gem/test/golden/strict/typography/text_as_span.html @@ -0,0 +1 @@ +span \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_1.html b/gem/test/golden/strict/typography/text_size_1.html new file mode 100644 index 00000000..72ca244a --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_1.html @@ -0,0 +1 @@ +

    Size 1

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_2.html b/gem/test/golden/strict/typography/text_size_2.html new file mode 100644 index 00000000..bd4cabab --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_2.html @@ -0,0 +1 @@ +

    Size 2

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_3.html b/gem/test/golden/strict/typography/text_size_3.html new file mode 100644 index 00000000..5d5872bd --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_3.html @@ -0,0 +1 @@ +

    Size 3

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_4.html b/gem/test/golden/strict/typography/text_size_4.html new file mode 100644 index 00000000..1b06b74f --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_4.html @@ -0,0 +1 @@ +

    Size 4

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_5.html b/gem/test/golden/strict/typography/text_size_5.html new file mode 100644 index 00000000..00ea816d --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_5.html @@ -0,0 +1 @@ +

    Size 5

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_6.html b/gem/test/golden/strict/typography/text_size_6.html new file mode 100644 index 00000000..52f7b4dc --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_6.html @@ -0,0 +1 @@ +

    Size 6

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_7.html b/gem/test/golden/strict/typography/text_size_7.html new file mode 100644 index 00000000..8313967e --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_7.html @@ -0,0 +1 @@ +

    Size 7

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_8.html b/gem/test/golden/strict/typography/text_size_8.html new file mode 100644 index 00000000..050789b8 --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_8.html @@ -0,0 +1 @@ +

    Size 8

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_size_9.html b/gem/test/golden/strict/typography/text_size_9.html new file mode 100644 index 00000000..2c2873c1 --- /dev/null +++ b/gem/test/golden/strict/typography/text_size_9.html @@ -0,0 +1 @@ +

    Size 9

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_weight_bold.html b/gem/test/golden/strict/typography/text_weight_bold.html new file mode 100644 index 00000000..8ea662fe --- /dev/null +++ b/gem/test/golden/strict/typography/text_weight_bold.html @@ -0,0 +1 @@ +

    bold

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_weight_light.html b/gem/test/golden/strict/typography/text_weight_light.html new file mode 100644 index 00000000..2001069d --- /dev/null +++ b/gem/test/golden/strict/typography/text_weight_light.html @@ -0,0 +1 @@ +

    light

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_weight_medium.html b/gem/test/golden/strict/typography/text_weight_medium.html new file mode 100644 index 00000000..f9b4ac68 --- /dev/null +++ b/gem/test/golden/strict/typography/text_weight_medium.html @@ -0,0 +1 @@ +

    medium

    \ No newline at end of file diff --git a/gem/test/golden/strict/typography/text_weight_regular.html b/gem/test/golden/strict/typography/text_weight_regular.html new file mode 100644 index 00000000..dcd0cbbe --- /dev/null +++ b/gem/test/golden/strict/typography/text_weight_regular.html @@ -0,0 +1 @@ +

    regular

    \ No newline at end of file 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..1b38cb32 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" @@ -58,10 +61,51 @@ def assert_golden(scenario) assert_equal recorded, canonical, "HTML for #{scenario.slug} no longer matches the recorded 1.6 snapshot" + + assert_strict(scenario, lane) + end + + # The strict form: text and whitespace verbatim. Same discipline as the + # canonical snapshot — determinism, fixed point, then equality. + def assert_strict(scenario, lane) + strict = Golden::CanonicalHtml.call(render(scenario, lane), strict: true) + + assert_equal strict, Golden::CanonicalHtml.call(render(scenario, lane), strict: true), + "#{scenario.slug} does not render deterministically in strict form" + + assert_path_exists scenario.strict_snapshot_path, + "no strict snapshot for #{scenario.slug} — run `bundle exec rake golden:update` and review the diff" + + recorded = File.read(scenario.strict_snapshot_path) + + assert_equal recorded, Golden::CanonicalHtml.call(recorded, strict: true), + "the recorded strict form of #{scenario.slug} is not a fixed point of the normalizer" + + assert_equal recorded, strict, + "strict HTML for #{scenario.slug} no longer matches the recorded 1.6 snapshot" + end + + def canonicalize(scenario, lane) + Golden::CanonicalHtml.call(render(scenario, lane)) end - def canonicalize(scenario) - Golden::CanonicalHtml.call(Golden::Harness.render(&scenario.block)) + 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 + rendered = render(scenario, scenario.recording_lane) + FileUtils.mkdir_p(File.dirname(scenario.snapshot_path)) + File.write(scenario.snapshot_path, Golden::CanonicalHtml.call(rendered)) + FileUtils.mkdir_p(File.dirname(scenario.strict_snapshot_path)) + File.write(scenario.strict_snapshot_path, Golden::CanonicalHtml.call(rendered, strict: true)) + true + end end end @@ -92,12 +136,38 @@ 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 + + def test_no_orphan_strict_snapshot_files + expected = Golden::Catalog.scenarios.select(&:pinned?).map(&:strict_snapshot_path).sort + orphans = Golden::Catalog.strict_files - expected + + assert_empty orphans, + "strict snapshot files with no scenario (delete them): #{orphans.map { |path| path.delete_prefix("#{Golden::Catalog::STRICT_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 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/attributes.html.erb b/gem/test/probes/views/probe/attributes.html.erb new file mode 100644 index 00000000..0079ab0d --- /dev/null +++ b/gem/test/probes/views/probe/attributes.html.erb @@ -0,0 +1 @@ +
    >
    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/probe/malformed.html.erb b/gem/test/probes/views/probe/malformed.html.erb new file mode 100644 index 00000000..cc19d304 --- /dev/null +++ b/gem/test/probes/views/probe/malformed.html.erb @@ -0,0 +1 @@ +
    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/attributes_differential_test.rb b/gem/test/ruby_ui/attributes_differential_test.rb new file mode 100644 index 00000000..42b3e88f --- /dev/null +++ b/gem/test/ruby_ui/attributes_differential_test.rb @@ -0,0 +1,103 @@ +require "test_helper" +require "golden/canonical_html" + +# RubyUI::Attributes against Phlex 2.4.1, the serializer the 1.6 snapshots were +# recorded with. Each hash is rendered by Phlex::HTML#div(**h) and by +#
    >, both canonicalized. +class AttributesDifferentialTest < Minitest::Test + class PhlexDiv < Phlex::HTML + def initialize(attributes) + @attributes = attributes + end + + def view_template + div(**@attributes) + end + end + + FLAT_CASES = { + "underscored keys at several levels" => {data_ruby_ui__dialog_target: "dialog", data: {ruby_ui__open_value: {deep_key: "v"}}}, + "nil and false omit the attribute" => {hidden: nil, disabled: false, data: {a: nil, b: false, c: "c"}}, + "true is a bare attribute" => {disabled: true, open: true, aria: {expanded: true}, data: {open: true}}, + "integers and floats" => {tabindex: 0, colspan: 2, data: {count: 3, ratio: 1.5}}, + "symbol values dasherize" => {dir: :ltr, data: {state: :on_hover}}, + "style hash" => {style: {width: "max-content", top: 0, left: 0}}, + "style array" => {style: ["width: 1px", "top: 0;", nil, {left: 0}]}, + "class array with nil and nesting" => {class: ["a", nil, ["b", ["c"]], "d"]}, + "nested data hash" => {data: {controller: "ruby-ui--dialog", action: "click->ruby-ui--dialog#open", ruby_ui__dialog_open_value: false}}, + "escaping" => {title: "q\"<>&'", data: {x: ""}}, + "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)"}, + "a String \"style\" key nests instead of matching Phlex's Symbol :style" => {"style" => {width: "1px"}}, + "a nested key may start with on, as in Phlex" => {foo: {onclick: "x"}} + }.freeze + + # Shapes where Phlex 2.4.1 raises — inside `generate_nested_tokens` or + # `generate_styles` (no `case` branch there for `true`/`false` or for + # Date/Time), or on the attribute name before it looks at the value — so + # `RubyUI::Attributes.flat` must raise too rather than silently serialize + # something Phlex would refuse. + RAISE_CASES = { + "true has no case in a token list" => {class: [true]}, + "true has no case as a style value" => {style: {display: true}}, + "a Date has no case in nested attributes" => {data: {at: Date.today}}, + "an unsafe name is refused before its Hash value is read" => {onclick: {_: "x"}}, + "srcdoc is refused with a Hash value too" => {srcdoc: {_: "

    "}} + }.freeze + + RAISE_CASES.each do |label, attributes| + define_method(:"test_raises_#{label.tr(" ", "_")}") do + assert_raises(Phlex::ArgumentError) { PhlexDiv.new(attributes).call } + assert_raises(ArgumentError) { RubyUI::Attributes.flat(attributes) } + end + end + + 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..ca2b9ca7 --- /dev/null +++ b/gem/test/ruby_ui/attributes_test.rb @@ -0,0 +1,90 @@ +# 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 + + # Phlex checks the name before it looks at the value, so a Hash under an + # unsafe name cannot smuggle it in through the `_` root key. + def test_an_unsafe_name_is_refused_before_its_hash_value_is_read + assert_raises(ArgumentError) { flat(onclick: {_: "x"}) } + assert_raises(ArgumentError) { flat(srcdoc: {_: "

    "}) } + 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 + + 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 diff --git a/gem/test/ruby_ui/component_test.rb b/gem/test/ruby_ui/component_test.rb new file mode 100644 index 00000000..4297e567 --- /dev/null +++ b/gem/test/ruby_ui/component_test.rb @@ -0,0 +1,124 @@ +# 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 + + def test_component_roots_are_registered_resolvers_the_reloader_can_see + RubyUI::Probes::Div.template + # Phase 2.0a has no real gem component under the `lib` root yet — every + # component that exists is a probe under `test/probes` — so nothing but + # this test ever resolves a sidecar there. Call `lookup_for` on every + # configured root directly (the same call `Component.template` makes) so + # the assertion below does not depend on some other root having already + # been exercised by an unrelated test. + RubyUI.component_roots.each { |root| RubyUI.lookup_for(root) } + paths = ActionView::PathRegistry.all_file_system_resolvers.map(&:path) + RubyUI.component_roots.each { |root| assert_includes paths, root.to_s } + 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/ruby_ui/enum_test.rb b/gem/test/ruby_ui/enum_test.rb new file mode 100644 index 00000000..4a6f33ec --- /dev/null +++ b/gem/test/ruby_ui/enum_test.rb @@ -0,0 +1,57 @@ +# 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 + + class BadDefault < RubyUI::Component + SIZES = {sm: "h-8", md: "h-9"}.freeze + + def initialize(size: nil, **attrs) + @size = enum(size, SIZES, default: :xlg) + super(**attrs) + end + end + + def test_a_bad_default_names_the_default_not_nil + error = assert_raises(ArgumentError) { BadDefault.new } + + assert_match(/default: :xlg/, error.message) + end +end diff --git a/gem/test/test_helper.rb b/gem/test/test_helper.rb index d31728d7..174d5956 100644 --- a/gem/test/test_helper.rb +++ b/gem/test/test_helper.rb @@ -1,10 +1,20 @@ # frozen_string_literal: true +# A gem test suite has no other legitimate environment; unlike an application's +# test_helper, a developer's exported RAILS_ENV must not leak in here — a +# stray RAILS_ENV=development would turn on 1.6's dev comment and fail six +# unit tests. +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 @@ -15,8 +25,53 @@ module RubyUI autoload class_name, path end + + # The smallest Rails application: ReActionView's Railtie registers its ERB + # handler from `config.after_initialize` (inside the `:action_view` load + # hook), which runs when an application boots — without one Erubi stays the + # `:erb` handler and Herb never sees a template (decision 5). 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! + +# 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(&) @@ -25,10 +80,8 @@ def render(component, &) def phlex(&) render Phlex::HTML.new, & end -end -# this is a tracepoint that will output the path of all files loaded that contain the string "phlex" -# trace = TracePoint.new(:class) do |tp| -# puts "Loaded: #{tp.path}" if tp.path.include?("phlex") -# end -# trace.enable + def render_erb(template) + RubyUI::TestApp.view.render(template: template) + end +end