diff --git a/design/2026-09-19-rubyui-2-0-design.md b/design/2026-09-19-rubyui-2-0-design.md index 9f124e265..668c0e791 100644 --- a/design/2026-09-19-rubyui-2-0-design.md +++ b/design/2026-09-19-rubyui-2-0-design.md @@ -143,7 +143,11 @@ Differences from 1.6's `Base` to carry into the documentation: - `attrs` keys are Strings (`attrs["class"]`), the flat form `tag.attributes` consumes. 1.6 code reading `attrs[:class]` — `PaginationItem` — changes one - character. + character. `mixed_attrs` is the nested, Symbol-keyed hash after `mix` and + the class merge — 1.6's `attrs` — for the 17 sites that forward attributes + to another component (`Checkbox.new(**mixed_attrs)`) or merge more in before + serializing; forwarding the flat form would put `"data-action"` beside a + neighbour's `data: {action:}` (decision 11). - `true` serializes as `""`; Rails then emits `disabled="disabled"` for HTML boolean attributes and `aria-x=""` for the rest. Both canonicalize like Phlex's bare attribute. @@ -159,6 +163,22 @@ 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 file's final newline is not output. Every file ends with one, + and rendered inside a parent it was a text node after the component; a + sidecar itself emits no whitespace between elements — a line breaks only + inside an ERB tag or inside a start tag between attributes (decision 12). +- A class with no sidecar beside its own file renders its nearest ancestor's + — a host's `class MyButton < RubyUI::Button` keeps rendering as it did when + it inherited `view_template`; `ToggleGroupItem < Toggle` has its own sidecar + and uses it (decision 13). +- A block is captured with ActionView's `capture`: what it *outputs* is the + content, and its return value counts only when it output nothing. In ERB + every `<%= %>` outputs, so nothing changes. From a Ruby block — a host + rendering from Ruby, a unit test — a component-scoped method that returns + markup (`group.ToggleGroupItem(…)`) must be output, not just called: + `safe_join([group.ToggleGroupItem(…), group.ToggleGroupItem(…)])`, or one + call per block. Calling it twice and returning the second renders only the + second; 1.6's Phlex `render` appended to the buffer, so both rendered. - 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 @@ -322,9 +342,11 @@ code. `*_docs.rb`; decision 11 (Phase 2.3) says what happens to those. - **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. +- Point `docs/Gemfile` at the gem at `main`'s commit — a git source with + `glob: "gem/*.gemspec"` — instead of `path: "../gem"`, so the site keeps + building and the CI Docs job stays green while the gem is mid-migration. + Not the published 1.6.0, which is 21 files behind `main` (decision 15). + Phase 3 reverts it. **Acceptance.** The layer is in the gem with its own tests, guards included. The ERB lane is green for Button's 15 scenarios against the frozen snapshots @@ -333,11 +355,14 @@ scenario (188). All five CI jobs are green with the registry unchanged. #### 2.1 The hard components first -Dialog (9 classes), Select (8), ToggleGroup and Toggle (3), Data Table (32). -About 52 classes, and they are the ones that exercise everything that can go -wrong: a block that receives the component, a generated id, a component that -renders no root element, a `` root, one component reading -another's computed `attrs`, a `style:` hash, merged `data-action` ordering. +Dialog (8 classes), Select (8), Toggle and ToggleGroup (3), Data Table (14, +plus 3 adapters that emit no HTML) and ThemeToggle (1, pulled in because it +renders Toggle — decision 14). 34 classes, and they are the ones that +exercise everything that can go wrong: a block that receives the component, a +generated id, a component that renders no root element, a `` +root, one component forwarding its computed attributes to another, a second +attribute hash mixed for a wrapper element, merged `data-action` ordering, a +CSRF token through the view context, an inline handler for a Phlex neighbour. Dialog is already proved. The other three are not. @@ -346,23 +371,22 @@ Herb's component-tag visitor emits a bare `do` with no block parameter, so they cannot be written as tags. They keep the `render X.new do |group|` form, which 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. +**Acceptance.** The 22 scenarios of these five families identical to the +frozen contract in both forms, through their ERB fixtures alone; the 1.6 +Stimulus controllers unedited; every unit test ported, none deleted. -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. +Decided in 2.1 (decision 13): a subclass with no sidecar of its own renders +its nearest ancestor's. Plan: `design/plans/2026-09-20-phase-2-1-hard-components-implementation.md`. #### 2.2 The bulk The remaining ~50 components, in batches. **Definition of done, per component.** A plain Ruby class with no Phlex; a -sidecar that emits no whitespace Phlex did not (decision 10); the canonical +sidecar that emits no whitespace Phlex did not (decisions 10 and 12); the canonical and the strict snapshot matching for every one of its scenarios (decision -8); a scenario passing the String form of every enum -attribute; **its existing unit tests in `gem/test/ruby_ui/` ported to the new +8); a unit test passing the String form of every enum +attribute (decision 14); **its existing unit tests in `gem/test/ruby_ui/` ported to the new harness, none deleted** — they are the inventory of what the component promises beyond its markup; the Stimulus controller untouched; the MCP registry rebuilt. @@ -664,6 +688,12 @@ To settle with Herb's maintainer: release that accepts it — 0.4.1 pins `herb < 0.11.0`, so both are needed. Decision D is designed so the answer changes the announcement, not the architecture. +4. **Formatting whitespace-sensitive markup.** A 2.0 sidecar keeps no + whitespace between elements, because 1.6 emitted none and a browser renders + it in an inline context; the layout puts line breaks only inside tags + (decision 12). Herb's formatter would reintroduce the whitespace. Is a mode + that never adds text between `>` and `<` — Prettier's + `htmlWhitespaceSensitivity: strict` — on the roadmap? ### 9.3 Codemod — revisit after Phase 2 diff --git a/design/plans/2026-09-20-phase-2-1-hard-components-implementation.md b/design/plans/2026-09-20-phase-2-1-hard-components-implementation.md new file mode 100644 index 000000000..ef345e19a --- /dev/null +++ b/design/plans/2026-09-20-phase-2-1-hard-components-implementation.md @@ -0,0 +1,4033 @@ +# Phase 2.1 — The Hard Components 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:** Migrate the first components from Phlex to the 2.0 layer — Dialog (8 classes), Toggle and ToggleGroup (3), Select (8), Data Table (14, plus 3 adapters that emit no HTML) and ThemeToggle (1, pulled in because it renders Toggle) — with their 22 golden scenarios byte-identical to the frozen 1.6 snapshots in both the canonical and the strict form, their Stimulus controllers unedited, their unit tests ported and none deleted, and `docs/` pinned first to the `main` commit the ruler froze (a git ref, not RubyGems 1.6.0, which is behind `main`) so the site keeps building unchanged while the gem is mid-migration. + +**Architecture:** Each component becomes a plain Ruby class inheriting `RubyUI::Component` (the layer Phase 2.0a built) beside a `.html.erb` sidecar that renders the computed attributes with `tag.attributes(component.attrs)` and the captured block with `component.content`. The ERB fixtures Phase 2.0b wrote are the ruler: they render the same composition before and after the migration, against the same snapshots, so a migration changes only an implementation. A migrated component's scenarios lose their Phlex block (the `Phlex::Kit` method that block calls no longer exists), leaving the ERB fixture as the only lane. Three gaps in the layer that these components expose are closed first: the nested attribute hash a component forwards to a neighbour (`mixed_attrs`), the sidecar file's final newline (dropped by `render_in`), and a subclass without a sidecar (renders its nearest ancestor's). + +**Tech Stack:** Ruby 3.3 and 3.4 in CI (4.0.2 locally; 3.4.7 through `mise` for `docs/`), Minitest, ActionView / Railties 8.1.3.1, ReActionView 0.4.1, Herb 0.10.4, Phlex 2.4.1 and phlex-rails 2.4.0 (development only, for the components that have not migrated yet), tailwind_merge 1.5.5, Nokogiri. + +**Reviewed:** by Codex on 2026-09-20 against `8ac23ac` (ten findings, each verified against the code before this revision; the verified ones are folded in below and listed in Appendix B). + +**Spec:** `design/2026-09-19-rubyui-2-0-design.md` — §4.1 (anatomy), §4.3 (the layer's differences from 1.6), §6 "Phase 2.0" last bullet (the `docs/` pin), §6 "Phase 2.1 The hard components first" and §6 "Phase 2.2" (the per-component definition of done), §9.2 (upstream questions). Decisions 1–10 in `design/v2/decisions.md`, especially 7 (every fixture exists before any migration), 8 (the strict lane), 9 (scoped sidecar lookup) and 10 (what Herb's trim mode does; the sidecar layout was left to this plan). The "Not in this plan" section of `design/plans/2026-09-20-phase-2-0b-fixtures-implementation.md` is where this plan was scoped, and its "Translation rules" and Appendix A are the rules every fixture already follows. `design/v2/follow-up-issues.md` item 1 (#537, DataTable's nested forms) is ported as it is, not fixed here. + +## Global Constraints + +- Branch `v2/hard-components`, created from `v2/fixtures` (PR #554, unmerged, tip `8ac23ac`); the PR for this plan targets `v2/fixtures`. Never branch from, rebase onto, or push to `main`. Never push before Task 9 asks. +- Work in `gem/` for every task but Task 1 (`docs/`) and the registry rebuilds (`mcp/`). Run every command from the directory the step names. `ENV["RAILS_ENV"]` is `test` for every gem test run (the helper sets it). +- **No snapshot changes.** Never run `bundle exec rake golden:update` in this plan. `git status --porcelain gem/test/golden/snapshots gem/test/golden/strict` (from the repo root) is empty at the end of every task. Never hand-edit a file under either directory. A migration that needs a snapshot change is a port defect or a STOP (see "When a golden test fails"), never a re-record. +- **No fixture changes.** `gem/test/golden/views/**` is not touched: the fixtures are the translation of the scenarios and were proved against the snapshots with the components still Phlex (plan 2.0b). If a fixture seems wrong, that is a STOP. +- **The Stimulus controllers are unedited.** `git diff --quiet v2/fixtures -- 'gem/lib/ruby_ui/**/*.js'` succeeds at the end of every task. +- **The ruler does not change.** `gem/test/golden/harness.rb`, `catalog.rb`, `canonical_html.rb` and `golden_test.rb` are not touched. `gem/test/golden/scenarios.rb` changes only by removing the Phlex blocks of migrated scenarios (Tasks 3–7) and one paragraph of its header comment (Task 8). +- **A sidecar emits no whitespace of its own** (decision 12, taken in Task 2): no whitespace between `>` and `<`, none at a text–element boundary, a line break only inside an ERB tag or inside an HTML start tag between two attributes, no trim markers (`-%>`, `<%-`) anywhere, and the file ends with exactly one newline (which `render_in` drops). The sidecar rules below are the complete list; every sidecar in this plan is written out in full — copy it, do not reformat it. +- **Every attribute value that comes from the caller goes through `Attributes.flat`** and reaches the page through `tag.attributes` — never `name="<%= value %>"`. That is where nil omits the attribute, a Symbol dasherizes and a `javascript:` URL is dropped, as Phlex did on every element. Only a value the component picks from its own constants (`icon_class`, a polyline's points) may be interpolated. +- **`attrs` is flat; `mixed_attrs` is forwarded.** A sidecar hands `component.attrs` (String keys, serialized) to `tag.attributes`. Ruby that builds another component from a component's attributes, or merges more attributes in before serializing, uses `mixed_attrs` (Symbol keys, nested, classes merged — 1.6's `attrs`). Never splat `attrs` into a `.new`. +- **A migrated class has no Phlex in it:** no `view_template`, no `< Base`, no Phlex element call, no `plain`, no `safe` except the one `Phlex::SGML::SafeValue` `DataTablePerPageSelect` needs for a neighbour that is still Phlex (Task 7, with its comment). +- **Every `bundle exec rake golden` and `bundle exec rake test` in this plan ends with `0 failures, 0 errors, 0 skips`**, at the counts the progress table gives. A test filter is always anchored on the class — `N="/^RubyUI::DialogTest#/"` — because an unanchored `/DialogTest/` also selects `AlertDialogTest` and `/SelectTest/` selects `NativeSelectTest` and `DataTablePerPageSelectTest`. StandardRB is green at every commit. `mcp/data/registry.json` is rebuilt (`cd mcp && bundle exec exe/ruby-ui-mcp-build`) and committed in every task that changes a file under `gem/lib/ruby_ui/`. +- Every commit message ends with `Co-Authored-By: Claude Fable 5.1 `. + +--- + +## What this plan measured — 2026-09-20 + +Every row was rendered through the real path — a template under the gem's `Rails.root`, compiled by ReActionView 0.4.1's handler and so by Herb 0.10.4 with `validation_mode = :raise` — on `v2/fixtures` at `8ac23ac`, Ruby 4.0.2. Probe sources are in Appendix A. "Strict-identical" means `Golden::CanonicalHtml.call(html, strict: true)` equal to the same form of the raw Phlex output. + +| Question | Probe | Result | +| --- | --- | --- | +| A 2.0 component forwards its attributes to a Phlex neighbour (`Checkbox.new(**…)`): the nested hash, or the flat one? | 1a, 1b | The nested hash (`Attributes.mix(default_attrs, user_attrs)`) is strict-identical to 1.6's `DataTableRowCheckbox`. The flat hash is not: `"data-action"` lands beside Checkbox's `data: {action:}` and **two `data-action` attributes** reach the page where 1.6 concatenated them. Decision 11. | +| `yield(self)` with a component-scoped method — `do |group| … group.ToggleGroupItem(…) { "L" }` — through `helpers.render(X.new(…), &block)` | 2 | Renders; the `{ "L" }` block's String is the content (ActionView's `capture` returns the block's value when the buffer is empty). Works for a Phlex neighbour (Button) and a 2.0 one (a probe) alike. | +| An inline event handler for a Phlex neighbour: `NativeSelect.new(onchange: Phlex::SGML::SafeValue.new("…"))` from a sidecar, with the `