Translator improvements: Radix registry, cva, Lucide sidecars, Stimulus bodies#1
Merged
Merged
Conversation
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>
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
mainto 56 CLEAN / 0 PARTIAL on this branch. Showcase renders directly from raw shadcn TSX — no hand-patches.What changed
Auto-yield on blockless spread-children tags
<div {...props} />and<Component {...rest} />now emitdo; yield if block_given?; endso 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.jsmethod 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 viaIR::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 achevron_right.rb+ sharedlucide_icon.rbsidecar sorender ChevronRight.new(...)resolves. Path data vendored atlib/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 aSeton the backend instance.Radix primitive registry
<SeparatorPrimitive.Root />,<LabelPrimitive.Root />,<AvatarPrimitive.Root />, etc. (imported fromradix-uior@radix-ui/react-*) lower to plain HTML elements with the right ARIA roles instead ofrender 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 optionalPrimitivesuffix 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 pastdataStatevsdata-statemismatches.Slot/asChild drop
shadcn's
<Comp asChild>pattern routes through Radix'sSlot.Root. The polymorphic conditionalconst Comp = asChild ? Slot.Root : "div"now drops the Slot branch at lowering time and emits the non-Slot tag directly.--keep-slot/keep_slot: trueopts back into the conditional. Detection is tight: source matchesRADIX_SOURCE_PATTERN, name matches\ASlot(?:Primitive)?\z— noSlotMachine-collateral damage.cva() variant builder translation
const fooVariants = cva(base, { variants, defaultVariants })is recognized at lowering as anIR::CvaBinding. The backend hoistsFOO_BASE_CLASS,FOO_VARIANT_CLASSES,FOO_DEFAULT_VARIANTSas Ruby constants alongside the class; the use-siteclassName={cn(fooVariants({ variant }), className)}translates to a Ruby string-interpolation literal against those constants.defaultVariantsflow into the initializer kwarg defaults.compoundVariantsare preserved as a TODO comment (not translated in this cut).Call-site detection is AST-driven via a dedicated
IR::CvaCallSiteIR node — not regex over verbatim JS source. This naturally handles:cn(<cvaCall>, <className>)form,cn(<className>, <cvaCall>),cndirectclassName={<cvaCall>}form,{ variant: "default" },Variants-only const name.Multi-component files (
lower_allon 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_componentcleared on emit exit, and a few cosmetic concerns.Stress / showcase verification
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+ distinctdata-slotkinds — equivalent to the previously hand-patched build.Deferred / out of scope
compoundVariantstranslation — TODO sidecomment preserved verbatim; the cva binding type has acompound_sourcefield standing by for a follow-up.<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.