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:
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.
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:
Both halves support a dry run, which writes nothing and prints a per-file diff plus the review summary:
--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:
--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.
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:
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:
--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.
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, …):
| Written as | Rewritten 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:
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:
.ts/.tsx/.js/.jsx/.gts/.gjs.css, .scss, .astro and .hbs<template> bodies in .gts/.gjsNothing 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.
@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:
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:
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.
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:
error vocabulary becoming criticalfont-size-s / -m / -l becoming font-size-sm / -md / -lg (the
extended sizes xs, xl, xxs, xxl, xxxl are unchanged)product-ps text style becoming product-psmA 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-renamecodemod covers the prop side of the same release (size="s"→size="sm").css-token-renamecovers the raw custom properties.
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:
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.
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):
In a stylesheet or a template, it is the same pair written out:
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.
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.
| Evidence | Examples |
|---|---|
| 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 nesting | a selectors: key or a nested '&:hover' key |
| Recipe variant | variants: { state: { hover: … } } |
| A name carrying the state | backgroundColorHover, _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.
| Site | Becomes |
|---|---|
| a background on a neutral plane, the neutral subtle fill or the overlay tint | layerHover() / layerPressed() |
| the same, on an element with a resting background of its own | layerHover(<the resting declaration>) / layerPressed(…) |
| a hovered border or text colour | shiftHoverStroke(<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.
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:
Two shapes need a look at the element, and guessing either one ships a wrong colour that type-checks:
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.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.
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.
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:
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:
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.
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:
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:
class | What it means |
|---|---|
interaction-state | A 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-collapse | A 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-base | A 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-shift | A 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-source | A file that pulls in @customerio/pluma-css, whose every custom property 5.0 renamed. One row per import. |
bare-name-collision | A bare custom property declared under a name that is also a Pluma token id. Always fix-by-hand. |
token-revival | A 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-ambiguous | A 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:
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:
Pluma 5.0 replaces the flat color.* token list with a semantic color layer, and the vocabulary
changed along with the names.
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.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.*-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.*-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.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.
| Old key | New key | Kind | Why |
|---|---|---|---|
color.surface-base | color.surface-content | review | surface-base kept its name but is now the page plane behind content |
color.background-default | color.surface-content | review | the default background was white |
color.surface-base-hover | color.surface-content | review | hover is a layer now, not a token |
color.surface-base-disabled | — | manual | the themes authored a neutral gray fill, not the page plane; no disabled surface |
color.surface-subtle | color.overlay-tint | review | a faint region tint; use the neutral subtle fill if this was a filled chip |
color.surface-subtle-hover | color.scope-neutral-subtle-bg | fill | hover is a layer now, not a token |
color.surface-subtle-disabled | color.scope-neutral-subtle-bg | fill | disabled is opacity now, not a color |
color.surface-minimal | color.overlay-tint | review | collapses onto one tint with surface-subtle; use the neutral subtle fill if this was a filled chip |
color.surface-active | color.scope-neutral-subtle-bg | fill | pressed is a layer now, not a token |
color.surface-active-hover | color.scope-neutral-subtle-bg | fill | hover is a layer now, not a token |
color.surface-active-disabled | color.scope-neutral-subtle-bg | fill | disabled is opacity now, not a color |
color.surface-bold | color.surface-track | review | the strong neutral fill is now the track plane |
color.surface-bold-hover | color.surface-track | review | hover is a layer now, not a token |
color.surface-overlay | color.surface-floating | review | menus, popovers and tooltips are surface-floating |
color.surface-overlay-hover | color.surface-floating | review | hover is a layer now, not a token |
color.surface-overlay-active | color.overlay-pressed | review | pressed is a layer now, not a token |
color.surface-overlay-active-hover | color.overlay-pressed | review | hover is a layer now, not a token |
color.surface-invert | color.surface-inverse | review | the inverse plane is neutral now; the teal is scope-action-solid-bg |
color.surface-invert-hover | color.surface-inverse | review | hover is a layer now, not a token |
color.surface-invert-bold | color.surface-inverse | review | collapses onto one rung |
color.surface-invert-bold-hover | color.surface-inverse | review | hover is a layer now, not a token |
color.surface-invert-subtle | color.surface-inverse-raised | review | the step off the inverse plane is neutral now, not teal |
color.backdrop-base | color.overlay-backdrop | review | overlay-backdrop is the finished translucent backdrop, not the opaque color you used to mix yourself |
backdrop.mix-amount | — | manual | the backdrop mix is baked into color.overlay-backdrop |
color.surface-workflow-data | — | manual | workflow surfaces are not part of the semantic layer |
color.surface-workflow-delays | — | manual | workflow surfaces are not part of the semantic layer |
color.surface-workflow-flowcontrol | — | manual | workflow surfaces are not part of the semantic layer |
color.surface-workflow-messages | — | manual | workflow surfaces are not part of the semantic layer |
| Old key | New key | Kind | Why |
|---|---|---|---|
color.surface-accent | color.scope-accent-solid-bg | review | teal in 5.0; defaultTheme authored it blue |
color.surface-accent-hover | color.scope-accent-solid-bg | review | hover is a layer now, not a token |
color.surface-accent-disabled | — | manual | the themes authored a neutral gray; no disabled surface |
color.surface-accent-subtle | color.scope-accent-subtle-bg | fill | the subtle rung is a much lighter tint than the old subtle surface |
color.surface-accent-minimal | color.scope-accent-subtle-bg | fill | collapses onto one rung |
color.surface-caution | color.scope-caution-solid-bg | rename | direct counterpart |
color.surface-caution-hover | color.scope-caution-solid-bg | review | hover is a layer now, not a token |
color.surface-caution-disabled | color.scope-caution-solid-bg | review | disabled is opacity now, not a color |
color.surface-caution-bold | color.scope-caution-solid-bg | review | collapses onto one rung |
color.surface-caution-subtle | color.scope-caution-subtle-bg | fill | the subtle rung is a much lighter tint than the old subtle surface |
color.surface-caution-subtle-hover | color.scope-caution-subtle-bg | fill | hover is a layer now, not a token |
color.surface-caution-minimal | color.scope-caution-subtle-bg | fill | collapses onto one rung |
color.surface-critical | color.scope-critical-solid-bg | rename | direct counterpart |
color.surface-critical-hover | color.scope-critical-solid-bg | review | hover is a layer now, not a token |
color.surface-critical-disabled | color.scope-critical-solid-bg | review | disabled is opacity now, not a color |
color.surface-critical-bold | color.scope-critical-solid-bg | review | collapses onto one rung |
color.surface-critical-subtle | color.scope-critical-subtle-bg | fill | the subtle rung is a much lighter tint than the old subtle surface |
color.surface-critical-subtle-hover | color.scope-critical-subtle-bg | fill | hover is a layer now, not a token |
color.surface-critical-minimal | color.scope-critical-subtle-bg | fill | collapses onto one rung |
color.surface-information | color.scope-information-solid-bg | review | 4.x flipped polarity per theme; the 5.0 fill does not |
color.surface-information-hover | color.scope-information-solid-bg | review | hover is a layer now, not a token |
color.surface-information-disabled | color.scope-information-solid-bg | review | disabled is opacity now, not a color |
color.surface-information-bold | color.scope-information-solid-bg | review | collapses onto one rung |
color.surface-information-subtle | color.scope-information-subtle-bg | fill | the subtle rung is a much lighter tint than the old subtle surface |
color.surface-information-subtle-hover | color.scope-information-subtle-bg | fill | hover is a layer now, not a token |
color.surface-information-minimal | color.scope-information-subtle-bg | fill | collapses onto one rung |
color.surface-success | color.scope-success-solid-bg | rename | direct counterpart |
color.surface-success-hover | color.scope-success-solid-bg | review | hover is a layer now, not a token |
color.surface-success-disabled | color.scope-success-solid-bg | review | disabled is opacity now, not a color |
color.surface-success-bold | color.scope-success-solid-bg | review | collapses onto one rung |
color.surface-success-subtle | color.scope-success-subtle-bg | fill | the subtle rung is a much lighter tint than the old subtle surface |
color.surface-success-subtle-hover | color.scope-success-subtle-bg | fill | hover is a layer now, not a token |
color.surface-success-minimal | color.scope-success-subtle-bg | fill | collapses onto one rung |
| Old key | New key | Kind | Why |
|---|---|---|---|
color.border-base | color.scope-neutral-subtle-border | rename | the same hairline in light; the old card edge in dark, which field edges now share |
color.border-base-hover | color.scope-neutral-subtle-border | review | hovered borders are a mix now |
color.border-minimal | color.scope-neutral-subtle-border | review | collapses onto the one subtle rung: the old minimal edge, which border-base now shares |
color.border-minimal-hover | color.scope-neutral-subtle-border | review | hovered borders are a mix now |
color.border-dropdown | color.scope-neutral-subtle-border | rename | the same hairline in light; the old card edge in dark, which field edges now share |
color.border-bold | color.scope-neutral-border | review | a step lighter than the old border-bold in light; a shade darker in dark |
color.border-bold-hover | color.scope-neutral-border | review | hovered borders are a mix now |
color.border-active | color.scope-neutral-border | review | collapses onto one rung: lighter than the old border-active in light, darker than it in dark |
color.border-active-hover | color.scope-neutral-border | review | hovered borders are a mix now |
color.border-focus | color.scope-focus-border | review | blue in 5.0; defaultTheme authored it a pale teal |
color.border-invert | — | manual | no contrasting border rung by design; rebuild with a Pluma pattern |
color.border-accent | color.scope-accent-border | review | the accent border rung is a step lighter than the old border-accent |
color.border-accent-hover | color.scope-accent-border | review | hovered borders are a mix now |
color.border-accent-disabled | — | manual | the themes authored a neutral gray; no disabled border |
color.border-accent-subtle | — | manual | one id did several jobs; no 5.0 border inverts with the theme |
color.border-accent-subtle-hover | — | manual | hovered borders are a mix now, and the resting id has no single successor |
color.border-accent-minimal | color.scope-neutral-subtle-border | review | the themes authored a neutral gray hairline, not an accent |
color.border-caution | color.scope-caution-border | review | 4.x flipped polarity per theme; dark quietens by 0.270 |
color.border-caution-hover | color.scope-caution-border | review | hovered borders are a mix now |
color.border-caution-disabled | color.scope-caution-border | review | disabled is opacity now, not a color |
color.border-caution-subtle | color.scope-caution-subtle-border | rename | direct counterpart |
color.border-caution-subtle-hover | color.scope-caution-subtle-border | review | hovered borders are a mix now |
color.border-caution-minimal | color.scope-caution-subtle-border | review | collapses onto one rung |
color.border-critical | color.scope-critical-border | rename | direct counterpart |
color.border-critical-hover | color.scope-critical-border | review | hovered borders are a mix now |
color.border-critical-disabled | color.scope-critical-border | review | disabled is opacity now, not a color |
color.border-critical-subtle | color.scope-critical-subtle-border | rename | direct counterpart |
color.border-critical-subtle-hover | color.scope-critical-subtle-border | review | hovered borders are a mix now |
color.border-critical-minimal | color.scope-critical-subtle-border | review | collapses onto one rung |
color.border-information | color.scope-information-border | review | 4.x flipped polarity per theme; both planes move |
color.border-information-hover | color.scope-information-border | review | hovered borders are a mix now |
color.border-information-disabled | color.scope-information-border | review | disabled is opacity now, not a color |
color.border-information-subtle | color.scope-information-subtle-border | rename | direct counterpart |
color.border-information-subtle-hover | color.scope-information-subtle-border | review | hovered borders are a mix now |
color.border-information-minimal | color.scope-information-subtle-border | review | collapses onto one rung |
color.border-success | color.scope-success-border | review | 4.x flipped polarity per theme; dark quietens by 0.248 |
color.border-success-hover | color.scope-success-border | review | hovered borders are a mix now |
color.border-success-disabled | color.scope-success-border | review | disabled is opacity now, not a color |
color.border-success-subtle | color.scope-success-subtle-border | rename | direct counterpart |
color.border-success-subtle-hover | color.scope-success-subtle-border | review | hovered borders are a mix now |
color.border-success-minimal | color.scope-success-subtle-border | review | collapses onto one rung |
color.border-feature-border-information | color.scope-feature-border | review | 4.x flipped polarity per theme; light lightens by 0.213 |
color.border-feature-border-information-hover | color.scope-feature-border | review | hovered borders are a mix now |
color.border-feature-border-information-disabled | color.scope-feature-border | review | disabled is opacity now, not a color |
color.border-feature-border-information-subtle | color.scope-feature-subtle-border | rename | direct counterpart |
color.border-feature-border-information-subtle-hover | color.scope-feature-subtle-border | review | hovered borders are a mix now |
color.border-feature-border-information-minimal | color.scope-feature-subtle-border | review | collapses onto one rung |
| Old key | New key | Kind | Why |
|---|---|---|---|
color.text-base | color.text-primary | rename | direct counterpart |
color.text-base-hover | color.text-primary | review | hovered text is a mix now |
color.text-base-disabled | color.text-disabled | rename | direct counterpart |
color.text-bold | color.text-primary | review | base, bold and active all resolve to text-primary |
color.text-bold-hover | color.text-primary | review | hovered text is a mix now |
color.text-active | color.text-primary | review | base, bold and active all resolve to text-primary |
color.text-subtle | color.text-secondary | rename | direct counterpart |
color.text-placeholder | color.text-secondary | review | placeholders darken to the secondary text color |
color.text-minimal | color.text-secondary | review | collapses onto one rung |
color.text-invert | color.text-inverse | rename | direct counterpart |
color.text-white | palette.white | rename | direct counterpart |
color.text-black | palette.black | rename | direct counterpart |
color.text-on-overlay | color.text-primary | rename | direct counterpart |
color.text-on-bold | color.text-primary | review | content on a neutral fill reads text-primary |
color.text-on-navigation | color.text-primary | review | navigation text is ordinary primary text now |
color.text-on-invert | color.text-inverse | review | content on the inverse plane reads text-inverse |
color.text-on-invert-bold | color.text-inverse | review | content on the inverse plane reads text-inverse |
color.text-on-invert-subtle | color.text-inverse | review | content on the raised inverse plane reads text-inverse |
color.text-accent | color.scope-action-text | review | accent text now reads as the action scope |
color.text-accent-hover | color.scope-action-text | review | hovered text is a mix now |
color.text-accent-disabled | color.scope-action-text | review | disabled is opacity now, not a color |
color.text-accent-bold | color.scope-action-text | review | collapses onto one rung |
color.text-on-accent | color.scope-accent-solid-text | rename | direct counterpart |
color.text-caution | color.scope-caution-text | rename | direct counterpart |
color.text-caution-hover | color.scope-caution-text | review | hovered text is a mix now |
color.text-caution-disabled | color.scope-caution-text | review | disabled is opacity now, not a color |
color.text-caution-bold | color.scope-caution-text | review | collapses onto one rung |
color.text-caution-subtle | color.scope-caution-text | review | scope-caution-subtle-text is text ON the subtle background, not a lighter caution text |
color.text-on-caution | color.scope-caution-solid-text | review | a near-black with a yellow cast; every plane moves |
color.text-on-caution-bold | color.scope-caution-solid-text | review | collapses onto one rung |
color.text-critical | color.scope-critical-text | rename | direct counterpart |
color.text-critical-hover | color.scope-critical-text | review | hovered text is a mix now |
color.text-critical-disabled | color.scope-critical-text | review | disabled is opacity now, not a color |
color.text-critical-bold | color.scope-critical-text | review | collapses onto one rung |
color.text-critical-subtle | color.scope-critical-text | review | scope-critical-subtle-text is text ON the subtle background, not a lighter critical text |
color.text-on-critical | color.scope-critical-solid-text | rename | direct counterpart |
color.text-on-critical-bold | color.scope-critical-solid-text | review | collapses onto one rung |
color.text-information | color.scope-information-text | rename | direct counterpart |
color.text-information-hover | color.scope-information-text | review | hovered text is a mix now |
color.text-information-disabled | color.scope-information-text | review | disabled is opacity now, not a color |
color.text-information-bold | color.scope-information-text | review | collapses onto one rung |
color.text-information-subtle | color.scope-information-text | review | scope-information-subtle-text is text ON the subtle background, not a lighter information text |
color.text-on-information | color.scope-information-solid-text | review | dark authored near-black ink on a pale fill; the value flips on dark |
color.text-on-information-bold | color.scope-information-solid-text | review | collapses onto one rung |
color.text-success | color.scope-success-text | rename | direct counterpart |
color.text-success-hover | color.scope-success-text | review | hovered text is a mix now |
color.text-success-disabled | color.scope-success-text | review | disabled is opacity now, not a color |
color.text-success-bold | color.scope-success-text | review | collapses onto one rung |
color.text-success-subtle | color.scope-success-text | review | scope-success-subtle-text is text ON the subtle background, not a lighter success text |
color.text-on-success | color.scope-success-solid-text | rename | direct counterpart |
color.text-on-success-bold | color.scope-success-solid-text | review | collapses onto one rung |
color.text-accent-invert-disabled | color.scope-accent-solid-text | review | disabled is opacity now, not a color |
color.text-critical-invert-disabled | — | manual | 5.0 has no disabled color; the old value was fine, its successor is not |
| Old key | New key | Kind | Why |
|---|---|---|---|
color.icon-base | color.mark-primary | rename | direct counterpart |
color.icon-base-hover | color.mark-primary | review | hovered text is a mix now |
color.icon-base-disabled | color.mark-disabled | rename | direct counterpart |
color.icon-bold | color.mark-primary | review | base, bold, button and navigation icons all resolve to mark-primary |
color.icon-button | color.mark-primary | review | base, bold, button and navigation icons all resolve to mark-primary |
color.icon-navigation | color.mark-primary | rename | direct counterpart |
color.icon-subtle | color.mark-secondary | rename | direct counterpart |
color.icon-minimal | color.mark-secondary | review | collapses onto one rung |
color.icon-invert | color.text-inverse | review | icons on the inverse plane read text-inverse |
color.icon-white | palette.white | rename | direct counterpart |
color.icon-accent | color.scope-accent-mark | review | teal in 5.0; defaultTheme authored it blue |
color.icon-accent-disabled | — | manual | light authored gray, dark a deep teal; no successor |
color.icon-caution | color.scope-caution-mark | review | 4.x flipped polarity per theme; dark darkens by 0.205 |
color.icon-caution-disabled | color.scope-caution-mark | review | disabled is opacity now, not a color |
color.icon-critical | color.scope-critical-mark | rename | direct counterpart |
color.icon-critical-disabled | color.scope-critical-mark | review | disabled is opacity now, not a color |
color.icon-feature | color.scope-feature-mark | rename | direct counterpart |
color.icon-feature-disabled | color.scope-feature-mark | review | disabled is opacity now, not a color |
color.icon-information | color.scope-information-mark | review | 4.x flipped polarity per theme; dark darkens by 0.171 |
color.icon-information-disabled | color.scope-information-mark | review | disabled is opacity now, not a color |
color.icon-success | color.scope-success-mark | rename | direct counterpart |
color.icon-success-disabled | color.scope-success-mark | review | disabled is opacity now, not a color |
color.icon-accent-hover | color.scope-accent-mark | review | hovered text is a mix now |
color.icon-critical-hover | color.scope-critical-mark | review | hovered text is a mix now |
color.icon-accent-invert-disabled | — | manual | the themes authored pink, not an accent; no disabled color |
color.icon-critical-invert-disabled | color.scope-critical-solid-mark | review | disabled is opacity now, not a color |
| Old key | New key | Kind | Why |
|---|---|---|---|
color.label-blue-background | color.scope-blue-subtle-bg | fill | direct counterpart |
color.label-blue-icon | color.scope-blue-subtle-mark | rename | direct counterpart |
color.label-blue-text | color.scope-blue-subtle-text | rename | direct counterpart |
color.label-clementine-background | color.scope-orange-subtle-bg | fill | direct counterpart |
color.label-clementine-icon | color.scope-orange-subtle-mark | rename | direct counterpart |
color.label-clementine-text | color.scope-orange-subtle-text | rename | direct counterpart |
color.label-green-background | color.scope-green-subtle-bg | fill | direct counterpart |
color.label-green-icon | color.scope-green-subtle-mark | rename | direct counterpart |
color.label-green-text | color.scope-green-subtle-text | rename | direct counterpart |
color.label-grey-background | color.scope-gray-subtle-bg | fill | direct counterpart |
color.label-grey-icon | color.scope-gray-subtle-mark | rename | direct counterpart |
color.label-grey-text | color.scope-gray-subtle-text | rename | direct counterpart |
color.label-plum-background | color.scope-fuchsia-subtle-bg | fill | direct counterpart |
color.label-plum-icon | color.scope-fuchsia-subtle-mark | rename | direct counterpart |
color.label-plum-text | color.scope-fuchsia-subtle-text | rename | direct counterpart |
color.label-purple-background | color.scope-violet-subtle-bg | fill | direct counterpart |
color.label-purple-icon | color.scope-violet-subtle-mark | rename | direct counterpart |
color.label-purple-text | color.scope-violet-subtle-text | rename | direct counterpart |
color.label-raspberry-background | color.scope-pink-subtle-bg | fill | direct counterpart |
color.label-raspberry-icon | color.scope-pink-subtle-mark | rename | direct counterpart |
color.label-raspberry-text | color.scope-pink-subtle-text | rename | direct counterpart |
color.label-red-background | color.scope-red-subtle-bg | fill | direct counterpart |
color.label-red-icon | color.scope-red-subtle-mark | rename | direct counterpart |
color.label-red-text | color.scope-red-subtle-text | rename | direct counterpart |
color.label-teal-background | color.scope-teal-subtle-bg | fill | direct counterpart |
color.label-teal-icon | color.scope-teal-subtle-mark | rename | direct counterpart |
color.label-teal-text | color.scope-teal-subtle-text | rename | direct counterpart |
color.label-yellow-background | color.scope-yellow-subtle-bg | fill | direct counterpart |
color.label-yellow-icon | color.scope-yellow-subtle-mark | rename | direct counterpart |
color.label-yellow-text | color.scope-yellow-subtle-text | rename | direct counterpart |
color.status-blue | color.scope-blue-mark | rename | direct counterpart |
color.status-green | color.scope-green-mark | rename | direct counterpart |
color.status-purple | color.scope-violet-mark | rename | direct counterpart |
color.status-red | color.scope-red-mark | rename | direct counterpart |
color.status-yellow | color.scope-yellow-mark | rename | direct counterpart |
| Old key | New key | Kind | Why |
|---|---|---|---|
color.banner-caution-background | color.scope-caution-subtle-bg | fill | direct counterpart |
color.banner-caution-border | color.scope-caution-border | rename | direct counterpart |
color.banner-critical-background | color.scope-critical-subtle-bg | fill | direct counterpart |
color.banner-critical-border | color.scope-critical-border | rename | direct counterpart |
color.banner-default-background | color.scope-neutral-subtle-bg | fill | direct counterpart |
color.banner-default-border | color.scope-neutral-border | review | 5.0 retuned the dark neutral tail darker; a far quieter line on dark |
color.banner-feature-background | color.scope-feature-subtle-bg | fill | direct counterpart |
color.banner-feature-border | color.scope-feature-border | rename | direct counterpart |
color.banner-information-background | color.scope-information-subtle-bg | fill | direct counterpart |
color.banner-information-border | color.scope-information-border | rename | direct counterpart |
color.banner-success-background | color.scope-success-subtle-bg | fill | direct counterpart |
color.banner-success-border | color.scope-success-border | rename | direct counterpart |
color.banner-error-background | color.scope-critical-subtle-bg | fill | the error Banner variant is now critical |
color.banner-warning-background | color.scope-caution-subtle-bg | fill | the warning Banner variant is now caution |
color.snackbar-default-background | color.surface-inverse | review | the default Snackbar is the neutral inverse plane now, not dark teal |
color.snackbar-critical-background | color.scope-critical-solid-bg | rename | direct counterpart |
color.snackbar-success-background | color.scope-success-solid-bg | rename | direct counterpart |
color.snackbar-error-background | color.scope-critical-solid-bg | review | the error Snackbar variant is now critical |
color.snackbar-critical-close-background-hover | color.overlay-inverse-hover | review | the close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars |
color.snackbar-default-close-background-hover | color.overlay-inverse-hover | review | the close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars |
color.snackbar-error-close-background-hover | color.overlay-inverse-hover | review | the close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars |
color.snackbar-success-close-background-hover | color.overlay-inverse-hover | review | the close button hovers with backgroundColor: layerHover() from @customerio/pluma-components/css/utils, inside a surface that spreads layerInverseVars |
color.navigation-background | color.surface-base | review | navigation sits on the page plane |
color.navigation-background-hover | color.surface-base | review | hover is a layer now, not a token |
color.navigation-background-selected | color.surface-content | review | pressed is a layer now, not a token |
color.navigation-icon | color.mark-secondary | rename | direct counterpart |
color.navigation-icon-collapsed | color.mark-primary | rename | direct counterpart |
color.navigation-icon-selected | color.scope-accent-mark | review | teal in 5.0; defaultTheme authored it blue |
color.navigation-text | color.text-primary | rename | direct counterpart |
color.navigation-text-selected | color.text-primary | review | the selected navigation item is marked by weight, not a darker color |
color.date_picker-background | color.overlay-pressed | review | pressed is a layer now, not a token |
color.date_picker-background-hover | color.scope-accent-solid-bg | review | hover is a layer now, not a token |
color.date_picker-background-selected | color.scope-accent-solid-bg | review | teal in 5.0; defaultTheme authored it blue |
color.toggle-surface-base | color.surface-track | rename | direct counterpart |
color.toggle-surface-base-hover | color.surface-track | review | hover is a layer now, not a token |
color.toggle-surface-base-disabled | color.surface-track | review | disabled is opacity now, not a color |
color.toggle-surface-active | color.scope-accent-solid-bg | rename | direct counterpart |
color.toggle-surface-active-hover | color.scope-accent-solid-bg | review | hover is a layer now, not a token |
color.toggle-surface-active-disabled | — | manual | the themes authored a neutral gray, not a dimmed accent |
color.button-surface-invert | color.scope-action-solid-bg | rename | direct counterpart |
color.button-surface-invert-hover | color.scope-action-solid-bg | review | hover is a layer now, not a token |
| Old ramp | New ramp | Kind | Step mapping | Why |
|---|---|---|---|---|
color.data-viz-categorical-* | dataViz.categorical-* | review | 1→1, 2→2, 3→3, 4→4, 5→5, 6→6, 7→7, 8→8, 9→9, 10→16 | ramp lengthened and rebuilt on the new palette |
color.data-viz-diverging-cool-* | dataViz.diverging-cool-* | review | 1→3, 2→4, 3→5, 4→6, 5→7, 6→8, 7→9, 8→10, 9→11, 10→13, 11→14 | ramp lengthened and rebuilt on the new palette |
color.data-viz-diverging-neutral-* | dataViz.diverging-neutral-* | review | 1→3, 2→5, 3→6, 4→6, 5→7, 6→8, 7→9, 8→10, 9→11, 10→13, 11→14 | ramp lengthened and rebuilt on the new palette |
color.data-viz-diverging-temperature-* | dataViz.diverging-temperature-* | review | 1→2, 2→3, 3→4, 4→6, 5→6, 6→8, 7→9, 8→10, 9→11, 10→12, 11→13 | ramp lengthened and rebuilt on the new palette |
color.data-viz-semantic-caution-* | dataViz.semantic-caution-* | review | 1→2, 2→2, 3→3, 4→4, 5→5, 6→6, 7→8 | ramp lengthened and rebuilt on the new palette |
color.data-viz-semantic-critical-* | dataViz.semantic-critical-* | review | 1→1, 2→3, 3→5, 4→6, 5→6, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-semantic-information-* | dataViz.semantic-information-* | review | 1→1, 2→4, 3→6, 4→6, 5→7, 6→7, 7→8 | ramp lengthened and rebuilt on the new palette |
color.data-viz-semantic-neutral-* | dataViz.semantic-neutral-* | review | 1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-semantic-success-* | dataViz.semantic-success-* | review | 1→2, 2→4, 3→5, 4→6, 5→8, 6→9, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-blue-* | dataViz.sequential-blue-* | review | 1→1, 2→4, 3→6, 4→6, 5→7, 6→7, 7→8 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-green-* | dataViz.sequential-green-* | review | 1→2, 2→4, 3→5, 4→6, 5→8, 6→9, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-grey-* | dataViz.sequential-gray-* | review | 1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-orange-* | dataViz.sequential-orange-* | review | 1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-pink-* | dataViz.sequential-pink-* | review | 1→1, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-purple-* | dataViz.sequential-violet-* | review | 1→1, 2→4, 3→6, 4→6, 5→6, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-red-* | dataViz.sequential-red-* | review | 1→1, 2→3, 3→5, 4→6, 5→6, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-teal-* | dataViz.sequential-teal-* | review | 1→2, 2→3, 3→5, 4→6, 5→7, 6→8, 7→9 | ramp lengthened and rebuilt on the new palette |
color.data-viz-sequential-yellow-* | dataViz.sequential-yellow-* | review | 1→2, 2→2, 3→3, 4→4, 5→5, 6→6, 7→8 | ramp 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:
| Old key | New ramp | Kind | Step mapping | Why |
|---|---|---|---|---|
color.chart-base-* | dataViz.categorical-* | review | 1→1, 2→2, 3→3, 4→4 | one teal ramp becomes four categorical slots, so check it reads as a set |
color.chart-positive-* | dataViz.semantic-success-* | review | 1→8, 2→7, 3→6, 4→5 | the green ramp is now the nine-step success ramp |
color.chart-secondary-* | dataViz.sequential-gray-* | review | 1→8, 2→7, 3→6, 4→5 | the gray ramp is now the nine-step gray sequential ramp |
| Old family | New family | Kind | Step mapping | Why |
|---|---|---|---|---|
palette.grey_charcoal-* | palette.gray-* | review | 015→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→1000 | grey_charcoal is replaced by gray |
palette.grey_warm_charcoal-* | palette.gray-* | review | 015→25, 025→50, 050→75, 100→100, 200→200, 300→400, 400→500, 500→700, 600→800, 700→900, 800→950, 900→975 | grey_warm_charcoal is replaced by gray |
palette.teal_spruce-* | palette.teal-* | review | 015→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→1000 | teal_spruce is replaced by teal |
palette.green_verdant-* | palette.verdant-* | review | 015→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→1000 | green_verdant is now the verdant family |
palette.red_flame-* | palette.red-* | review | 025→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→1000 | red_flame is replaced by red |
palette.orange_zest-* | palette.orange-* | review | 025→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→1000 | orange_zest is replaced by orange |
palette.yellow_mustard-* | palette.yellow-* | review | 025→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→1000 | yellow_mustard is replaced by yellow |
palette.blue_wave-* | palette.blue-* | review | 025→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→1000 | blue_wave is replaced by blue |
palette.purple_nova-* | palette.violet-* | review | 025→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→1000 | purple_nova is replaced by violet |
palette.pink_blush-* | palette.pink-* | review | 025→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→1000 | pink_blush is replaced by pink |
palette.plum-* | palette.fuchsia-* | review | 100→50, 300→200, 400→400, 500→500, 600→600, 700→700, 800→800, 900→800, 950→900 | plum is replaced by fuchsia |
palette.grey-* | palette.gray-* | review | 050→25, 100→75, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→925 | grey is replaced by gray |
palette.purple-* | palette.violet-* | review | 050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | purple is replaced by violet |
palette.raspberry-* | palette.pink-* | review | 050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | raspberry is replaced by pink |
palette.blue-* | palette.blue-* | review | 050→25, 100→50, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | same family, strength scale rebuilt |
palette.green-* | palette.green-* | review | 050→25, 100→50, 200→100, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | same family, strength scale rebuilt |
palette.orange-* | palette.orange-* | review | 050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | same family, strength scale rebuilt |
palette.red-* | palette.red-* | review | 050→25, 100→50, 200→200, 300→300, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | same family, strength scale rebuilt |
palette.teal-* | palette.teal-* | review | 050→25, 100→50, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | same family, strength scale rebuilt |
palette.yellow-* | palette.yellow-* | review | 050→25, 100→100, 200→200, 300→400, 400→500, 500→600, 600→700, 700→800, 800→900, 900→950 | same 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:
| Old key | New key | Kind | Why |
|---|---|---|---|
palette.brand-forest | palette.teal-900 | review | nearest teal rung; collapses with brand-forest-light |
palette.brand-forest-dark | palette.teal-950 | review | nearest teal rung |
palette.brand-forest-light | palette.teal-900 | review | nearest 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.
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:
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:
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.
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:
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.
"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.
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:
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.
--force is risking: the cascade double-shift53 ids in the rename table are both an old name and another entry's target, so they sit in the middle of a chain:
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.
Everything the codemod can't finish on its own is reported at the end of the run, grouped by file:
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.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.@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.@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.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.themeVars.color[name] — resolve the variable and check it
by hand.const holding themeVars.color that is
also passed around, spread, or returned. Reads under it are left alone.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.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.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'].themeVars type only admits the new keys, so
anything the codemod left behind surfaces as a type error.palette-shift row in the report
is a colour that actually moved.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.node ember.mjs entry point doesn't
detect at all — go through pluma-cli upgrade, or pass --bare-css-vars.--no-ledger — would shift
again. The re-run guard refuses most of that, and
--baseline records it.--baseline <path>, or let the old rows go with
--forget <old path> when it is not the same code.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.const in the same file and used only as a property base.
Anything looser is reported.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.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.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.layerHover() is the right recipe, not a
promise that the step reads the way it did in 4.x.