Theme tokens rename codemod

What it does

Pluma 5.0 replaces the old theme-token names with the semantic layer — surfaces, text and mark roles, and per-scope colors. This codemod rewrites every reference to a renamed token, in both of the forms consumers use:

// before
borderColor: themeVars.color['border-base'],
// after
borderColor: themeVars.color['scope-neutral-subtle-border'],
/* before */
color: var(--pluma-color-border-base);
/* after */
color: var(--pluma-color-scope-neutral-subtle-border);

A subtle fill is authored per theme rather than as one token, so those reads are rewritten to the pair that replaces it — see Subtle fills.

An interaction state is not a token at all in 5.0 — it is a lightness shift or a translucent layer — so a token read inside a hover, pressed or selected context is rewritten to the recipe that replaces it rather than to the resting rung it collapsed into. See Interaction states.

Tokens whose 5.0 equivalent is a judgement call are rewritten to the closest match and flagged. Tokens with no equivalent are left exactly as they are and flagged. Both land in the "needs manual review" summary at the end of the run, with a note explaining what to do.

Seven classes of site also land in a machine-readable findings report — the work list to clear before you ship.

Running it

Run it through the Pluma CLI from inside your consuming app. Point it at source directories (./src, ./app) rather than a workspace root — the React half hands your paths to jscodeshift, which walks everything it is given:

pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename react ./src
pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename ember ./app ./lib

Both halves support a dry run, which writes nothing and prints a per-file diff plus the review summary:

pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename react --dry ./src
pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename ember --dry ./app

--force bypasses the re-run guard, in the rare case you know a path only looks migrated (part of it was renamed by hand, say). It prints a warning and runs. It never changes which files are transformed, and the run ledger still protects every site it recorded:

pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename react --force ./src

--bare-css-vars and --no-bare-css-vars force the bare custom properties pass on or off, overriding the auto-detection. The run prints which way it went either way, so check that line first if a stylesheet came back untouched.

The React half covers .tsx, .ts, .jsx, .js, .mts, .cts, .mjs and .cjs, then walks the same paths again for .css, .scss and .astro. The Ember half covers .gts, .gjs, .ts, .js, .mts, .cts, .mjs, .cjs, .hbs, .scss and .css. Both skip node_modules and .git wherever they appear, and dist, build, tmp and .cache where they sit directly under a path you named — that is the one place those names reliably mean build output. Deeper in the tree they are walked like any other directory, because a directory called build is real app source in plenty of checkouts. Point the codemod at such a directory and it is walked: you meant it.

A file the codemod can't parse is left untouched and reported, so a syntax error (or a file in a dialect it doesn't read) never disappears from the migration silently. The parser is chosen from the extension — TypeScript for .ts/.mts/.cts/.gts, TSX for the rest — so TS-only syntax like an angle-bracket cast reads correctly.

Running it again

A branch migrated to 5.0 rarely stays still: main keeps merging in code written against 4.x, often into files the branch already migrated. Run the codemod over the merged tree and it migrates the new code only:

pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename react ./src

That works because the first run left a record. Every site it wrote that a second pass would shift — the surface-base it wrote for navigation-background, the blue-600 it wrote for blue-500 — is in .pluma/codemod-ledger.json, keyed by the token and the code around it rather than by a line number, so it survives lines added above or below, a merge moving the block, and your formatter re-quoting and re-wrapping it. A later run leaves those sites exactly as they are and rewrites everything else, so a 4.x surface-base merged in next to a migrated one still becomes surface-content. The CLI README has the format and the trade-offs.

Two cases need a hand:

  • A tree migrated before the ledger existed. The ledger has no record of it, so the re-run guard still refuses, as it always has. Record the tree as it stands — on the branch, before merging main in — then merge and run as normal:

    pnx @customerio/pluma-cli@latest upgrade --codemod theme-tokens-rename react --baseline ./src

    --baseline changes no file. It lists every site it records, so check that none of them is still 4.x code: a recorded site is never migrated.

  • 5.0 code written by hand since the last run. It reads exactly like 4.x code still to migrate. In a file the ledger lists, the run goes ahead, names the file as changed since it was recorded, and migrates every site in it the ledger has no row for. So whenever 5.0 code has been written by hand, run --baseline again before the next merge of main, not just once.

  • A site the ledger can't tell from new code. When a file holds more identical copies of a recorded site than the ledger recorded — the same token on the same line in the same surroundings — one of them is new and nothing says which. None of them is rewritten; each lands in the findings report as a ledger-ambiguous row for you to decide, on every run until you migrate the new copies by hand and then, before any other run, record the file with --baseline <path>. The same goes for a site whose surroundings changed too much to be sure of.

What gets matched

Theme-vars reads. Any identifier bound by an import of themeVars or themeContractVars (aliases included) from @customerio/pluma-components/css or any of its subpaths (/css/theme-tokens, /css/components, …):

table
Written asRewritten as
themeVars.color['border-base']themeVars.color['scope-neutral-subtle-border']
themeVars.color["text-base"]themeVars.color["text-primary"]
themeVars['color']['text-base']themeVars['color']['text-primary']
themeVars.palette['grey_charcoal-500']themeVars.palette['gray-700']
themeVars.color['data-viz-categorical-1']themeVars.dataViz['categorical-1']

The original quote character is preserved, and bracket access stays bracket access. Dot access is left as written unless the new key can't be spelled as an identifier — every renamed token lands on a hyphenated key, so a dot read becomes a bracket read. When a token moves to a different category, as the data-viz colors do, the category is rewritten too.

Re-bound bases. A same-file const that holds the object or one of its categories is followed through:

const { color } = themeVars;
const radius = themeVars.radius;
color['border-base']; // rewritten

CSS custom properties. --pluma-color-*, --pluma-palette-* and --pluma-backdrop-* names — the categories come from the mapping table below, so they can't drift out of step with it. Matched whole (so --pluma-color-border-base-extra is not a match for --pluma-color-border-base), inside:

  • string literals and template literals in .ts/.tsx/.js/.jsx/.gts/.gjs
  • anywhere in .css, .scss, .astro and .hbs
  • <template> bodies in .gts/.gjs

Nothing else is touched — a string that merely contains the token text ('color.border-base', an object key, a comment) is left alone.

.astro files go through the same plain-text pass a stylesheet does. Nothing parses them; the pass matches custom property names, so a <style> block, an inline style attribute and a name in the frontmatter are all covered, and everything else in the file survives byte for byte.

The same kind decides the outcome here as for a themeVars read: rename and review names are both swapped for their 5.0 custom property, a review one warning as it goes; a fill target becomes the two-half var(); and a manual one is reported untouched. A raw custom property gets no help from the typecheck — a name out of a family 5.0 removed is undefined, which invalidates the whole declaration, and a name 5.0 still defines but re-shaded paints a different colour — so leaving one behind is the silent choice, not the safe one. Where a re-shaded name carries the 4.x colour as a literal fallback (var(--pluma-palette-blue-400, #0a95ff)), the fallback moves with it.

Bare custom properties (@customerio/pluma-css)

@customerio/pluma-components/css namespaces its custom properties (--pluma-color-surface-base). @customerio/pluma-css ships the same token ids without the prefix (--color-surface-base), and 5.0 renamed every one of them. So the pass above runs a second time over the same text in the bare spelling, reading the same mapping table — the notes and the subtle-fill recipes apply unchanged, because the id either side of the prefix is identical.

The re-run guard has to be told which spelling to expect. It recognises an already-migrated file by the 5.0-only names in it, and a site whose reads are entirely bare leaves no --pluma- name behind for it to find. So the bare pass is armed before the pre-flight scans, and the guard then looks for whichever spelling this run would rewrite: both, where the pass armed; only the prefixed one, where it did not — a --color-* a project owns itself must never refuse that project's first run.

The bare form is not a Pluma namespace, though. A Tailwind @theme block, a design-system shim, or an app's own :root will have --color-* names of its own. Three rules keep the pass off everything that isn't Pluma:

1. A bare name is rewritten only where it is read. The name has to be the argument of a var(). A declaration's left-hand side is never renamed — renaming one would rename every utility class hanging off it, in files this codemod never opens:

@theme inline {
	/* before */
	--color-surface-accent: var(--color-surface-accent);
	/* after — the registration keeps its name, the read becomes the 5.0 token */
	--color-surface-accent: var(--color-scope-accent-solid-bg);
}

2. The pass is off unless the package is actually in play. It arms when a scanned file pulls in @customerio/pluma-css — @use / @import / @forward, import, from, require() — or when the nearest package.json above your paths depends on it. --bare-css-vars and --no-bare-css-vars override the answer. Either way the run prints which it chose and why, on its first line:

Bare `--color-*` / `--palette-*` names: ON — src/styles/global.css:13 pulls in `@customerio/pluma-css`.
Bare `--color-*` / `--palette-*` names: off — nothing under these paths pulls in
`@customerio/pluma-css` and the nearest package.json doesn't depend on it, so a bare
`--color-*` here is this codebase's own. Pass --bare-css-vars to run it anyway.

3. A declaration whose name is also a Pluma id is reported, never rewritten. --color-surface-accent: var(--color-surface-accent) declares a name this codebase owns and reads a Pluma token of the same name, and so does every other var(--color-surface-accent) in the codebase — the codemod rewrote those as Pluma's, and only you can say which ones meant the declaration. Every collision lands in the findings report as a bare-name-collision row. Work those first.

A file that pulls @customerio/pluma-css in also gets one bare-token-source row against the import itself, armed or not, so the signal never depends on what the scan happened to match.

Run css-token-rename first

@customerio/pluma-css renamed a handful of names in its own 1.0.0, which shipped with @customerio/pluma-components 3.0.0 — one release before anything in this codemod's table:

  • the banner and snackbar error vocabulary becoming critical
  • font-size-s / -m / -l becoming font-size-sm / -md / -lg (the extended sizes xs, xl, xxs, xxl, xxxl are unchanged)
  • the product-ps text style becoming product-psm

A consumer pinned to ^0.5.0 is still on the old names. The ones 1.0.0 renamed softly kept their ids, so this table still maps them; the rest lost their ids at 3.0 and this table never had them. Run the 3.0.0 css-token-rename codemod over the same paths first, then this one — it leaves a tidier result either way. If this run meets one of the names it cannot map it says so, name by name, in the review summary.

The 3.0.0 text-size-shorthand-rename codemod covers the prop side of the same release (size="s" → size="sm"). css-token-rename covers the raw custom properties.

Removed exports and packages

defaultTheme. The legacy palette theme is gone from @customerio/pluma-components/css; lightTheme succeeds it. An import of defaultTheme from that entry point (or any of its subpaths) is pointed at lightTheme, and every read of the binding in the same file follows:

// before
import { defaultTheme } from '@customerio/pluma-components/css';
export const themeStore = atom<PlumaTheme | null>(defaultTheme);
// after
import { lightTheme } from '@customerio/pluma-components/css';
export const themeStore = atom<PlumaTheme | null>(lightTheme);

An alias keeps its local name (defaultTheme as theme becomes lightTheme as theme). When the file already imports lightTheme, the defaultTheme specifier is dropped and the reads point at the existing name instead — which can collapse two entries of a theme map onto one key, so every rewrite is listed in the review summary.

lightTheme, newTheme and darkTheme are unaffected. darkerTheme was already removed in pluma-components 4.0.0; use darkTheme.

pipelinesTheme. The aqua brand overlay is gone from @customerio/pluma-components/css as well, and unlike defaultTheme it has no successor to point at, so nothing is rewritten. 5.0 ships lightTheme and darkTheme; a site that needs the aqua accent sets the color.scope-accent-* custom properties on a wrapper element. Those reach every Pluma component inside that wrapper, with nothing to set per component.

Removed token packages. @customerio/pluma-js, @customerio/pluma-sass and @customerio/pluma-tailwind no longer exist. There is no drop-in replacement, so nothing is rewritten: any import, export … from, require() — or @use / @import in a .scss/.css file — that names one of them is reported for a hand fix. Token values now come from @customerio/tokens/js (a flat tokens object keyed color-… / palette-… with the new names) and CSS variables from @customerio/pluma-components/css.

@customerio/pluma-css is not one of them — it still ships. Every custom property in it was renamed by 5.0, though, so an import of it is reported as a source of renamed names, and the bare custom properties pass is what rewrites the reads.

Subtle fills

How a subtle fill is authored is each theme's own choice, and the two themes choose differently. Light ships a finished color — the hue's lightest palette step, in color.scope-<name>-subtle-bg. Dark ships the opaque intent color instead — color.scope-<name>-subtle-bgMix — and the fill is mixed at the element that paints it. The mix has to happen there: a var() inside a custom property is substituted where that property is declared, so a fill resolved once on the theme root could never see a percentage or an intent color redefined further down the tree.

Every theme ships both ids, and the half it did not author ships as the CSS keyword initial — the guaranteed-invalid value — so one declaration reading the flat half with the mix as its fallback lets the active theme decide, with no theme check at the call site.

The mapping tables name the flat half (color.scope-<name>-subtle-bg) as the target, because that is the id the migrated read resolves to first. A fill entry rewrites to the pair rather than to that one token, in the form the file is written in. In JS/TS, that is the fillScope helper, imported once per file (merged into an existing import of @customerio/pluma-components/css/utils when there is one):

// before
import { themeVars } from '@customerio/pluma-components/css';
background: themeVars.color['surface-active'],
// after
import { themeVars } from '@customerio/pluma-components/css';
import { fillScope } from '@customerio/pluma-components/css/utils';
background: fillScope('neutral', 'subtle'),

In a stylesheet or a template, it is the same pair written out:

/* before */
background: var(--pluma-color-surface-active);
/* after */
background: var(
	--pluma-color-scope-neutral-subtle-bg,
	color-mix(
		in oklab,
		var(--pluma-color-scope-neutral-subtle-bgMix) calc(var(--pluma-colorMix-fillSubtle-percentage) * 1%),
		var(--pluma-colorMix-fillSubtle-withColor)
	)
);

To change how strong the mixed half is, set themeVars.colorMix['fillSubtle-percentage'] on the element rather than reaching for a different token.

A recipe is a value, not a name, so the raw-custom-property rewrite is narrower than the plain rename one: it only fires where the whole read is being replaced — the token is the sole argument of a var() (no fallback), and that var() is the entire declaration value, or the entire contents of a string literal or template chunk. Anything else — a fallback, a shorthand like 1px solid var(…), a list, a Sass expression — is left alone and reported with the recipe in the note, to paste in by hand. Every fill rewrite is reported either way.

Interaction states

4.x had a token per state: surface-base-hover, surface-subtle-hover, surface-active, border-base-hover, text-accent-hover. 5.0 has none of them. A state is one of two levers — an opaque colour moves its own OKLCH lightness by the theme's shift, or a neutral element mixes a semi-transparent layer into its own background — and both are built by helpers in @customerio/pluma-components/css/utils.

The rename table can only point each old state token at the resting rung it collapsed into. Inside an interaction selector that paints the colour the element already rests at, so the state reads as no state at all. The codemod therefore looks at where a declaration sits before it rewrites it.

What counts as an interaction context

table
EvidenceExamples
Pseudo-class:hover, :active, :focus, :focus-visible, :focus-within
State attribute[aria-selected], [aria-pressed], [aria-current], [data-selected]
A data-state that names a state[data-state="selected"], "checked", "on", "active", "pressed"
State class.is-active, .active, .selected, .is-selected, .highlighted
vanilla-extract nestinga selectors: key or a nested '&:hover' key
Recipe variantvariants: { state: { hover: … } }
A name carrying the statebackgroundColorHover, _hoverBg, export const rowPressed

A selector, the declaration's own key and a recipe variant key are strong evidence and stand on their own. An enclosing export or debug id is weak — a style called activeRow names the element, not the declaration — so it only counts when the token being read is itself a 4.x state token. :not(:hover) is not a hover.

data-state is read by value, never as a bare attribute: Radix and Ark spell the resting appearance data-state="closed" / "inactive", so anything but the five values above means no state and the declaration takes the plain rename.

What it rewrites

table
SiteBecomes
a background on a neutral plane, the neutral subtle fill or the overlay tintlayerHover() / layerPressed()
the same, on an element with a resting background of its ownlayerHover(<the resting declaration>) / layerPressed(…)
a hovered border or text colourshiftHoverStroke(<the resting token>)

A backgroundImage read inside a state is reported rather than rewritten: 5.0 paints every state on backgroundColor, and moving the declaration there needs the element's background, which a backgroundImage read doesn't give. The note names the form to write, backgroundColor: layerHover(bg) (or layerHover() when the element has none). A state on something inside the element, such as '&:hover .icon', takes the plain form, since it doesn't sit on the element's resting background.

// before — the codemod's own output, one release ago
globalStyle(`${cardLink}:hover ${card}`, { backgroundColor: fillScope('neutral', 'subtle') });
globalStyle(`${cardLink}:active ${card}`, { backgroundColor: themeVars.color['surface-content'] });

// after
globalStyle(`${cardLink}:hover ${card}`, { backgroundColor: layerHover() });
globalStyle(`${cardLink}:active ${card}`, { backgroundColor: layerPressed() });

The import is added for you, merged into an existing @customerio/pluma-components/css/utils import where there is one.

A .scss, .css or .hbs file cannot import a helper, so it gets the CSS the helper emits:

&:hover {
	/* was: color: var(--pluma-color-text-accent-hover); */
	color: oklch(from var(--pluma-color-scope-action-text) calc(l + var(--pluma-interaction-text-hover-shift)) c h);
}

What it refuses to rewrite

Two shapes need a look at the element, and guessing either one ships a wrong colour that type-checks:

  • A solid fill. shiftHover() / shiftPressed() move the fill's own lightness; layerHover() / layerPressed() layer over whatever is behind it. Which is right depends on whether the element paints that fill or sits on it.
  • A hue-tinted state. A critical or caution control's interaction may be meant to read in the hue — layerHover(bg, themeVars.color['scope-critical-text']) — rather than as the neutral layer.

In JavaScript those sites are left exactly as they are, 4.x token and all, and land in the findings report as "action": "fix-by-hand". Leaving the old key in place is deliberate: the 5.0 themeVars type does not admit it, so each one is a type error pointing at the line, which is a louder work list than a plausible wrong colour.

A stylesheet has no typecheck, and an undefined --pluma-* name is not an error but an invalid declaration that quietly disappears — the same silent failure this change exists to stop. So in .scss, .css and .hbs the name is still migrated where a replacement exists, the row reads "action": "renamed-only", and a warning goes to the console during the run alongside the report. The state is flat until you fix it either way; only the signal differs.

A resting surface-subtle, a resting surface-active (a progress track, a code chip, a zebra row) and a site already on the right recipe are all left alone with no finding at all.

Collapsed planes

4.x had two neutral planes. color.surface-base and color.background-default both map to color.surface-content, which means a hover that switched an element from the page plane to the card plane is a plain rename as far as the table is concerned — and there is no *-hover token anywhere in the diff for a note to hang on. The codemod finds these structurally instead: after rewriting, a declaration inside an interaction context that resolves to the same token as the element's resting declaration for the same property is reported.

The same collapse lands on resting page planes. color.surface-content resolves to exactly surface-block in both themes, so a body, an app shell or a route wrapper rewritten this way paints the same colour as the panels on it and they stop reading as a layer above the page. The page plane is color.surface-base. The codemod cannot know which elements are page planes, so a surface-base / background-default read on an html, body, :root or #app selector — or in a style whose name carries page, app, root, shell, scaffold, layout, viewport or canvas — is reported for you to check.

Overlay bases

A subtle fill is defined relative to the surface it sits on — near-canvas by construction — where the 4.x token it replaces was a free-standing mid-tone. Mixing a near-canvas colour down to 12-15% leaves nothing: the workflow builder's drag-to-select marquee went from 1.12 : 1 to 1.02 : 1 against the light canvas that way.

So a mapped token read as an argument of a color-mix(), rgba() or hsla() at 50% or less is treated as a layer's tint rather than as a fill, and mapped to the scope's -text step — the tint layerHover(bg, tint) / layerPressed(bg, tint) take, and the one step whose lightness sits far enough from the content plane for a low alpha to land:

// before
backgroundColor: `color-mix(in srgb, ${themeVars.color['surface-accent-subtle']} 15%, transparent)`,
// after
backgroundColor: `color-mix(in srgb, ${themeVars.color['scope-accent-text']} 15%, transparent)`,

The alpha is left as it was and the site is reported, because the right alpha is a measurement against both canvases, not a rename.

The text step is only right when it is closer than the row's own target to the 4.x token's lightness offset from its canvas. A near-canvas 4.x gray such as surface-active (#ededed on white, #333333 on #212121) has a like-for-like 5.0 fill, and the text step at the same alpha paints a dark veil; a 4.x solid fill such as surface-accent has a like-for-like scope-accent-solid-bg. Rows like these keep their own target as the base, and the site is still reported:

// before
backgroundColor: `color-mix(in srgb, ${themeVars.color['surface-active']} 40%, transparent)`,
// after
backgroundColor: `color-mix(in srgb, ${fillScope('neutral', 'subtle')} 40%, transparent)`,

A row whose target is a translucent overlay, such as surface-subtle -> overlay-tint, can't keep its target inside a mix, because mixing a layer down again leaves almost nothing. When the opaque neutral subtle fill keeps the 4.x offset, the mix takes that fill (fillScope('neutral', 'subtle')) instead. An overlay row with no close fill, such as the black backdrop, is left for a human.

lib/mix-bases.mjs lists those rows. It is generated by scripts/generate-mix-bases.mjs, which compares each 4.x value's lightness offset from its canvas with the offsets of the row's target and of the text step on both themes; regenerate it when a scoped row's target changes.

Palette shifts

A palette rename can move lightness and chroma with no semantic signal at all. palette.purple-500 becomes palette.violet-600, which is 0.005 of lightness away but 0.036 of chroma; palette.orange-400 becomes palette.orange-500, 0.018 on each axis. A raw themeVars.palette read is theme-invariant, so the whole move lands on one plane — that pair re-shaded one app's gradient text on the dark plane, with types, lint and the codemod's own notes all silent.

Every palette rename whose 4.x and 5.0 values are at least 0.02 apart in OKLCH lightness, or 0.02 apart overall in OKLab distance, is reported with both hex values and the deltas. 0.02 is half the theme's own interaction.hover-shift (0.04) — the smallest colour move the design system treats as a visible state change. The distance half of the test is what catches a rename that moved chroma instead of lightness, which is the case above.

The table lives in lib/palette-shifts.mjs and is generated by scripts/generate-palette-shifts.mjs, which reads the 4.x primitives off origin/main and the 5.0 palette off packages/tokens/src/foundation/colors.tokens.json. Regenerate it whenever the palette or the rename table moves:

node packages/cli/src/codemods/5.0.0/theme-tokens-rename/scripts/generate-palette-shifts.mjs

The findings report

The sections above all feed one JSON file, written at .pluma/codemod-report.json at the repository root unless --report <path> says otherwise. Both halves of a React run write into it. See the CLI README for the file's shape; the classes this codemod emits are:

table
classWhat it means
interaction-stateA state that would have flattened onto a resting fill. Rewritten to the 5.0 recipe, or left alone when the recipe needed a human.
plane-collapseA state that resolves to the colour the element already rests at, or a page plane rewritten as a block plane. Always fix-by-hand: the name is migrated so the file stays valid, but only you can say which plane the element is.
overlay-baseA token read as the base of a low-alpha layer. Rewritten to the scope's text step, or kept on its own target where that matches the 4.x lightness better; re-measure the alpha.
palette-shiftA palette rename that also moved the colour. Carries before / after hex, deltaLightness and deltaChroma. rewritten where the name was swapped, fix-by-hand where it wasn't.
bare-token-sourceA file that pulls in @customerio/pluma-css, whose every custom property 5.0 renamed. One row per import.
bare-name-collisionA bare custom property declared under a name that is also a Pluma token id. Always fix-by-hand.
token-revivalA 5.0-only name the file already reads, where it resolves to nothing. Written by the re-run guard, not by a transform, so the source is untouched and there is no diff to review. Carries nearMiss when the name is an edit or two off a real 4.x one.
ledger-ambiguousA site a re-run would change that the run ledger can't tell apart from a migrated one. Always fix-by-hand: the source is untouched, and only you can say whether it is new 4.x code.

Start with the fix-by-hand rows — the colour at those sites is still yours to pick — then the renamed-only ones, which compile but are still flat:

jq -r '.findings[] | select(.action != "rewritten") | "(.file):(.line)  (.action)  (.class)  (.reason)"' .pluma/codemod-report.json

That is the hand-work list, not the whole report. rewritten means the codemod made the call and the result wants your eyes — every palette-shift row whose name was swapped is one, because the name moving is the colour moving; a shift with no drop-in name stays fix-by-hand above:

jq -r '.findings[] | select(.action == "rewritten") | "(.file):(.line)  (.class)  (.reason)"' .pluma/codemod-report.json

Token mappings

Pluma 5.0 replaces the flat color.* token list with a semantic color layer, and the vocabulary changed along with the names.

  • Surface planes. Neutral backgrounds are planes rather than shades: surface-base is the page, surface-content the content area on it, surface-block a block inside that, surface-floating a menu or popover, surface-recessed a well, surface-track a channel, and surface-inverse the dark panel. color.surface-base kept its name but changed meaning: it is the page plane now, so the old white background is color.surface-content.
  • Scopes with solid and subtle rungs. Every hue and semantic name (neutral, accent, action, destructive, information, success, caution, critical, feature, plus each palette hue) exposes a bare text / border / mark for colored content, solid-bg / solid-text / solid-mark for the saturated fill and what sits on it, and subtle-bg / subtle-bgMix / subtle-text / subtle-border / subtle-mark for the tinted fill and what sits on it. Note the shift: subtle-text is text on the subtle background, not a lighter version of the text. And the two subtle-fill ids are one slot the themes fill in differently, read together — see Subtle fills.
  • Overlays instead of hover and pressed tokens. There are no *-hover or *-active colors. Paint backgroundColor: layerHover(bg) or layerPressed(bg) on anything neutral, with its own background as bg, move a fill's own color with shiftHover() / shiftPressed(), or pull a text or border color with shiftHoverStroke(). All of them come from @customerio/pluma-components/css/utils.
  • Opacity instead of disabled tokens. There are no *-disabled colors either. Dim the whole control with opacity.disabled, and use color.text-disabled / color.mark-disabled for content that describes a disabled control.
  • Neutral text and icons collapse. text-base, text-bold and text-active all resolve to color.text-primary; text-subtle, text-minimal and text-placeholder to color.text-secondary. Neutral icons read color.mark-primary / color.mark-secondary.

Kind says what the codemod does. rename rewrites silently. review rewrites and warns, because the meaning shifted or the color moved far enough to look at. manual leaves the code alone and reports it. fill rewrites to the subtle fill recipe — fillScope(…), or the two-half var() in a stylesheet — and always warns, because the shape of the code changes, not just a name.

Surfaces

table
Old keyNew keyKindWhy
color.surface-basecolor.surface-contentreviewsurface-base kept its name but is now the page plane behind content
color.background-defaultcolor.surface-contentreviewthe default background was white
color.surface-base-hovercolor.surface-contentreviewhover is a layer now, not a token
color.surface-base-disabled—manualthe themes authored a neutral gray fill, not the page plane; no disabled surface
color.surface-subtlecolor.overlay-tintreviewa faint region tint; use the neutral subtle fill if this was a filled chip
color.surface-subtle-hovercolor.scope-neutral-subtle-bgfillhover is a layer now, not a token
color.surface-subtle-disabledcolor.scope-neutral-subtle-bgfilldisabled is opacity now, not a color
color.surface-minimalcolor.overlay-tintreviewcollapses onto one tint with surface-subtle; use the neutral subtle fill if this was a filled chip
color.surface-activecolor.scope-neutral-subtle-bgfillpressed is a layer now, not a token
color.surface-active-hovercolor.scope-neutral-subtle-bgfillhover is a layer now, not a token
color.surface-active-disabledcolor.scope-neutral-subtle-bgfilldisabled is opacity now, not a color
color.surface-boldcolor.surface-trackreviewthe strong neutral fill is now the track plane
color.surface-bold-hovercolor.surface-trackreviewhover is a layer now, not a token
color.surface-overlaycolor.surface-floatingreviewmenus, popovers and tooltips are surface-floating
color.surface-overlay-hovercolor.surface-floatingreviewhover is a layer now, not a token
color.surface-overlay-activecolor.overlay-pressedreviewpressed is a layer now, not a token
color.surface-overlay-active-hovercolor.overlay-pressedreviewhover is a layer now, not a token
color.surface-invertcolor.surface-inversereviewthe inverse plane is neutral now; the teal is scope-action-solid-bg
color.surface-invert-hovercolor.surface-inversereviewhover is a layer now, not a token
color.surface-invert-boldcolor.surface-inversereviewcollapses onto one rung
color.surface-invert-bold-hovercolor.surface-inversereviewhover is a layer now, not a token
color.surface-invert-subtlecolor.surface-inverse-raisedreviewthe step off the inverse plane is neutral now, not teal
color.backdrop-basecolor.overlay-backdropreviewoverlay-backdrop is the finished translucent backdrop, not the opaque color you used to mix yourself
backdrop.mix-amount—manualthe backdrop mix is baked into color.overlay-backdrop
color.surface-workflow-data—manualworkflow surfaces are not part of the semantic layer
color.surface-workflow-delays—manualworkflow surfaces are not part of the semantic layer
color.surface-workflow-flowcontrol—manualworkflow surfaces are not part of the semantic layer
color.surface-workflow-messages—manualworkflow surfaces are not part of the semantic layer

Scoped surfaces

table
Old keyNew keyKindWhy
color.surface-accentcolor.scope-accent-solid-bgreviewteal in 5.0; defaultTheme authored it blue
color.surface-accent-hovercolor.scope-accent-solid-bgreviewhover is a layer now, not a token
color.surface-accent-disabled—manualthe themes authored a neutral gray; no disabled surface
color.surface-accent-subtlecolor.scope-accent-subtle-bgfillthe subtle rung is a much lighter tint than the old subtle surface
color.surface-accent-minimalcolor.scope-accent-subtle-bgfillcollapses onto one rung
color.surface-cautioncolor.scope-caution-solid-bgrenamedirect counterpart
color.surface-caution-hovercolor.scope-caution-solid-bgreviewhover is a layer now, not a token
color.surface-caution-disabledcolor.scope-caution-solid-bgreviewdisabled is opacity now, not a color
color.surface-caution-boldcolor.scope-caution-solid-bgreviewcollapses onto one rung
color.surface-caution-subtlecolor.scope-caution-subtle-bgfillthe subtle rung is a much lighter tint than the old subtle surface
color.surface-caution-subtle-hovercolor.scope-caution-subtle-bgfillhover is a layer now, not a token
color.surface-caution-minimalcolor.scope-caution-subtle-bgfillcollapses onto one rung
color.surface-criticalcolor.scope-critical-solid-bgrenamedirect counterpart
color.surface-critical-hovercolor.scope-critical-solid-bgreviewhover is a layer now, not a token
color.surface-critical-disabledcolor.scope-critical-solid-bgreviewdisabled is opacity now, not a color
color.surface-critical-boldcolor.scope-critical-solid-bgreviewcollapses onto one rung
color.surface-critical-subtlecolor.scope-critical-subtle-bgfillthe subtle rung is a much lighter tint than the old subtle surface
color.surface-critical-subtle-hovercolor.scope-critical-subtle-bgfillhover is a layer now, not a token
color.surface-critical-minimalcolor.scope-critical-subtle-bgfillcollapses onto one rung
color.surface-informationcolor.scope-information-solid-bgreview4.x flipped polarity per theme; the 5.0 fill does not
color.surface-information-hovercolor.scope-information-solid-bgreviewhover is a layer now, not a token
color.surface-information-disabledcolor.scope-information-solid-bgreviewdisabled is opacity now, not a color
color.surface-information-boldcolor.scope-information-solid-bgreviewcollapses onto one rung
color.surface-information-subtlecolor.scope-information-subtle-bgfillthe subtle rung is a much lighter tint than the old subtle surface
color.surface-information-subtle-hovercolor.scope-information-subtle-bgfillhover is a layer now, not a token
color.surface-information-minimalcolor.scope-information-subtle-bgfillcollapses onto one rung
color.surface-successcolor.scope-success-solid-bgrenamedirect counterpart
color.surface-success-hovercolor.scope-success-solid-bgreviewhover is a layer now, not a token
color.surface-success-disabledcolor.scope-success-solid-bgreviewdisabled is opacity now, not a color
color.surface-success-boldcolor.scope-success-solid-bgreviewcollapses onto one rung
color.surface-success-subtlecolor.scope-success-subtle-bgfillthe subtle rung is a much lighter tint than the old subtle surface
color.surface-success-subtle-hovercolor.scope-success-subtle-bgfillhover is a layer now, not a token
color.surface-success-minimalcolor.scope-success-subtle-bgfillcollapses onto one rung

Borders

table
Old keyNew keyKindWhy
color.border-basecolor.scope-neutral-subtle-borderrenamethe same hairline in light; the old card edge in dark, which field edges now share
color.border-base-hovercolor.scope-neutral-subtle-borderreviewhovered borders are a mix now
color.border-minimalcolor.scope-neutral-subtle-borderreviewcollapses onto the one subtle rung: the old minimal edge, which border-base now shares
color.border-minimal-hovercolor.scope-neutral-subtle-borderreviewhovered borders are a mix now
color.border-dropdowncolor.scope-neutral-subtle-borderrenamethe same hairline in light; the old card edge in dark, which field edges now share
color.border-boldcolor.scope-neutral-borderreviewa step lighter than the old border-bold in light; a shade darker in dark
color.border-bold-hovercolor.scope-neutral-borderreviewhovered borders are a mix now
color.border-activecolor.scope-neutral-borderreviewcollapses onto one rung: lighter than the old border-active in light, darker than it in dark
color.border-active-hovercolor.scope-neutral-borderreviewhovered borders are a mix now
color.border-focuscolor.scope-focus-borderreviewblue in 5.0; defaultTheme authored it a pale teal
color.border-invert—manualno contrasting border rung by design; rebuild with a Pluma pattern
color.border-accentcolor.scope-accent-borderreviewthe accent border rung is a step lighter than the old border-accent
color.border-accent-hovercolor.scope-accent-borderreviewhovered borders are a mix now
color.border-accent-disabled—manualthe themes authored a neutral gray; no disabled border
color.border-accent-subtle—manualone id did several jobs; no 5.0 border inverts with the theme
color.border-accent-subtle-hover—manualhovered borders are a mix now, and the resting id has no single successor
color.border-accent-minimalcolor.scope-neutral-subtle-borderreviewthe themes authored a neutral gray hairline, not an accent
color.border-cautioncolor.scope-caution-borderreview4.x flipped polarity per theme; dark quietens by 0.270
color.border-caution-hovercolor.scope-caution-borderreviewhovered borders are a mix now
color.border-caution-disabledcolor.scope-caution-borderreviewdisabled is opacity now, not a color
color.border-caution-subtlecolor.scope-caution-subtle-borderrenamedirect counterpart
color.border-caution-subtle-hovercolor.scope-caution-subtle-borderreviewhovered borders are a mix now
color.border-caution-minimalcolor.scope-caution-subtle-borderreviewcollapses onto one rung
color.border-criticalcolor.scope-critical-borderrenamedirect counterpart
color.border-critical-hovercolor.scope-critical-borderreviewhovered borders are a mix now
color.border-critical-disabledcolor.scope-critical-borderreviewdisabled is opacity now, not a color
color.border-critical-subtlecolor.scope-critical-subtle-borderrenamedirect counterpart
color.border-critical-subtle-hovercolor.scope-critical-subtle-borderreviewhovered borders are a mix now
color.border-critical-minimalcolor.scope-critical-subtle-borderreviewcollapses onto one rung
color.border-informationcolor.scope-information-borderreview4.x flipped polarity per theme; both planes move
color.border-information-hovercolor.scope-information-borderreviewhovered borders are a mix now
color.border-information-disabledcolor.scope-information-borderreviewdisabled is opacity now, not a color
color.border-information-subtlecolor.scope-information-subtle-borderrenamedirect counterpart
color.border-information-subtle-hovercolor.scope-information-subtle-borderreviewhovered borders are a mix now
color.border-information-minimalcolor.scope-information-subtle-borderreviewcollapses onto one rung
color.border-successcolor.scope-success-borderreview4.x flipped polarity per theme; dark quietens by 0.248
color.border-success-hovercolor.scope-success-borderreviewhovered borders are a mix now
color.border-success-disabledcolor.scope-success-borderreviewdisabled is opacity now, not a color
color.border-success-subtlecolor.scope-success-subtle-borderrenamedirect counterpart
color.border-success-subtle-hovercolor.scope-success-subtle-borderreviewhovered borders are a mix now
color.border-success-minimalcolor.scope-success-subtle-borderreviewcollapses onto one rung
color.border-feature-border-informationcolor.scope-feature-borderreview4.x flipped polarity per theme; light lightens by 0.213
color.border-feature-border-information-hovercolor.scope-feature-borderreviewhovered borders are a mix now
color.border-feature-border-information-disabledcolor.scope-feature-borderreviewdisabled is opacity now, not a color
color.border-feature-border-information-subtlecolor.scope-feature-subtle-borderrenamedirect counterpart
color.border-feature-border-information-subtle-hovercolor.scope-feature-subtle-borderreviewhovered borders are a mix now
color.border-feature-border-information-minimalcolor.scope-feature-subtle-borderreviewcollapses onto one rung

Text

table
Old keyNew keyKindWhy
color.text-basecolor.text-primaryrenamedirect counterpart
color.text-base-hovercolor.text-primaryreviewhovered text is a mix now
color.text-base-disabledcolor.text-disabledrenamedirect counterpart
color.text-boldcolor.text-primaryreviewbase, bold and active all resolve to text-primary
color.text-bold-hovercolor.text-primaryreviewhovered text is a mix now
color.text-activecolor.text-primaryreviewbase, bold and active all resolve to text-primary
color.text-subtlecolor.text-secondaryrenamedirect counterpart
color.text-placeholdercolor.text-secondaryreviewplaceholders darken to the secondary text color
color.text-minimalcolor.text-secondaryreviewcollapses onto one rung
color.text-invertcolor.text-inverserenamedirect counterpart
color.text-whitepalette.whiterenamedirect counterpart
color.text-blackpalette.blackrenamedirect counterpart
color.text-on-overlaycolor.text-primaryrenamedirect counterpart
color.text-on-boldcolor.text-primaryreviewcontent on a neutral fill reads text-primary
color.text-on-navigationcolor.text-primaryreviewnavigation text is ordinary primary text now
color.text-on-invertcolor.text-inversereviewcontent on the inverse plane reads text-inverse
color.text-on-invert-boldcolor.text-inversereviewcontent on the inverse plane reads text-inverse
color.text-on-invert-subtlecolor.text-inversereviewcontent on the raised inverse plane reads text-inverse
color.text-accentcolor.scope-action-textreviewaccent text now reads as the action scope
color.text-accent-hovercolor.scope-action-textreviewhovered text is a mix now
color.text-accent-disabledcolor.scope-action-textreviewdisabled is opacity now, not a color
color.text-accent-boldcolor.scope-action-textreviewcollapses onto one rung
color.text-on-accentcolor.scope-accent-solid-textrenamedirect counterpart
color.text-cautioncolor.scope-caution-textrenamedirect counterpart
color.text-caution-hovercolor.scope-caution-textreviewhovered text is a mix now
color.text-caution-disabledcolor.scope-caution-textreviewdisabled is opacity now, not a color
color.text-caution-boldcolor.scope-caution-textreviewcollapses onto one rung
color.text-caution-subtlecolor.scope-caution-textreviewscope-caution-subtle-text is text ON the subtle background, not a lighter caution text
color.text-on-cautioncolor.scope-caution-solid-textreviewa near-black with a yellow cast; every plane moves
color.text-on-caution-boldcolor.scope-caution-solid-textreviewcollapses onto one rung
color.text-criticalcolor.scope-critical-textrenamedirect counterpart
color.text-critical-hovercolor.scope-critical-textreviewhovered text is a mix now
color.text-critical-disabledcolor.scope-critical-textreviewdisabled is opacity now, not a color
color.text-critical-boldcolor.scope-critical-textreviewcollapses onto one rung
color.text-critical-subtlecolor.scope-critical-textreviewscope-critical-subtle-text is text ON the subtle background, not a lighter critical text
color.text-on-criticalcolor.scope-critical-solid-textrenamedirect counterpart
color.text-on-critical-boldcolor.scope-critical-solid-textreviewcollapses onto one rung
color.text-informationcolor.scope-information-textrenamedirect counterpart
color.text-information-hovercolor.scope-information-textreviewhovered text is a mix now
color.text-information-disabledcolor.scope-information-textreviewdisabled is opacity now, not a color
color.text-information-boldcolor.scope-information-textreviewcollapses onto one rung
color.text-information-subtlecolor.scope-information-textreviewscope-information-subtle-text is text ON the subtle background, not a lighter information text
color.text-on-informationcolor.scope-information-solid-textreviewdark authored near-black ink on a pale fill; the value flips on dark
color.text-on-information-boldcolor.scope-information-solid-textreviewcollapses onto one rung
color.text-successcolor.scope-success-textrenamedirect counterpart
color.text-success-hovercolor.scope-success-textreviewhovered text is a mix now
color.text-success-disabledcolor.scope-success-textreviewdisabled is opacity now, not a color
color.text-success-boldcolor.scope-success-textreviewcollapses onto one rung
color.text-success-subtlecolor.scope-success-textreviewscope-success-subtle-text is text ON the subtle background, not a lighter success text
color.text-on-successcolor.scope-success-solid-textrenamedirect counterpart
color.text-on-success-boldcolor.scope-success-solid-textreviewcollapses onto one rung
color.text-accent-invert-disabledcolor.scope-accent-solid-textreviewdisabled is opacity now, not a color
color.text-critical-invert-disabled—manual5.0 has no disabled color; the old value was fine, its successor is not

Icons

table
Old keyNew keyKindWhy
color.icon-basecolor.mark-primaryrenamedirect counterpart
color.icon-base-hovercolor.mark-primaryreviewhovered text is a mix now
color.icon-base-disabledcolor.mark-disabledrenamedirect counterpart
color.icon-boldcolor.mark-primaryreviewbase, bold, button and navigation icons all resolve to mark-primary
color.icon-buttoncolor.mark-primaryreviewbase, bold, button and navigation icons all resolve to mark-primary
color.icon-navigationcolor.mark-primaryrenamedirect counterpart
color.icon-subtlecolor.mark-secondaryrenamedirect counterpart
color.icon-minimalcolor.mark-secondaryreviewcollapses onto one rung
color.icon-invertcolor.text-inversereviewicons on the inverse plane read text-inverse
color.icon-whitepalette.whiterenamedirect counterpart
color.icon-accentcolor.scope-accent-markreviewteal in 5.0; defaultTheme authored it blue
color.icon-accent-disabled—manuallight authored gray, dark a deep teal; no successor
color.icon-cautioncolor.scope-caution-markreview4.x flipped polarity per theme; dark darkens by 0.205
color.icon-caution-disabledcolor.scope-caution-markreviewdisabled is opacity now, not a color
color.icon-criticalcolor.scope-critical-markrenamedirect counterpart
color.icon-critical-disabledcolor.scope-critical-markreviewdisabled is opacity now, not a color
color.icon-featurecolor.scope-feature-markrenamedirect counterpart
color.icon-feature-disabledcolor.scope-feature-markreviewdisabled is opacity now, not a color
color.icon-informationcolor.scope-information-markreview4.x flipped polarity per theme; dark darkens by 0.171
color.icon-information-disabledcolor.scope-information-markreviewdisabled is opacity now, not a color
color.icon-successcolor.scope-success-markrenamedirect counterpart
color.icon-success-disabledcolor.scope-success-markreviewdisabled is opacity now, not a color
color.icon-accent-hovercolor.scope-accent-markreviewhovered text is a mix now
color.icon-critical-hovercolor.scope-critical-markreviewhovered text is a mix now
color.icon-accent-invert-disabled—manualthe themes authored pink, not an accent; no disabled color
color.icon-critical-invert-disabledcolor.scope-critical-solid-markreviewdisabled is opacity now, not a color

Labels and status

table
Old keyNew keyKindWhy
color.label-blue-backgroundcolor.scope-blue-subtle-bgfilldirect counterpart
color.label-blue-iconcolor.scope-blue-subtle-markrenamedirect counterpart
color.label-blue-textcolor.scope-blue-subtle-textrenamedirect counterpart
color.label-clementine-backgroundcolor.scope-orange-subtle-bgfilldirect counterpart
color.label-clementine-iconcolor.scope-orange-subtle-markrenamedirect counterpart
color.label-clementine-textcolor.scope-orange-subtle-textrenamedirect counterpart
color.label-green-backgroundcolor.scope-green-subtle-bgfilldirect counterpart
color.label-green-iconcolor.scope-green-subtle-markrenamedirect counterpart
color.label-green-textcolor.scope-green-subtle-textrenamedirect counterpart
color.label-grey-backgroundcolor.scope-gray-subtle-bgfilldirect counterpart
color.label-grey-iconcolor.scope-gray-subtle-markrenamedirect counterpart
color.label-grey-textcolor.scope-gray-subtle-textrenamedirect counterpart
color.label-plum-backgroundcolor.scope-fuchsia-subtle-bgfilldirect counterpart
color.label-plum-iconcolor.scope-fuchsia-subtle-markrenamedirect counterpart
color.label-plum-textcolor.scope-fuchsia-subtle-textrenamedirect counterpart
color.label-purple-backgroundcolor.scope-violet-subtle-bgfilldirect counterpart
color.label-purple-iconcolor.scope-violet-subtle-markrenamedirect counterpart
color.label-purple-textcolor.scope-violet-subtle-textrenamedirect counterpart
color.label-raspberry-backgroundcolor.scope-pink-subtle-bgfilldirect counterpart
color.label-raspberry-iconcolor.scope-pink-subtle-markrenamedirect counterpart
color.label-raspberry-textcolor.scope-pink-subtle-textrenamedirect counterpart
color.label-red-backgroundcolor.scope-red-subtle-bgfilldirect counterpart
color.label-red-iconcolor.scope-red-subtle-markrenamedirect counterpart
color.label-red-textcolor.scope-red-subtle-textrenamedirect counterpart
color.label-teal-backgroundcolor.scope-teal-subtle-bgfilldirect counterpart
color.label-teal-iconcolor.scope-teal-subtle-markrenamedirect counterpart
color.label-teal-textcolor.scope-teal-subtle-textrenamedirect counterpart
color.label-yellow-backgroundcolor.scope-yellow-subtle-bgfilldirect counterpart
color.label-yellow-iconcolor.scope-yellow-subtle-markrenamedirect counterpart
color.label-yellow-textcolor.scope-yellow-subtle-textrenamedirect counterpart
color.status-bluecolor.scope-blue-markrenamedirect counterpart
color.status-greencolor.scope-green-markrenamedirect counterpart
color.status-purplecolor.scope-violet-markrenamedirect counterpart
color.status-redcolor.scope-red-markrenamedirect counterpart
color.status-yellowcolor.scope-yellow-markrenamedirect counterpart

Component tokens

table
Old keyNew keyKindWhy
color.banner-caution-backgroundcolor.scope-caution-subtle-bgfilldirect counterpart
color.banner-caution-bordercolor.scope-caution-borderrenamedirect counterpart
color.banner-critical-backgroundcolor.scope-critical-subtle-bgfilldirect counterpart
color.banner-critical-bordercolor.scope-critical-borderrenamedirect counterpart
color.banner-default-backgroundcolor.scope-neutral-subtle-bgfilldirect counterpart
color.banner-default-bordercolor.scope-neutral-borderreview5.0 retuned the dark neutral tail darker; a far quieter line on dark
color.banner-feature-backgroundcolor.scope-feature-subtle-bgfilldirect counterpart
color.banner-feature-bordercolor.scope-feature-borderrenamedirect counterpart
color.banner-information-backgroundcolor.scope-information-subtle-bgfilldirect counterpart
color.banner-information-bordercolor.scope-information-borderrenamedirect counterpart
color.banner-success-backgroundcolor.scope-success-subtle-bgfilldirect counterpart
color.banner-success-bordercolor.scope-success-borderrenamedirect counterpart
color.banner-error-backgroundcolor.scope-critical-subtle-bgfillthe error Banner variant is now critical
color.banner-warning-backgroundcolor.scope-caution-subtle-bgfillthe warning Banner variant is now caution
color.snackbar-default-backgroundcolor.surface-inversereviewthe default Snackbar is the neutral inverse plane now, not dark teal
color.snackbar-critical-backgroundcolor.scope-critical-solid-bgrenamedirect counterpart
color.snackbar-success-backgroundcolor.scope-success-solid-bgrenamedirect counterpart
color.snackbar-error-backgroundcolor.scope-critical-solid-bgreviewthe error Snackbar variant is now critical
color.snackbar-critical-close-background-hovercolor.overlay-inverse-hoverreviewthe close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars
color.snackbar-default-close-background-hovercolor.overlay-inverse-hoverreviewthe close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars
color.snackbar-error-close-background-hovercolor.overlay-inverse-hoverreviewthe close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars
color.snackbar-success-close-background-hovercolor.overlay-inverse-hoverreviewthe close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars
color.navigation-backgroundcolor.surface-basereviewnavigation sits on the page plane
color.navigation-background-hovercolor.surface-basereviewhover is a layer now, not a token
color.navigation-background-selectedcolor.surface-contentreviewpressed is a layer now, not a token
color.navigation-iconcolor.mark-secondaryrenamedirect counterpart
color.navigation-icon-collapsedcolor.mark-primaryrenamedirect counterpart
color.navigation-icon-selectedcolor.scope-accent-markreviewteal in 5.0; defaultTheme authored it blue
color.navigation-textcolor.text-primaryrenamedirect counterpart
color.navigation-text-selectedcolor.text-primaryreviewthe selected navigation item is marked by weight, not a darker color
color.date_picker-backgroundcolor.overlay-pressedreviewpressed is a layer now, not a token
color.date_picker-background-hovercolor.scope-accent-solid-bgreviewhover is a layer now, not a token
color.date_picker-background-selectedcolor.scope-accent-solid-bgreviewteal in 5.0; defaultTheme authored it blue
color.toggle-surface-basecolor.surface-trackrenamedirect counterpart
color.toggle-surface-base-hovercolor.surface-trackreviewhover is a layer now, not a token
color.toggle-surface-base-disabledcolor.surface-trackreviewdisabled is opacity now, not a color
color.toggle-surface-activecolor.scope-accent-solid-bgrenamedirect counterpart
color.toggle-surface-active-hovercolor.scope-accent-solid-bgreviewhover is a layer now, not a token
color.toggle-surface-active-disabled—manualthe themes authored a neutral gray, not a dimmed accent
color.button-surface-invertcolor.scope-action-solid-bgrenamedirect counterpart
color.button-surface-invert-hovercolor.scope-action-solid-bgreviewhover is a layer now, not a token

Data visualization

table
Old rampNew rampKindStep mappingWhy
color.data-viz-categorical-*dataViz.categorical-*review1→1, 2→2, 3→3, 4→4, 5→5, 6→6, 7→7, 8→8, 9→9, 10→16ramp lengthened and rebuilt on the new palette
color.data-viz-diverging-cool-*dataViz.diverging-cool-*review1→3, 2→4, 3→5, 4→6, 5→7, 6→8, 7→9, 8→10, 9→11, 10→13, 11→14ramp lengthened and rebuilt on the new palette
color.data-viz-diverging-neutral-*dataViz.diverging-neutral-*review1→3, 2→5, 3→6, 4→6, 5→7, 6→8, 7→9, 8→10, 9→11, 10→13, 11→14ramp lengthened and rebuilt on the new palette
color.data-viz-diverging-temperature-*dataViz.diverging-temperature-*review1→2, 2→3, 3→4, 4→6, 5→6, 6→8, 7→9, 8→10, 9→11, 10→12, 11→13ramp lengthened and rebuilt on the new palette
color.data-viz-semantic-caution-*dataViz.semantic-caution-*review1→2, 2→2, 3→3, 4→4, 5→5, 6→6, 7→8ramp lengthened and rebuilt on the new palette
color.data-viz-semantic-critical-*dataViz.semantic-critical-*review1→1, 2→3, 3→5, 4→6, 5→6, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-semantic-information-*dataViz.semantic-information-*review1→1, 2→4, 3→6, 4→6, 5→7, 6→7, 7→8ramp lengthened and rebuilt on the new palette
color.data-viz-semantic-neutral-*dataViz.semantic-neutral-*review1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-semantic-success-*dataViz.semantic-success-*review1→2, 2→4, 3→5, 4→6, 5→8, 6→9, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-blue-*dataViz.sequential-blue-*review1→1, 2→4, 3→6, 4→6, 5→7, 6→7, 7→8ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-green-*dataViz.sequential-green-*review1→2, 2→4, 3→5, 4→6, 5→8, 6→9, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-grey-*dataViz.sequential-gray-*review1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-orange-*dataViz.sequential-orange-*review1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-pink-*dataViz.sequential-pink-*review1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-purple-*dataViz.sequential-violet-*review1→1, 2→4, 3→6, 4→6, 5→6, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-red-*dataViz.sequential-red-*review1→1, 2→3, 3→5, 4→6, 5→6, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-teal-*dataViz.sequential-teal-*review1→2, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9ramp lengthened and rebuilt on the new palette
color.data-viz-sequential-yellow-*dataViz.sequential-yellow-*review1→2, 2→2, 3→3, 4→4, 5→5, 6→6, 7→8ramp lengthened and rebuilt on the new palette

Each -hover twin follows its base step. The four-step color.chart-* ramps land on the ramp they belonged to, nearest step by OKLab distance across both themes:

table
Old keyNew rampKindStep mappingWhy
color.chart-base-*dataViz.categorical-*review1→1, 2→2, 3→3, 4→4one teal ramp becomes four categorical slots, so check it reads as a set
color.chart-positive-*dataViz.semantic-success-*review1→8, 2→7, 3→6, 4→5the green ramp is now the nine-step success ramp
color.chart-secondary-*dataViz.sequential-gray-*review1→8, 2→7, 3→6, 4→5the gray ramp is now the nine-step gray sequential ramp

Palette

table
Old familyNew familyKindStep mappingWhy
palette.grey_charcoal-*palette.gray-*review015→25, 025→50, 050→50, 100→100, 200→200, 250→300, 300→400, 350→500, 400→500, 500→700, 600→800, 650→900, 700→900, 750→950, 775→950, 800→950, 850→950, 900→1000grey_charcoal is replaced by gray
palette.grey_warm_charcoal-*palette.gray-*review015→25, 025→50, 050→75, 100→100, 200→200, 300→400, 400→500, 500→700, 600→800, 700→900, 800→950, 900→975grey_warm_charcoal is replaced by gray
palette.teal_spruce-*palette.teal-*review015→25, 025→100, 050→200, 075→200, 100→300, 200→400, 250→500, 300→500, 400→700, 500→800, 600→900, 700→900, 800→950, 900→1000, 950→1000teal_spruce is replaced by teal
palette.green_verdant-*palette.verdant-*review015→25, 025→50, 050→50, 100→100, 200→100, 300→200, 400→300, 500→400, 550→400, 600→500, 700→700, 800→900, 850→950, 900→1000, 950→1000green_verdant is now the verdant family
palette.red_flame-*palette.red-*review025→25, 050→50, 075→100, 100→200, 150→200, 200→300, 250→400, 300→500, 400→600, 500→700, 600→800, 700→900, 800→950, 900→1000, 950→1000red_flame is replaced by red
palette.orange_zest-*palette.orange-*review025→25, 050→25, 100→100, 200→200, 250→200, 300→300, 350→300, 400→400, 500→500, 600→600, 700→800, 800→900, 900→950, 950→1000orange_zest is replaced by orange
palette.yellow_mustard-*palette.yellow-*review025→25, 050→25, 100→50, 200→100, 300→200, 400→300, 500→300, 525→400, 550→400, 600→500, 650→600, 700→700, 800→900, 900→950, 950→1000yellow_mustard is replaced by yellow
palette.blue_wave-*palette.blue-*review025→25, 050→25, 100→50, 200→100, 250→200, 300→300, 350→400, 400→400, 450→600, 500→600, 600→700, 700→800, 800→900, 900→1000, 950→1000blue_wave is replaced by blue
palette.purple_nova-*palette.violet-*review025→25, 050→50, 100→100, 125→100, 150→200, 200→300, 250→400, 300→400, 350→600, 400→600, 500→700, 600→800, 700→900, 750→900, 800→950, 900→1000, 950→1000purple_nova is replaced by violet
palette.pink_blush-*palette.pink-*review025→25, 050→25, 100→100, 125→100, 150→100, 200→200, 250→300, 300→300, 350→400, 400→500, 500→700, 600→800, 700→900, 800→950, 900→1000, 950→1000pink_blush is replaced by pink
palette.plum-*palette.fuchsia-*review100→50, 300→200, 400→400, 500→500, 600→600, 700→700, 800→800, 900→800, 950→900plum is replaced by fuchsia
palette.grey-*palette.gray-*review050→25, 100→75, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→925grey is replaced by gray
palette.purple-*palette.violet-*review050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950purple is replaced by violet
palette.raspberry-*palette.pink-*review050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950raspberry is replaced by pink
palette.blue-*palette.blue-*review050→25, 100→50, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950same family, strength scale rebuilt
palette.green-*palette.green-*review050→25, 100→50, 200→100, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950same family, strength scale rebuilt
palette.orange-*palette.orange-*review050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950same family, strength scale rebuilt
palette.red-*palette.red-*review050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950same family, strength scale rebuilt
palette.teal-*palette.teal-*review050→25, 100→50, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950same family, strength scale rebuilt
palette.yellow-*palette.yellow-*review050→25, 100→100, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950same family, strength scale rebuilt

Every *-white / *-black endpoint becomes palette.white / palette.black. The brand primitives are gone; teal carries their hue, so they move onto the teal ramp rather than onto verdant, whose brand green is a different hue:

table
Old keyNew keyKindWhy
palette.brand-forestpalette.teal-900reviewnearest teal rung; collapses with brand-forest-light
palette.brand-forest-darkpalette.teal-950reviewnearest teal rung
palette.brand-forest-lightpalette.teal-900reviewnearest teal rung; collapses with brand-forest

grey_warm_charcoal lands on gray, not brown: its chroma never passes 0.009, and each step sits within 0.03 OKLab of a gray step (grey_warm_charcoal-500 #686664 against gray-700 #686663), while the nearest brown steps are up to 0.097 away.

brand-forest and brand-forest-light both land on teal-900 because the hue ladder runs 800, 900, 950, 1000 with no rung between 950 and 900. A codebase that read both steps loses the distinction, which is what the warning is for.

Re-run guard

Before either half starts, the codemod scans the paths you gave it for token names that only exist in 5.0 — color['scope-neutral-subtle-border'], color['surface-content'], palette['blue-700'], var(--pluma-color-surface-content) and the rest of the semantic layer. Those names have no meaning in a 2.x codebase, so a file that reads them has almost certainly been migrated already.

Once it finds those names in two or more files, the run stops before anything is written, lists what it found (up to ten sites, one per file), and exits with status 3:

⛔ Refusing to run. Found 5.0-only token names in 4 files:
     app/utils/color-labels.ts:11  `palette['violet-500']`
     app/components/panel.css:3  `--pluma-color-surface-content`
     app/components/toolbar.ts:87  `color['scope-neutral-subtle-border']`
     app/styles/app.scss:120  `--pluma-color-surface-content`

A single file is not enough to refuse. The 5.0 vocabulary is full of near-misses to older names (invert / inverse, minimal / subtle), and a custom property nothing defines paints nothing — so a misspelling that happens to match a 5.0 name can sit unnoticed in an unmigrated codebase. A migrated one reads the new names in file after file. When the scan finds them in one file only it says so on stdout and the run goes ahead:

⚠ Found 5.0-only token names in 1 file of the 1965 scanned:
     app/components/preview.tsx:73  `--pluma-color-surface-inverse`

The bar is measured against what you pointed it at, so it can't be dodged by narrowing the target: aim the codemod at a single file and that file is the whole of the evidence, so one 5.0 name in it refuses the run as before.

The list of names it looks for is derived from the mapping table below: every rename target that is not also a rename source. A name on both sides (color.surface-base, palette.blue-600) reads the same before and after a migration, so it is never treated as evidence. Neither is a target whose key is one plain word — palette.white, palette.black. No 4.x id spells those exactly, but the words are the same in every vocabulary: 4.x's own (palette.grey-white, color.text-white) and every other palette a codebase keeps beside Pluma's, where theme.palette['white'] is somebody else's white.

Custom properties are looked for in the spelling this run would rewrite. Where the bare pass armed, that is both --pluma-color-surface-content and --color-surface-content, plus the bare --colorMix-fillSubtle-percentage a stylesheet of subtle fills leaves behind. Where it did not arm, only the prefixed spelling counts: --color-* is not a Pluma namespace, and a name the project declares for itself is no evidence of a migration.

A file that migrated entirely onto those names therefore leaves the guard nothing to find — a stylesheet of palette ramp steps is the usual shape — so both passes list every site they rewrite onto one at the end of the run instead.

Pass --force to run anyway. Do that only when you know the paths are a mix of hand-migrated and untouched files — on a fully migrated codebase the ledger has no record of, it produces exactly the double-shift the guard exists to prevent.

With a run ledger

A file the run ledger holds a row for is not scanned: it reads 5.0 names because an earlier run wrote them, and the ledger, not the guard, is what stops those sites shifting again. So re-running over a tree the codemod migrated is no longer refused, however many files it spans:

Run ledger: 212 files under these paths were migrated by an earlier run; the re-run guard leaves them to the ledger.

A listed file that changed since its rows were recorded is named in a warning instead, and never refuses the run. Every other file is scanned, and once the ledger lists files under these paths, 5.0 names in even one of them refuse the run: the tree is known to be migrated, so that file is code written against 5.0 by hand, or migrated code the ledger couldn't follow when it moved. Record it with --baseline <path> (see Running it again). Without a ledger file, or with --no-ledger, the guard behaves exactly as it always has.

The other reading of a hit

"Already migrated" is only one of the two things a hit can mean. The other is that the name was dead: a 5.0 name in a pre-5.0 file resolves to nothing, so the declaration reading it was invalid and the line painted nothing. 5.0 defines the name, and the same line — unchanged — starts painting.

Nothing else catches that. The codemod leaves the line alone, correctly: it is a valid 5.0 name. A typecheck sees a string. A reviewer sees no diff, because there is no diff; only what the line resolves to has changed.

So every hit on a run that goes ahead is written to the findings report as a token-revival row — under the threshold and under --force alike, because a finding that only ever appeared in a refusal message is no use to whoever forced past it. A refusal writes nothing: no run happened, and the report describes a run.

{
	"class": "token-revival",
	"action": "fix-by-hand",
	"file": "app/src/components/marshaled-preview/addons/interaction-border-styles.tsx",
	"line": 2,
	"token": "--pluma-color-surface-inverse",
	"mappedTo": null,
	"rewrittenTo": null,
	"reason": "4.x never defined `--pluma-color-surface-inverse`, so every declaration reading it was invalid and painted nothing. 5.0 defines it, so the same line resolves and starts painting — without changing, which is why no diff and no typecheck shows it. Either this file was migrated already — in which case the codemod running over it at all is the problem — or the name is a typo that is about to come alive. 4.x spelt it `color.surface-invert` (2 edits away), so a misspelling is the likelier of the two. Check what this element is meant to paint.",
	"nearMiss": { "token": "color.surface-invert", "distance": 2 },
}

nearMiss is the hint that tells the two readings apart. A hit an edit or two off a name 4.x really had is typo-shaped, and a typo is the dead-line reading — that row is the one to open first. Digits have to match for a pair to count, or every new palette rung would be a near miss of the rung below it (palette.blue-50 is one edit from palette.blue-500 and nothing like it). A pair that differs by a whole word (minimal / subtle) is past what edit distance can see: the row is still reported, just without the mark.

The fill recipe's own vocabulary is left out — fillScope(…) and --pluma-colorMix-fillSubtle-percentage. Neither is a line that quietly painted nothing before 5.0: the call does not compile against 4.x at all, and the percentage is a name you only write knowing the recipe. Both mean the tree is migrated, which the guard's message already says.

The scan stops at ten sites, one per file, so a --force run over a tree that is genuinely migrated reports a sample rather than all of it — the run says so when it hits the cap.

That row is the only record of the site, so --force is not a way to make it go away:

jq -r '.findings[] | select(.class == "token-revival") | "(.file):(.line)  (.token)"' .pluma/codemod-report.json

The names the guard looks for come from the rename table, which means a 5.0 name no table entry happens to target isn't among them — whole hue families 4.x never had (palette.silver-*, palette.lime-*) and the per-scope -text / -mark slots are outside the set. A dead read of one of those is still invisible. The complete set would have to come from diffing the 4.x and 5.0 vocabularies themselves; what is here is what caught the case in the field.

What --force is risking: the cascade double-shift

53 ids in the rename table are both an old name and another entry's target, so they sit in the middle of a chain:

palette.blue-400             ->  palette.blue-500   ->  palette.blue-600
palette.blue_wave-300        ->  palette.blue-300   ->  palette.blue-400
color.navigation-background  ->  color.surface-base ->  color.surface-content

A single run is correct: every site is decided against the original input, never against the run's own output, so nothing walks two rungs. A second run is not, because a name the first run produced is itself a valid input — the value moves another rung, and each step looks like a legitimate rewrite.

The re-run guard does not catch this. Its evidence is names that exist only in 5.0, and a cascade name is valid in both versions, so finding one says nothing about whether the tree has been migrated. What the guard blocks is the ordinary re-run path — the 5.0-only names alongside those cascade ids — which leaves --force as the only way to reach a second pass. That is the caveat on the flag: force a run over source that has already been migrated and every cascade id in it shifts again.

The run ledger is what stops it: every cascade site a run writes is recorded, and a later run — forced or not — leaves those sites alone. What --force still risks is the migrated code the ledger has no record of: code migrated before the ledger existed, by hand, or with --no-ledger. Record that with --baseline instead of forcing past it, and if you do force, run with --dry first and read the diff.

Warnings

Everything the codemod can't finish on its own is reported at the end of the run, grouped by file:

  • Closest-match renames. The token was rewritten — as a themeVars read or as a raw custom property, in either spelling — but the replacement isn't an exact equivalent, usually because hover, pressed and disabled states are no longer separate tokens in 5.0. The note says what to reach for instead (typically layerHover() / layerPressed() / disabledStyles from @customerio/pluma-components/css/utils). Check these render as intended.
  • No replacement. The token is gone and nothing stands in for it. Left untouched; pick a replacement from the table above.
  • defaultTheme rewritten to lightTheme. Applied, but worth a look: if the file keyed a theme map on theme.name, two entries may now collapse onto the same key.
  • Removed token packages. @customerio/pluma-js, @customerio/pluma-sass and @customerio/pluma-tailwind are gone. Left untouched; move to @customerio/tokens/js or the CSS variables from @customerio/pluma-components/css.
  • @customerio/pluma-css imports. The package still ships, but 5.0 renamed every custom property in it. The warning says whether the bare-name pass was armed for the run, and so whether anything reading it was rewritten.
  • Pre-1.0.0 @customerio/pluma-css names this table never had. --color-banner-error-border, --font-size-s/m/l, --text-product-ps-*. Run the 3.0.0 css-token-rename codemod first. The pre-1.0.0 names this table does map never reach this bullet — they are rewritten like any other 4.x name, under whichever note that carries.
  • CSS custom properties with no drop-in name. Same as above, for var() usage in either spelling — a manual token, or a fill target read inside a larger expression. The warning names the token the read maps to so you can look up its new custom property. A closest-match name is swapped rather than left behind and warns under the bullet above.
  • Computed keys. themeVars.color[name] — resolve the variable and check it by hand.
  • Re-bound bases that escape. A const holding themeVars.color that is also passed around, spread, or returned. Reads under it are left alone.
  • Category moves under a destructured base. const { color } = themeVars can't carry a token that moved out of color; those reads are left alone.
  • themeVars inside a <template> block. Glimmer templates resolve values through the JS scope but aren't JS, so those reads are reported, not rewritten.

After running

  1. Work the findings report first — the fix-by-hand rows are sites the codemod deliberately left untouched, so they are both the most likely to be wrong and the ones your typecheck will fail on. The renamed-only rows are the stylesheet equivalent: they compile, so only the report and the run's warnings point at them.
  2. Work through the review summary top to bottom — the closest-match renames first, since those changed your rendered output.
  3. Replace any remaining state tokens the notes point at with the 5.0 helpers: layerHover(bg?, tint?), layerPressed(bg?, tint?), shiftHover(color), shiftHoverStroke(color) and disabledStyles, all exported from @customerio/pluma-components/css/utils. For the disabled dimming alone, read themeVars.opacity['disabled'].
  4. Re-run your typecheck. The 5.0 themeVars type only admits the new keys, so anything the codemod left behind surfaces as a type error.
  5. Diff your visual snapshots. Semantic renames are mostly value-preserving, but the closest-match ones are not — and every palette-shift row in the report is a colour that actually moved.

Known limitations

  • A bare name is only followed into a var(). A read is renamed; a declaration is not, and neither is a name written anywhere a var() doesn't reach it — a Sass map-get key, a string assembled at runtime. That is the price of the bare spelling not being a namespace Pluma owns.
  • The bare pass is decided per run, not per file. One answer covers every path you gave it, and the standalone node ember.mjs entry point doesn't detect at all — go through pluma-cli upgrade, or pass --bare-css-vars.
  • Re-running needs the ledger. Every pass reads the original file and rewrites each site exactly once, so a rename whose target is itself a legacy id can't cascade within a run. A second run leaves alone what the run ledger recorded; migrated code it has no record of — migrated by hand, before the ledger, or with --no-ledger — would shift again. The re-run guard refuses most of that, and --baseline records it.
  • Only a file moved unchanged keeps its record. A migrated file renamed or moved as it was keeps its rows, when the new path is under the run's paths. Moved and edited, or pulled out into a new file, the run is refused until you record the new file with --baseline <path>, or let the old rows go with --forget <old path> when it is not the same code.
  • Only static reads are resolved. themeVars.color[key], values that arrive through a function argument, and tokens assembled from string pieces are reported rather than guessed at.
  • import * as tokens from '@customerio/pluma-components/css' is not followed — only named imports of themeVars / themeContractVars.
  • A re-bind must be a const in the same file and used only as a property base. Anything looser is reported.
  • Token keys destructured out of a category (const { 'border-base': base } = themeVars.color) are reported, not rewritten — renaming the key would leave the local binding name misleading.
  • .gts/.gjs rewrites cover the JavaScript half of the file plus CSS custom properties inside <template>. A themeVars read written directly in a template is reported instead.
  • Type-only references (typeof themeVars.color['border-base']) are rewritten like any other read, but a token name that reaches your code as a plain string type is invisible to the codemod.
  • Interaction contexts are read from the source, so a state applied in JavaScript — isHovered ? a : b, a class toggled by a component — is invisible and the token is renamed as a resting one. The plane-collapse check does not see those either, because it needs a resting declaration on the same element to compare against.
  • The findings report records what the codemod did and did not do. It is not a contrast audit: a rewrite to layerHover() is the right recipe, not a promise that the step reads the way it did in 4.x.