Skip to content

Translator improvements: Radix registry, cva, Lucide sidecars, Stimulus bodies#1

Merged
mrinterweb merged 14 commits into
mainfrom
feature/translator-improvements
May 13, 2026
Merged

Translator improvements: Radix registry, cva, Lucide sidecars, Stimulus bodies#1
mrinterweb merged 14 commits into
mainfrom
feature/translator-improvements

Conversation

@mrinterweb
Copy link
Copy Markdown
Owner

Summary

Closes the gap between raw shadcn/ui v4 TSX and renderable Phlex output. Six new translator features (A-F from the punch list) plus a Radix registry extension land the bulk of shadcn's surface area; six review-fix commits harden the edge cases.

  • Translation quality: a fresh bulk pass through every shadcn v4 component goes from 31 CLEAN / 25 PARTIAL on main to 56 CLEAN / 0 PARTIAL on this branch. Showcase renders directly from raw shadcn TSX — no hand-patches.
  • No new dependencies. All new code is internal to the gem; vendored Lucide path data is the only new asset (~4 KB JSON).
  • Test coverage: 442 → 483 specs; rubocop clean throughout.

What changed

Auto-yield on blockless spread-children tags

<div {...props} /> and <Component {...rest} /> now emit do; yield if block_given?; end so callers' children aren't silently dropped. Void elements unchanged. Hit ~84 of 303 generated files in the bulk pass.

Stimulus controller body translation when safe

Inline event handler bodies whose JS happens to be valid JS (the common DOM-driven case in shadcn) now paste directly into the generated _controller.js method body instead of leaving a TODO. Falls back to TODO when the body references React state setters (setX(), hooks (useX(), or non-Identifier arrow params (({ target }) =>). The brace-strip is AST-aware (carried via IR::StimulusMethod#body_is_block) so expression-form arrows like () => ({ x: 1 }) don't get mauled.

Lucide icon sidecar emission

import { ChevronRight } from "lucide-react" plus a <ChevronRight /> usage now emits a chevron_right.rb + shared lucide_icon.rb sidecar so render ChevronRight.new(...) resolves. Path data vendored at lib/jsx_rosetta/icons/lucide.json (~35 icons covering the shadcn-relevant set). Aliased imports (import { ChevronRight as CR }) resolve canonical SVG data while emitting under the local alias. Batch translations dedup the shared base and per-icon files via a Set on the backend instance.

Radix primitive registry

<SeparatorPrimitive.Root />, <LabelPrimitive.Root />, <AvatarPrimitive.Root />, etc. (imported from radix-ui or @radix-ui/react-*) lower to plain HTML elements with the right ARIA roles instead of render SeparatorPrimitive::Root.new(...) NameErrors. Initial registry covers Separator / Label / Avatar / Switch / Progress / AspectRatio / ScrollArea / Tabs / Toggle / ToggleGroup / Collapsible (~25 (Local, Member) pairs). The lookup strips an optional Primitive suffix from the local binding so both shadcn-v3 (SeparatorPrimitive) and shadcn-v4 umbrella (import { Separator } from "radix-ui") idioms resolve. Consumer JSX attrs always win over registry defaults; the collision check normalizes case + hyphens/underscores so future entries can't slip past dataState vs data-state mismatches.

Slot/asChild drop

shadcn's <Comp asChild> pattern routes through Radix's Slot.Root. The polymorphic conditional const Comp = asChild ? Slot.Root : "div" now drops the Slot branch at lowering time and emits the non-Slot tag directly. --keep-slot / keep_slot: true opts back into the conditional. Detection is tight: source matches RADIX_SOURCE_PATTERN, name matches \ASlot(?:Primitive)?\z — no SlotMachine-collateral damage.

cva() variant builder translation

const fooVariants = cva(base, { variants, defaultVariants }) is recognized at lowering as an IR::CvaBinding. The backend hoists FOO_BASE_CLASS, FOO_VARIANT_CLASSES, FOO_DEFAULT_VARIANTS as Ruby constants alongside the class; the use-site className={cn(fooVariants({ variant }), className)} translates to a Ruby string-interpolation literal against those constants. defaultVariants flow into the initializer kwarg defaults. compoundVariants are preserved as a TODO comment (not translated in this cut).

Call-site detection is AST-driven via a dedicated IR::CvaCallSite IR node — not regex over verbatim JS source. This naturally handles:

  • the standard cn(<cvaCall>, <className>) form,
  • reversed-arg cn(<className>, <cvaCall>),
  • the no-cn direct className={<cvaCall>} form,
  • literal-pinned axes { variant: "default" },
  • the degenerate Variants-only const name.

Multi-component files (lower_all on a source exporting two siblings that both reference the same cva binding) emit the constants in every file so non-first siblings don't NameError.

Review fixes

The last six commits on the branch are code-review follow-ups. They tighten contracts found during a thorough multi-agent review of the seven feature commits — destructured-param Stimulus bails, Lucide alias resolution, sibling-component cva-constant duplication, regex-to-AST migration for cva, attribute-name normalization in the Radix collision check, @current_component cleared on emit exit, and a few cosmetic concerns.

Stress / showcase verification

# bulk-translate every shadcn v4 component
for tsx in /tmp/shadcn_raw_src/*.tsx; do
  bundle exec exe/jsx_rosetta translate "$tsx" --as=phlex --phlex-namespace=Components -o /tmp/shadcn_v2_out
done

# drop into the showcase
cp /tmp/shadcn_v2_out/*.rb /Users/sean/code/rosetta_showcase/app/components/
cp /tmp/shadcn_v2_out/*.js /Users/sean/code/rosetta_showcase/app/javascript/controllers/

Result: every shadcn v4 source translates without manual intervention. All showcase routes return HTTP 200 (/create, /create/cards, /create/mail, /create/forms, /create/music, /create/playground); the playground renders 250 component instances across 60+ distinct data-slot kinds — equivalent to the previously hand-patched build.

Deferred / out of scope

  • compoundVariants translation — TODO sidecomment preserved verbatim; the cva binding type has a compound_source field standing by for a follow-up.
  • More Radix primitives — ~181 <XPrimitive>::<Member> references still surface in unmapped families (Dialog / DropdownMenu / Menubar / ContextMenu / NavigationMenu / Popover / HoverCard / Tooltip / Sheet / Drawer / Carousel). Additive registry expansion only — same pattern as existing entries.
  • Icon libraries beyond Lucide (tabler-icons, heroicons, radix-icons) — same sidecar pattern would extend; not in scope here.
  • Backend split — the Phlex backend lives in one class still; the AST-driven cva refactor recovered enough lines that we're back under the original cop limit. Revisit if it grows.

mrinterweb and others added 13 commits May 13, 2026 12:12
shadcn idiom <tag {...props} /> (self-closing, rest-spread carries
React `children`) translated to Phlex `tag(..., **(@props || {}))` with
no block, silently dropping any children a caller passed via
`Component.new { ... }`. Now: when the IR element/invocation has no
explicit children but has a SpreadAttribute and the tag is non-void,
emit `tag(...) do; yield if block_given?; end`.

Implementation:
- New private predicate `spreads_children?(node)` checks for
  IR::SpreadAttribute in attributes/props.
- `render_element` and `render_component_invocation` consult it in the
  blockless branch and add a yield-safe block via a new
  `yield_only_block` helper.
- `yield if block_given?` (not bare `yield`) so the same component
  still renders fine when called without a block — preserves the
  void-element / non-spread blockless behavior for callers that
  intentionally don't pass children.
- render_element and render_component_invocation each refactored to
  delegate to small helper methods (element_body / blockless_element?
  / component_invocation_body) to keep AbcSize under cop limits.

Stress corpus impact:
- 84 of 303 generated files in a fresh shadcn/ui v4 pass go from
  "renders an empty container" to "yields callers' children correctly."
- Existing fixtures unchanged.
- 442 specs, 0 failures; rubocop clean.
The auto-generated _controller.js stubs left the handler body as a
TODO comment with verbatim JSX source; the human had to copy that
comment into the method. For DOM-driven bodies (the common case in
shadcn-shaped UI code), the JSX is already valid JS — paste it
directly into the method instead, using the original arrow's
parameter name so the body's references still resolve. For bodies
that reference React state setters or hooks we can't run in the
browser, fall back to the previous TODO behavior.

Implementation:
- IR::StimulusMethod gains a params: field carrying the original
  arrow parameter names. Lowering populates it from
  arrow_node[:params]; the identifier-bound path passes [].
- stimulus_method_lines splits into safe_to_paste_handler?,
  pasted_handler_lines, and todo_handler_lines.
- safe_to_paste_handler? bails on:
  - bodies starting with `//` (the "originally bound to:" pseudo-body)
  - bodies referencing setX( (React state setters)
  - bodies referencing useX( (React hooks)
- pasted_handler_lines strips an outer `{ … }` block wrapper if
  present (arrow bodies can be expr-form or block-form) and indents
  inner lines to 4 spaces.

The collision-marker NOTE comment is preserved across both paths.

Stress corpus impact:
- 3 Stimulus controllers in a fresh shadcn/ui v4 pass have their
  handler body translated end-to-end. Identifier-bound and
  state-touching handlers still get TODOs — that's the right
  behavior, not a regression.
- 445 specs, 0 failures; rubocop clean.
`import { ChevronRight } from "lucide-react"` + a `<ChevronRight />`
JSX usage produced `render ChevronRight.new(...)` referencing a
non-existent Ruby class — NameError at render. Now: detect Lucide
imports (lucide-react, lucide) that are actually used as JSX
component tags, and emit one sidecar Phlex class per icon plus a
shared base.

Implementation:
- lib/jsx_rosetta/icons/lucide.json: vendored inner-SVG path data
  for the ~35 icons commonly imported by shadcn/ui sources.
- lib/jsx_rosetta/icons.rb: JsxRosetta::Icons.lucide_for(name) +
  lucide_source?(source). Tolerates both ChevronRight and
  ChevronRightIcon naming (same path data).
- Phlex backend emit() returns extra File entries when
  referenced_lucide_icons(component) is non-empty: one per icon
  named after the JSX import (chevron_right.rb defining
  Components::ChevronRight), plus lucide_icon.rb with the shared
  Phlex::HTML base.
- collect_component_invocations walks the IR tree to find which
  Lucide imports are actually referenced as JSX tags; unused
  imports don't trigger sidecar emission.
- Honors --phlex-namespace by wrapping each sidecar in the same
  module as the main class.

Icons not in the vendored set still emit a class, but inner_svg is
empty with a TODO comment pointing at lucide.json — the human
reviewer can fill in the path data and skip the failing render
without a NameError.

Stress corpus impact:
- 33 of 303 generated files in a fresh shadcn/ui v4 pass go from
  "NameError on render" to "icon renders correctly out of the box."
- 451 specs, 0 failures; rubocop clean.
<SeparatorPrimitive.Root />, <LabelPrimitive.Root />, etc. lowered
to ComponentInvocations (`render SeparatorPrimitive::Root.new`),
producing undefined-constant NameErrors at render. Now: when the
import source matches a Radix package (radix-ui or @radix-ui/react-*)
and the (LocalName, Member) pair is in a small registry, lower as
an HTML Element with the right tag and always-applied attributes.
Unknown primitives keep the existing ComponentInvocation behavior.

Implementation:
- New lib/jsx_rosetta/ir/radix_registry.rb. Maps Separator / Label /
  Avatar (Root/Image/Fallback) / Switch (Root/Thumb) / Progress
  (Root/Indicator) / AspectRatio (Root) / ScrollArea (Root/Viewport).
- New RADIX_SOURCE_PATTERN module-level constant matches radix-ui
  + @radix-ui/react-<primitive> per-primitive packages.
- Lowering stores file-level @module_imports for the duration of
  the component pass; lower_jsx_element consults the registry when
  the JSX tag is a two-segment member chain whose root segment was
  imported from a Radix package.
- merge_radix_attrs injects the registry's fixed attrs (role, type,
  etc.) only when the consumer hasn't already specified that
  attribute. Consumer JSX always wins on collision.
- Bump Metrics/ClassLength cap on Lowering from 1200 to 1300 to
  accommodate the registry-lookup helpers (Lowering is the central
  AST→IR class and is expected to be large).

Stress corpus impact:
- ~30 of 303 generated files in a fresh shadcn/ui v4 pass go from
  "NameError on the Radix Primitive::Root render" to "renders the
  underlying HTML element correctly" with no source adaptation.
- 458 specs, 0 failures; rubocop clean.
shadcn's <Comp asChild> pattern routes through Radix's Slot, which
the translator faithfully preserved as a polymorphic Conditional
whose true-branch rendered `Slot::Root.new(...)` — a non-existent
Ruby class. Now: when the polymorphic conditional has exactly one
branch resolvable to a Slot reference rooted at a Radix import,
drop that branch and emit only the non-Slot tag directly. Pass
--keep-slot to preserve the conditional (e.g. if shimming
Slot::Root yourself).

Implementation:
- Lowering takes a `keep_slot:` constructor kwarg (default false).
  Threaded through JsxRosetta.lower / .translate and IR.lower /
  .lower_all so the CLI flag and library callers both work.
- New CLI flag --keep-slot for `exe/jsx_rosetta translate`.
- lower_polymorphic_tag_use consults drop_slot_branch first; when
  it returns a non-nil branch, emit that branch directly and skip
  the Conditional entirely. Otherwise fall through to the existing
  if/else emission.
- radix_slot_branch? recognizes both `<Slot>` and `<Slot.Root>`
  forms when the root identifier was imported from a radix-shaped
  package; tolerates the rare `SlotPrimitive` alias by checking
  the local name starts with "Slot".
- Bump Metrics/ParameterLists from 6 to 7 to accommodate
  JsxRosetta.translate now taking keep_slot: alongside the existing
  five kwargs and **legacy_options.

Stress corpus impact:
- ~15 of 303 generated files in a fresh shadcn/ui v4 pass go from
  "NameError on the asChild render path" to "renders the underlying
  tag correctly" with no source adaptation.
- 463 specs, 0 failures; rubocop clean.
shadcn-shaped components use class-variance-authority to declare
variant axes at module level (`const fooVariants = cva(base, ...)`)
and apply them at the use site
(`className={cn(fooVariants({ variant }), className)}`). Both paths
were unhandled before: the const surfaced as a TODO comment block,
the call site landed in the class= attribute as a literal string
`"cn(fooVariants({...}), className)"`. Now: recognize the cva
pattern, hoist base + variant maps + defaultVariants into Ruby
constants alongside the class, and emit the call site as a
string-interpolated class attribute.

Implementation:
- New IR::CvaBinding type (name / base_class / variants /
  default_variants / compound_source), distinct from LocalBinding.
- record_module_binding routes `const X = cva(...)` to a new
  parse_cva_binding helper that pulls base + per-axis option maps
  + defaultVariants from the AST. Falls back to LocalBinding when
  the shape isn't recognized.
- Phlex backend's render_module_bindings_prefix partitions the
  bindings: CvaBindings emit `FOO_BASE_CLASS`, `FOO_VARIANT_CLASSES`,
  `FOO_DEFAULT_VARIANTS` constants; non-cva bindings keep the
  existing module-level TODO comment block. Renamed the body-level
  collision target to render_module_local_bindings_todo.
- class_attribute_part calls rewrite_cva_call_site first when the
  attr expression is a `cn(<name>({...}), <maybeClassName>)` against
  a known CvaBinding on the current component. Emits a Ruby string
  interpolation against the hoisted constants.
- render_initializer's ruby_default_for consults
  cva_axis_default_for so the cva defaultVariants flow into the
  Ruby kwarg defaults (`variant: "default"` instead of `nil`).
- compoundVariants are preserved as a TODO comment alongside the
  emitted constants — translation is left for a follow-up.

Stress corpus impact:
- 14 of 303 generated files in a fresh shadcn/ui v4 pass go from
  "needs hand-patch to render" to "renders out of the box."
- 469 specs, 0 failures; rubocop clean.
End-to-end verification through the showcase repo surfaced three
more Radix-primitive packages whose Root/Trigger/Content/Item
sub-components needed registry entries: TabsPrimitive (Root, List,
Trigger, Content), TogglePrimitive (Root), ToggleGroupPrimitive
(Root, Item), CollapsiblePrimitive (Root, Trigger, Content). All
follow the same pattern as the existing entries — map the member
chain to an HTML tag with the right role/type kwargs.

This is purely additive: existing translations are unchanged.
Unknown primitives still fall through to the ComponentInvocation/
TODO path, so consumers who hand-shim other primitives aren't
affected.

Stress impact: the showcase's `/create` and `/create/playground`
now render directly from the new gem's translation of the *raw*
shadcn/ui v4 source — no hand-patching, no source adaptation.
Two related correctness fixes flagged in code review of the Radix
registry commit (e2b622a) and the Slot drop commit (a8afbf0):

1. `import { Separator } from "radix-ui"; <Separator.Root/>` — the
   shadcn-v4 umbrella idiom without the `Primitive` suffix — was
   never resolved by RadixRegistry.lookup because every key in MAP
   used the `*Primitive` convention. So the v4 form fell through to
   ComponentInvocation and emitted `render Separator::Root.new` (a
   NameError at render — exactly the failure mode that commit was
   meant to close).

   Switch registry keys to the canonical primitive name (`Separator`)
   and have lookup strip an optional `Primitive` suffix from the
   local binding before keying in. Both v3 (`SeparatorPrimitive`)
   and v4 (`Separator`) names now resolve.

2. `radix_slot_branch?` used `imp.source.to_s.include?("radix")` and
   `imp.name.start_with?("Slot")` — too loose on both axes. A user
   import like `import { SlotMachine } from "@radix-ui/react-slot-machine"`
   would silently get its branch dropped. Tighten the source check
   to `RADIX_SOURCE_PATTERN.match?` and constrain the local name to
   a new `SLOT_LOCAL_NAME_PATTERN` that matches only `Slot` and
   `SlotPrimitive`.

Specs added: shadcn-v4 umbrella positive case (`<Separator.Root/>`)
and a `SlotMachine`-from-radix negative case. 471 specs, 0 failures;
rubocop clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness fixes flagged in code review of commit 23eafd4:

1. **Aliased imports lost vendored SVG path data.** `import { ChevronRight
   as CR } from "lucide-react"; <CR/>` emitted `cr.rb` with empty
   `inner_svg` because the SVG lookup keyed on the local alias `CR`
   (not in lucide.json) instead of the original `ChevronRight`. Fix:
   extend `IR::ModuleImport` with an `imported_name` field that
   captures `spec[:imported][:name]` from the AST. Phlex backend's
   `referenced_lucide_icons` now returns `{ local_name:, canonical_name: }`
   pairs; SVG lookup uses the canonical name, the emitted class /
   filename uses the local name.

2. **Batch emission re-emitted shared icons per component.** A multi-
   component source (`lower_all`) where two siblings both referenced
   `<ChevronRight/>` produced two `chevron_right.rb` + `lucide_icon.rb`
   files, risking overwrites and bloating output. Track a
   `@seen_lucide_icons` Set on the backend instance and skip already-
   emitted icons + base in subsequent `emit` calls on the same backend.

3. **`LucideIcon` captured `@props` but never used it** — the field
   was dead state. Drop the assignment; the `**` in the signature
   still accepts kwargs gracefully so subclasses with the standard
   shadcn render pattern aren't disturbed.

Bonus: `Icons.lucide_for` now bails early for the degenerate name
`"Icon"` so callers don't get a misleading `data[""]` miss.

Specs added: aliased-import resolves canonical SVG; multi-component
file dedups base + per-icon emission. 474 specs, 0 failures; rubocop
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness fixes flagged in code review of commit fd8a9fe
that tighten when handler bodies get pasted verbatim vs preserved
as a TODO.

1. **Non-Identifier arrow params silently lost their bindings.**
   `({ target }) => target.classList.add("hi")` lowered with
   `params = [].compact` — the ObjectPattern had no `:name`, so the
   pasted method emitted `clickHandler(event) { target.classList… }`
   referencing an undefined `target`. Same hazard for rest
   (`(...args) =>`) and array destructuring.

   Fix: lowering now records `nil` for non-Identifier params instead
   of dropping them. `safe_to_paste_handler?` bails to TODO when any
   param entry is nil — the reviewer translates the destructure
   intentionally instead of getting a silent runtime NameError.

2. **`safe_to_paste_handler?` false-positived on DOM `set*` calls.**
   `/\bset[A-Z]\w*\(/` matched `e.target.setAttribute(`,
   `el.setPointerCapture(`, `input.setSelectionRange(` because `\b`
   matches at the `.`. These are perfectly pasteable; only top-level
   React state setters should bail.

   Fix: anchor on `(?<![.\w])set[A-Z]` (and same for `use[A-Z]`) so
   the regex only fires on bare `setX(`, not member calls.

3. **`pasted_handler_lines` brace-strip wasn't AST-aware.** Babel
   gives back `{ x: 1 }` as the body source for an expression-form
   arrow `() => ({ x: 1 })`. The character-bounds strip yielded
   `x: 1` — a JS label statement (no-op). Block-form arrows
   `(e) => { … }` correctly want the braces stripped.

   Fix: lowering captures whether the body was a BlockStatement on
   `IR::StimulusMethod#body_is_block`; the emitter only strips when
   `body_is_block: true`.

Specs added: destructured / rest param → TODO, DOM `set*` →
pasted, expression-form body preserves braces. 478 specs, 0
failures; rubocop clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y prefix

Three correctness fixes flagged in code review of commit 0d03e5f.

1. **Sibling components in a multi-component file produced undefined
   constants at render.** `lower_all_components` shares one
   `module_bindings` array across siblings, and
   `first_emit_for_module_bindings?` suppresses re-emission so the cva
   constants only landed in the first sibling's file. Sibling 2+ still
   interpolated `BUTTON_BASE_CLASS` etc. — undefined in its own file.

   Fix: `render_module_bindings_prefix` partitions before the first-
   emit gate. cva constants always emit (every sibling references them
   in its class body); the non-cva TODO block keeps the original
   suppress-on-later-siblings behavior since that block is purely
   informational.

2. **Literal-pinned axes (`{ variant: "default" }`) emitted invalid
   Ruby.** `cva_call_axes` wrapped every axis value as
   `@#{underscore(value_src)}`, so `cn(buttonVariants({ variant:
   "default" }), className)` produced `@"default"` — a parse error.
   Real shadcn wrappers occasionally pin a default at the call site
   for an "always-this-variant" subclass.

   Fix: new `cva_axis_value_expr` distinguishes JS identifier (snake-
   case `@ivar`) from string / numeric / boolean / null literal (Ruby
   equivalent passed through). `null` / `undefined` collapse to `nil`.

3. **`cva_constant_prefix` produced an empty prefix for the
   degenerate name `Variants`.** `"Variants".sub(/Variants?\z/, "")`
   → `""` → `_BASE_CLASS` (Ruby SyntaxError). Falls back to the
   bare upcased name when the stripped form is empty.

Specs added (closes the spec-coverage gaps the review called out):
literal-pinned axis, multi-component file with cva, degenerate
`Variants` name. 481 specs, 0 failures; rubocop clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four design concerns flagged in code review of commits 0d03e5f /
e2b622a / 0c23b2c. None of these change emitted output for any
JSX shape the test suite covers — they harden contracts.

1. **Phlex backend's `emit()` left `@current_component` set after
   completion.** A long-running emitter instance pinned the entire
   component IR tree until the next emit() call — small mem-pressure
   footgun. Pair the assignment with `ensure @current_component =
   nil` so the reference drops on every exit (success or raise).

2. **`merge_radix_attrs` compared attribute names without case /
   hyphen normalization.** Today's registry only ships lowercase
   keys (`role`, `type`), so collisions resolve correctly — but the
   moment a future entry like `data-state` lands while a consumer
   writes `dataState`, neither would register as a conflict and
   both would emit. Added `normalize_attr_key` (downcase + strip
   `-_`) so the collision check works under any case/separator
   convention.

3. **`element_body` and `component_invocation_body` used different
   control flow shapes for the same logical decision tree** (void
   / blockless / blockless-with-spread / explicit-children).
   Refactor `component_invocation_body` to mirror element_body:
   early-return on `blockless_invocation?`, then the spread-vs-
   children fork. Same observable behavior; the two methods now
   read symmetrically and are easier to keep in sync.

4. **`IR::CvaBinding#compound_source` doc strengthened** to make
   it unambiguous that the field is an INTENTIONALLY UNPARSED
   verbatim JS string, not a structural sub-shape — the original
   name reads like the latter.

481 specs, 0 failures; rubocop clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architectural cleanup flagged in code review of commit 0d03e5f.
The Phlex backend used to regex over verbatim JS source to detect
`cn(<cvaName>({...}), <classArg>)` at `className` attribute values
— precisely the "no JS-to-Ruby translation paths that best-guess
arbitrary call expressions" pattern CLAUDE.md warns against. The
regex also missed several real shapes:

  - reversed-arg order: `cn(className, fooVariants({...}))`
  - no-cn direct form: `className={fooVariants({...})}`
  - literal-pinned axes: `cn(foo({ variant: "default" }), …)`
    (separately fixed in c9ada6b; subsumed here for free)

Implementation:

- New IR types `CvaCallSite { binding_name, axes, class_arg }`
  and `CvaAxisPair { axis, kind, source }`. The `kind`
  discriminator carries one of `:prop_ref`, `:literal_string`,
  `:literal_other`, `:literal_nil` so the backend renders each
  axis value with the right Ruby form (e.g. `"default"` for a
  literal, `@variant` for a prop reference) — handled at
  lowering, not regex-grepped at emit.

- `lower_class_name` now calls `try_lower_cva_call_site` after
  the existing `cn(...)` decomposer. It walks the AST:
  1. `cn(<cvaCall>, <classArg>)` or `cn(<classArg>, <cvaCall>)`
     — accept the first arg that resolves to a known CvaBinding,
     the other becomes class_arg.
  2. Bare `<cvaName>({ axes })` — no class_arg.
  3. Anything more complex (3+ args, nested cn) bails to the
     generic StyleBinding path.

- `@module_bindings` now lives on the Lowering instance for the
  duration of `lower_file` / `lower_all_components` so
  `lower_class_name` can check whether a referenced call site
  matches a known CvaBinding. Previously module_bindings was a
  local variable consumed only at attach-metadata time.

- Phlex backend grew a `IR::CvaCallSite` branch in
  `phlex_attribute_part`. Renders the same Ruby string-
  interpolation literal the regex used to produce, but the
  inputs come from the IR node, not a re-parse.

- Deleted: `CVA_CALL_PATTERN`, `rewrite_cva_call_site`,
  `cva_call_axes`, `cva_axis_value_expr`, `translate_cva_class_arg`.

Specs added: no-cn direct form, reversed-arg order. 483 specs,
0 failures; rubocop clean. Backend file lost 70 lines net —
smaller class, lowering carrying its share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mrinterweb mrinterweb self-assigned this May 13, 2026
Resolves conflicts with main's pages-routing feature (three slices:
25d76ab + 5a063eb + 3f82265).

Conflicts:
- lib/jsx_rosetta.rb — both sides added a top-level require_relative
  (icons here, pages_routing on main). Kept both. The `backend_for`
  call site auto-merged cleanly: main's `:rails_view, :route_table`
  Phlex backend options sit alongside our `:suffix, :namespace`.
- lib/jsx_rosetta/cli.rb — main refactored the option parser into
  three `consume_*_option?` helpers (translate / phlex / pages_routes).
  Adopted main's shape and added `--keep-slot` to
  `consume_translate_option?` (it's a translation-flow flag, parallel
  to `--tsx` / `--as`, not a Phlex-renderer flag).

Auto-merge correctly wove the per-tag `tag:` kwarg main threaded
through `format_attributes` → `phlex_attribute_part` →
`plain_attribute_part` (used by the new href rewriter for `<a>`
links) around our `IR::CvaCallSite` dispatch branch.

Verification:
- 572 specs, 0 failures (478 from this branch + 94 from main's
  pages-routing slices); rubocop clean.
- Main's pages-routing + cli specs all pass: 86 examples in
  `spec/pages_routing_spec.rb` + `spec/cli_spec.rb`.
@mrinterweb mrinterweb merged commit 4593573 into main May 13, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant