Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8e735cc
[Documentation] RubyUI 2.0: Phase 2.0a implementation plan
cirdes Sep 20, 2026
9af5939
[Documentation] Phase 2.0a plan: fold the adversarial review in
cirdes Sep 20, 2026
f9a30c7
[Documentation] Phase 2.0a plan: the fixed-point fixes moved to the r…
cirdes Sep 20, 2026
76f2df3
[Documentation] Phase 2.0a plan: counts follow the strengthened harne…
cirdes Sep 20, 2026
d8ce4e9
[Feature] Test harness: boot an inline Rails application so ERB compi…
cirdes Sep 20, 2026
637760a
[Feature] RubyUI::Attributes: the 2.0 attribute layer, with Phlex's g…
cirdes Sep 20, 2026
7df35df
[Bug Fix] RubyUI::Attributes: refuse a URL attribute with no String f…
cirdes Sep 20, 2026
e5e7a3e
[Feature] RubyUI::Component: the 2.0 layer with a scoped sidecar lookup
cirdes Sep 20, 2026
75d3dfe
[Feature] RubyUI::Component#enum: coerce and validate enumerated attr…
cirdes Sep 20, 2026
4eb08a6
[Feature] Golden suite: an ERB lane, proved on Button
cirdes Sep 20, 2026
3cf6e97
[Feature] Golden suite: a strict lane for every scenario
cirdes Sep 20, 2026
922f2b1
[Documentation] RubyUI 2.0: record what Phase 2.0a decided
cirdes Sep 20, 2026
b635994
[Bug Fix] RubyUI 2.0 layer: match Phlex on four attribute shapes, pin…
cirdes Sep 20, 2026
044316c
[Documentation] RubyUI 2.0: the spec stops promising a collision erro…
cirdes Sep 20, 2026
06fa8bd
[Documentation] Test harness: say why the application is needed
cirdes Sep 20, 2026
c2449ea
[Bug Fix] RubyUI::Attributes: refuse an unsafe name before reading it…
cirdes Sep 20, 2026
09e2ee6
[Bug Fix] Golden suite: the strict form trims only HTML whitespace at…
cirdes Sep 20, 2026
afb83f6
[Documentation] Review of #548: the load-hook assertion, decision 5's…
cirdes Sep 20, 2026
4e6bbee
[Documentation] Follow-up issues 8–11 opened as #550–#553
cirdes Sep 20, 2026
036e18d
Merge feat/golden-suite: the custom-keybindings scenario fix
cirdes Sep 20, 2026
f855423
[Bug Fix] Golden suite: strict snapshot follows the custom-keybinding…
cirdes Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 72 additions & 51 deletions design/2026-09-19-rubyui-2-0-design.md

Large diffs are not rendered by default.

2,130 changes: 2,130 additions & 0 deletions design/plans/2026-09-20-phase-2-0a-foundation-implementation.md

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions design/v2/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,97 @@ before the gem release instead of after it.
**What would reverse this:** sub-phase 2.3 proving larger than the component
migration itself, in which case the generator ships in 2.1 instead.

## 5. The gem's test harness is an inline Rails application — 2026-09-20

Decision A said `actionview` + `reactionview`, no controller, no dummy app.
The premise this started from — that `local_template?` treats a template
outside `Rails.root` as "external" and falls back to Erubi — doesn't hold
without an application: `Rails.root` is then `nil`, `nil.to_s` is `""`, and
every identifier `start_with?("")`, so every template counts as local. The
reasons that do hold: ReActionView's Railtie registers its ERB handler
(`ActionView::Template.register_template_handler :erb, …`) only from
`config.after_initialize`, which a Railtie runs only when a
`Rails::Application` boots, inside the `:action_view` load hook that fires
when `ActionView::Base` loads — without an application, Erubi stays the
`:erb` handler and Herb's validation never runs on anything, local or not;
and `Rails.env`
defaults to `development` unless `RAILS_ENV`/`RACK_ENV` is set (it does not
need an application, but nothing else in a bare `require "rails"` sets it
either), which turns on 1.6 `Base`'s dev comment. So the tests need a `Rails`,
and its root must be the gem. `test_helper.rb` boots the smallest
`Rails::Application` — no `app/` directory, no routes, no database — with
`config.root` at the gem and `RAILS_ENV=test`. Measured: the existing suite is
unchanged under it and `ActionView::Template.handler_for_extension(:erb)` is
ReActionView's.
**Cost if wrong:** `railties` as a development dependency and ~1 s of boot per
test run. **What would reverse it:** ReActionView registering its ERB handler
outside an application-boot initializer, at which point the app object's only
remaining job is `Rails.env`, which `ENV["RAILS_ENV"] = "test"` alone already
covers.

## 6. The 2.0 layer is `RubyUI::Component` until the last Phlex component is gone — 2026-09-20

`RubyUI::Base` is the Phlex base that 256 components inherit; the 2.0 layer
cannot take the name while they exist. It lands as `RubyUI::Component`, every
migrated component inherits `Component`, and one mechanical commit in Phase
2.4 — after the last migration, before the installer is rewritten — renames
`Component` to `Base`. Users never see `Component`. The alternative, renaming
the Phlex base first, touches all 256 files for no user benefit.
**Cost if wrong:** one sed across the migrated files at the end.

## 7. Every ERB fixture is written before any component migrates — 2026-09-20

With `phlex-rails` loaded (development only), an ERB fixture renders a
component that is still Phlex, so the ERB lane can be green for all 188
scenarios while nothing has migrated. Plan 2.0a proves it on Button's 15;
plan 2.0b writes the other 173. After that a migration changes only an
implementation, never the ruler — "did I write the fixture right" and "did I
port the component right" stop being one failure. While a scenario keeps its
Phlex block, that lane records and the ERB lane compares.
**Cost if wrong:** ~2,000 lines of ERB written ahead of the first migration.
`phlex-rails` leaves the gemspec with the last Phlex component.

## 8. The strict lane is the canonical form in preserve mode, for every scenario — 2026-09-20

Every scenario keeps a second snapshot, `CanonicalHtml.call(html, strict: true)`:
the whole fragment in the normalizer's preserve mode (text and whitespace
verbatim, attributes sorted, comments dropped, the fragment's own edges
trimmed). Same fixed-point discipline, same recording rule, same runner. The 188
strict snapshots were recorded from Phlex.

The spec named seven text-bearing components; the plan first mapped that to
five directories. Review found a sixth (Breadcrumb), and an audit of the raw
Phlex output found text inside an inline element in 38 of 54 components — any
list is one review away from missing one. So there is no list: the contract is
the literal one, *the 2.0 sidecar emits what Phlex emitted*, and Phlex never
emitted whitespace between elements. Fixtures and sidecars are written
whitespace-tight — one line, or `<%-`/`-%>` — because the strict form sees
every newline they add; the canonical form stays as the diagnostic (canonical
passes, strict fails: whitespace only).
**Cost if wrong:** 188 more files to keep, sidecars without newlines between
static elements, and strictness where a browser would not have cared (inside a
flex parent, say). **What would reverse it:** sidecars for the large composites
proving unreadable under the rule, at which point the strict lane narrows to a
criterion computed from the output (text adjacent to an element sibling) rather
than a hand-picked list.

## 9. Sidecar lookup takes a list of roots; the fresh-app script moves to 2.4 — 2026-09-20

`RubyUI.component_roots` is an Array — `lib` and `test/probes` in the gem, one
entry in a host app — and a class's sidecar is found under the root that
contains its file, through a `LookupContext` scoped to that root. Collision
with a host template is impossible by construction, and "two sidecars for one
class" cannot happen (one class file, one directory). The fresh-app install
script the spec put in Phase 2.0 moves to Phase 2.4: until the installer writes
the 2.0 initializer, the script would only exercise the 1.6 installer, which
proves nothing about 2.0.

The scoped lookup registers each root's resolver through
`ActionView::PathRegistry.cast_file_system_resolvers` — a `:nodoc:` API
present since Rails 7.1 — because that registration is what lets the
reloader's `DetailsKey.clear` and `CacheExpiry` see the sidecars; a test pins
it. ReActionView allows `actionview >= 7.0`; the 2.4 gemspec floors Rails at
7.1.

**Cost if wrong:** installation is first exercised end to end in 2.4 rather
than now.
46 changes: 45 additions & 1 deletion design/v2/follow-up-issues.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Follow-up issues surfaced by the golden suite

Date: 2026-09-19
Status: opened 2026-09-19 as #537–#543
Status: 1–7 opened 2026-09-19 as #537–#543; 8–11 opened 2026-09-20 as
#550–#553 from the review of PR #548

The golden suite pins what 1.6 renders today, defects included — that is
what makes it a ruler. These are the defects the recording made visible
Expand Down Expand Up @@ -110,6 +111,49 @@ Ordered by user impact.
- **Fix:** `aria_expanded: "false"` and have `command_controller.js` set it;
re-record `command/*`.

## 8. `Switch` is neither perceivable nor operable by assistive technology — #550

- **Where:** `switch.rb`: `role: "switch"` on the label with no
`aria-checked`; the checkbox input carries `class: "hidden peer"`
(`display: none`). Snapshots `switch/*`.
- **Effect:** the switch role requires `aria-checked`; a `display: none`
input leaves the accessibility tree and the tab order, so keyboard users
cannot toggle it and the label's `focus-visible:` classes never apply.
- **Fix:** render `aria-checked` and keep it in sync from the controller;
`sr-only` instead of `hidden` so the input stays focusable; re-record
`switch/*`.

## 9. `ComboboxInputTrigger` puts the combobox states on a role-less `<div>` — #551

- **Where:** `combobox_input_trigger.rb`: `aria-expanded` and
`aria-haspopup="listbox"` on the wrapper `div`; the `<input>` has no
`role="combobox"`, `aria-controls` or `aria-activedescendant`. Snapshot
`combobox/input_trigger`.
- **Effect:** ARIA 1.2 puts those states on the element with the combobox
role; on a role-less div they are not conveyed, and the listbox
relationship is never announced.
- **Fix:** move `role: "combobox"`, `aria-haspopup`, `aria-expanded` and
`aria-controls` onto the input; re-record `combobox/*`.

## 10. `ClipboardPopover` starts hidden with `data-state="open"` — #552

- **Where:** `clipboard_popover.rb` `default_attrs` sets `state: :open` on a
panel that starts hidden; `clipboard_controller.js` sets `open` in
`showPopover` and `closed` in `hidePopover`. Snapshots
`clipboard/popover_*`.
- **Effect:** the hidden panel is labelled open, unlike ContextMenu,
HoverCard and Popover (`closed` at rest). Low user impact: the animate-in
classes still run when the element is first displayed.
- **Fix:** `state: :closed`; re-record `clipboard/popover_*`.

## 11. `Link` carries `type="button"` on an `<a>` — #553

- **Where:** `link.rb` `default_attrs` `{type: "button", ...}`, copied from
Button. Snapshots `link/*`.
- **Effect:** `type` on an anchor names the linked resource's MIME type;
`button` is meaningless there. Invalid, harmless.
- **Fix:** drop `type:` from Link's `default_attrs`; re-record `link/*`.

## Rejected review findings, for the record

Two findings from the same review were checked and are wrong; they are
Expand Down
112 changes: 112 additions & 0 deletions gem/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,66 @@ PATH
GEM
remote: https://rubygems.org/
specs:
actionpack (8.1.3.1)
actionview (= 8.1.3.1)
activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actionview (8.1.3.1)
activesupport (= 8.1.3.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
bigdecimal (4.1.3)
builder (3.3.0)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
cruise (0.3.0)
cruise (0.3.0-arm64-darwin)
cruise (0.3.0-x86_64-linux-gnu)
drb (2.2.3)
erb (6.0.7)
erubi (1.13.1)
herb (0.10.4)
herb (0.10.4-arm64-darwin)
herb (0.10.4-x86_64-linux-gnu)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.21.1)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mini_portile2 (2.8.9)
minitest (6.0.6)
drb (~> 2.0)
Expand All @@ -29,14 +84,60 @@ GEM
phlex (2.4.1)
refract (~> 1.0)
zeitwerk (~> 2.7)
phlex-rails (2.4.0)
phlex (~> 2.4.0)
railties (>= 7.1, < 9)
zeitwerk (~> 2.7)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
racc (1.8.1)
rack (3.2.7)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reactionview (0.4.1)
actionview (>= 7.0)
cruise
herb (>= 0.10.4, < 0.11.0)
refract (1.1.0)
prism
zeitwerk
regexp_parser (2.12.0)
reline (0.7.0)
io-console (~> 0.5)
rouge (5.1.0)
strscan (~> 3.1)
rubocop (1.88.2)
Expand All @@ -58,6 +159,7 @@ GEM
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
sin_lru_redux (2.5.3)
standard (1.56.0)
language_server-protocol (~> 3.17.0.2)
Expand All @@ -74,9 +176,15 @@ GEM
strscan (3.1.8)
tailwind_merge (1.5.5)
sin_lru_redux (~> 2.5)
thor (1.5.0)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
zeitwerk (2.7.5)

PLATFORMS
Expand All @@ -85,10 +193,14 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
actionview (~> 8.1)
minitest (~> 6.0)
nokogiri (~> 1.18)
phlex (~> 2.1, >= 2.1.2)
phlex-rails (~> 2.4)
railties (~> 8.1)
rake (~> 13.0)
reactionview (~> 0.4)
rouge (~> 5.1.0)
ruby_ui!
standard (~> 1.0)
Expand Down
Loading
Loading