Migrates Pluma Button (and ButtonGroup) variant props for the
upcoming Button overhaul. The current secondary variant is being removed,
and the remaining variants are shifting down a tier:
| Before | After |
|---|---|
variant="primary" | variant="primary" (no-op) |
variant="secondary" | variant="secondary" (removed in a later release — see below) |
variant="tertiary" | variant="secondary" |
variant="subtle" | variant="tertiary" |
Buttons with no variant prop are left alone — the default has always been
(and remains) secondary.
There are two codemods — one for React (.tsx/.ts/.jsx/.js) and one
for Ember (.gts/.gjs/.hbs). Run whichever apply to your app.
Before running the codemod, set the unsafe_withMigratedButtonVariants
flag on PlumaProvider (or per-button) so the renamed variants render
the same as they did before:
The flag makes Pluma render variants as if the upcoming major release
(which removes the current secondary variant) had already shipped:
secondary renders with the current tertiary stylestertiary renders with the current subtle stylesThis also applies to <Button> invocations with no variant prop —
they default to secondary and therefore start rendering with
tertiary styles the moment the flag is turned on. That's intentional:
the default-variant look post-major is today's tertiary. Enabling
the flag is the one visible change; dropping it again at the major
release is a no-op.
Run the codemod with this flag enabled, and your app looks identical before and after the rename. When the breaking-change major eventually ships, drop the flag — your migrated source already uses the new names.
If you'd previously enabled unsafe_withSoftDeprecatedSecondaryVariant
(the earlier, narrower flag from
#1531), you can replace
it with unsafe_withMigratedButtonVariants. The new flag supersedes the
old one (it includes the secondary → tertiary mapping).
unsafe_withSoftDeprecatedSecondaryVariant so variant="secondary"
renders with tertiary styles. Verify the app still looks right.unsafe_withMigratedButtonVariants. Visually the app is unchanged so
far (you haven't migrated tertiary/subtle source yet).secondary removed,
tertiary→secondary rename, subtle→tertiary rename), update Pluma
and drop the flag from your PlumaProvider. Rendering doesn't
change.Run it through the Pluma CLI from inside your consuming app. Always start with a dry run to review the diff and the warning summary before applying:
Both frameworks accept --dry (no writes; per-file diff is printed inline)
and --force (bypass the re-run safety check — see below). The React path
also forwards standard jscodeshift flags, so -p prints transformed output.
React. Only JSX elements whose tag resolves (via the import in the same
file) to Button or ButtonGroup from @customerio/pluma-components/react
(or the bare @customerio/pluma-components entry). Aliased imports
(import { Button as MyButton } from …) are handled correctly. Any local
component called Button that wasn't imported from Pluma is left alone.
Inline string literals are always rewritten (variant="tertiary",
variant={'tertiary'}, variant={'tertiary' as const}). The codemod
also follows two additional shapes:
Result-position expressions — branches of a ?: ternary, the
right-hand side of ?? / || / &&, and recursive combinations:
Each tertiary / subtle literal that flows into a value position of
the expression is rewritten under the fixed map; non-literal branches
(identifiers, function calls, etc.) are left alone.
Local consts — a binding like
gets its initializer rewritten — provided the binding is a const,
isn't exported, and every reference in the file is a Pluma
<Button|ButtonGroup variant={…}> value. If the const flows somewhere
else (a prop on a non-Pluma component, a log call, etc.), the codemod
leaves it alone and emits a warning (see below). let bindings,
exported consts, and bindings in other files (imports, props) are
likewise skipped with a warning.
Ember. Only <PlumaButton …> and <PlumaButtonGroup …> element
nodes are touched. Inline literals — @variant="tertiary",
@variant='tertiary', @variant={{"tertiary"}} — are always rewritten.
In .gts / .gjs files the codemod additionally follows two kinds of
binding back into the surrounding JavaScript:
@variant={{this.X}}. The codemod
rewrites a matching get X() { return '…'; } getter or X = '…'
class field (including @tracked X = '…') provided every return
expression in the body / the field initializer reduces to a result-
position string literal under the same rules as the React side
(ternary branches, ?? / || / && sides, if/else returns), and
this.X is not read anywhere else in the file's JavaScript.@variant={{X}}. A matching
non-exported const X = '…' declaration is rewritten provided X
isn't read anywhere else in JavaScript. The initializer may itself
be a ternary / ?? / || fallback — literal branches are
rewritten.if / unless / or / and helpers in templates —
@variant={{if cond 'tertiary' 'subtle'}}, @variant={{or @x 'tertiary'}}. Result-position literals (the consequent/alternate
of if/unless, every positional arg of or/and) are rewritten
under the same fixed-map rule. Other helpers ({{my-helper 'tertiary'}}) are left alone with a warning.Anything that doesn't fit those shapes — @variant={{@arg}},
unknown helper calls, class members shared with non-Pluma JS readers,
deep this.X.Y paths — is left untouched and surfaces as a warning.
.hbs files don't have a JS context to consult, so template-only
references ({{this.x}}, {{x}}) are always warned; those need to
be migrated by hand in the corresponding .js/.ts component file.
Internally, the Ember codemod uses
@codemod-utils/ast-template
@codemod-utils/ast-template-tag
for the Glimmer side, and jscodeshift for parsing the JavaScript
portion of .gts/.gjs files. Rewrites are applied as byte-level
splices on the original source, so formatting is preserved byte-for-byte
outside the literal value itself — indentation, comments, quote style,
and whitespace are untouched.For larger codebases, the Ember script spreads files across
node:worker_threads (up to 8 workers, scaled to your CPU count). Runs
of fewer than 30 files stay in the main thread to skip the spawn
overhead. You'll see a Processing N files across K workers… line at
the start of the run.
Both subcommands refuse to run if they find
unsafe_withMigratedButtonVariants anywhere in the input — that's the
signal that the codemod has already been applied to this codebase. A
second run would cascade the renames (formerly-subtle buttons, now
tertiary, would silently shift to secondary), which is the wrong
style.
If you've set the flag for preview only and haven't actually applied
the rename yet (uncommon but possible), pass --force to bypass the
check. The dispatcher prints a Found … running anyway (--force)
notice when it does.
Both codemods print a summary at the end of the run listing every variant usage they recognized but chose not to migrate, with a file + line and a short explanation. Examples of what shows up here:
variant={getVariant()} — dynamic expression, can't determine valuevariant={importedFromElsewhere} — identifier resolves outside this
file; check the source moduleconst v = 'tertiary'; … log(v); <Button variant={v} /> — binding is
shared between Pluma and non-Pluma uses; rename only the Pluma path@variant={{@buttonVariant}} — caller-provided argument@variant={{my-helper 'tertiary'}} — unknown helper invocation@variant={{this.x}} (in .hbs) — class member lives in a sibling
.js/.ts file the codemod can't seeThere's also a partial-migration category for cases where some of an expression's branches were rewritten but others were opaque:
<Button variant={override ?? 'tertiary'} /> — 'tertiary' migrates
to 'secondary', but override could itself hold a variant the
codemod can't see{{if @cond @maybeVariant 'subtle'}} — 'subtle' migrates, but
@maybeVariant is opaqueget v() { return this.x ?? 'subtle'; } — same idea on a class
getter; the literal migrates, this.x is opaqueWhen partial migration happens, the codemod rewrites what it can and
emits a warning so you know to inspect the opaque branches. Silent
no-ops only happen when every branch is either a clean rewrite or a
literal that doesn't need migration (e.g.
cond ? 'primary' : 'secondary').
Run through this list before committing — it's the codemod's way of saying "I noticed these, you need to look."
secondary, any
remaining variant="secondary" usages in your codebase will
trigger TypeScript / ember-template-lint errors and can be
removed/replaced manually.There's a test suite under tests/ covering the React and Ember
transforms (renames, formatting preservation, dynamic-value skip, alias
handling, etc.). It ships as part of the @customerio/pluma-cli package.
After running pnpm install at the Pluma repo root, run the CLI's transform
tests from anywhere in the repo:
It uses Node's built-in test runner (node --test), so no extra
dependencies beyond what the CLI already needs.
These cases are recognized and reported in the warning summary, but not auto-rewritten:
<Button {...{ variant: 'tertiary' }} />. The inner object isn't
inspected.<Button variant={getX()}> (and any other expression that isn't a
literal, ternary, or logical-fallback). The codemod can't determine
what the call resolves to.<Button variant={importedVar}>
where importedVar is from another module. The codemod won't follow
the import.this.X is used as
a Pluma variant and read elsewhere (an event handler, a derived
getter, …), the codemod can't safely rewrite the declaration.PlumaButton — if your app
re-exports PlumaButton as <MyButton …>, the codemod won't match
the wrapper invocation. Rename to PlumaButton first, or migrate
those uses manually..hbs files referencing this.X — the class definition lives
in a sibling .js/.ts file the codemod doesn't read. Use the
warning's file/line to find each one.After running the codemod, you can also grep for any leftover
'tertiary' / 'subtle' / "tertiary" / "subtle" to catch
anything subtle the warnings might have missed.