Skip to content

feat(tokens): repoint interactive primary color to black/white CTA - #1957

Draft
cmwinters wants to merge 4 commits into
mainfrom
cwinters/feat/cta-color-black-white
Draft

feat(tokens): repoint interactive primary color to black/white CTA#1957
cmwinters wants to merge 4 commits into
mainfrom
cwinters/feat/cta-color-black-white

Conversation

@cmwinters

@cmwinters cmwinters commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Implements DSYS-263 (epic DSYS-158, POC Rebrand x LaunchPad).

Summary

Moves the primary CTA from LaunchDarkly blue to black in light mode / white in dark mode by repointing bg.interactive.primary.* at the black/white primitive ramps, rather than adding a new token role.

The ramps are alpha rampsblack.0900 are alpha steps over #07080C, white.* over #FFFFFF. So the upper members supply hover and focus by compositing over whatever sits behind the control: light mode lightens on hover, dark mode dims, with no light-dark() and no per-theme CSS.

base (.950) hover/focus ΔL* label contrast
Light #07080C black.800#39393D +21.9 20.0:1 → 11.5:1
Dark #FFFFFF white.900#E8E8E9 −8.0 17.4:1 → 14.2:1

The two themes use different alpha steps on purpose. black.900 was near-imperceptible against an opaque black base, so light mode uses .800; dimming a white surface reads much more strongly, so dark mode stays at .900. The numeric deltas are asymmetric to land perceptually symmetric.

Why the paired text tokens were left alone

text.interactive.primary and fill.interactive.primary are white in both themes today (light value, no dark override). .destructive and destructive Menu items borrow them for their white-on-red labels, and text.interactive.destructive can't substitute — it resolves to {color.text.feedback.error}, i.e. red text for links and menu labels.

Flipping them would have dropped the destructive label to 2.94:1 in dark mode, an AA failure. So they're untouched, destructive needs no changes at all, and the foregrounds that sit on the primary fill move to the existing text-ui-primary-inverted instead.

Trade-off accepted: text.interactive.primary no longer pairs with bg.interactive.primary. Recorded in a code comment; renaming it to something like text.interactive.on-accent is a follow-up.

Foregrounds that had to be fixed

Each was hard-locked to white in both themes, so the new white dark-mode fill made it invisible:

  • Checkbox checkmark / indeterminate dash
  • Radio dot — a selected radio otherwise had no affordance at all
  • DateField focused segment — caret-color is transparent there, so it would have rendered completely blank. Also covers DatePicker, DateRangePicker, TimeField.
  • Switch primary handle and label. The handle also gets an explicit neutral shadow: the selected rule tints it green for the default variant, and the primary block previously overrode that with a hardcoded blue glow (rgb(64 91 255 …)) that would clash with a black or white track.
  • Slider thumb straddles the filled and unfilled track, so it can't invert — keeps its white fill, gains a 2px border for definition.

Also fixed

.button[data-selected] swaps the fill but not the label, leaving a selected primary toggle at ~1.01:1 in dark mode. Added .primary[data-selected] (6.69:1 light / 12.72:1 dark). The :where(button, label) guard mirrors the shared rule so it doesn't reach Calendar's selected date cell, which React Aria renders as a div.

Disabled primary also improves incidentally — opacity: 0.64 with no color token means light mode goes from a composited #8698FF at 2.64:1 (fails AA today) to roughly 6.2:1.

Deliberately unchanged

shadow.interactive.focus (the blue focus ring), shadow.interactive.primary (already flips, so the inset halo is correct on both new fills), border.interactive.primary.* (transparent), and all destructive styling.

Screenshots (if appropriate)

⚠️ Not yet attached — needs before/after in both themes. Please review the Chromatic diff as the visual sign-off.

Two things worth a designer's eye specifically:

  • Selected dates in Calendar flip to black/white (Calendar.tsx styles them with variant: 'primary'). Intentional, but it's the one change outside Button and the selection controls.
  • A selected primary toggle now transitions black → pale blue rather than blue → pale blue, which is a hue jump.

Testing approaches

  • pnpm test — 273 tests / 88 files pass; token snapshot regenerated (exactly 8 changed lines, 4 keys × 2 themes)
  • pnpm typecheck and pnpm check clean
  • Verified the compiled dist/style.css: .primary pairs the CTA fill with the inverted label, the :where(button, label) guard survives compilation, the primary override follows the shared [data-selected] rule so it wins the specificity tie, and the blue glow is gone (0 occurrences of 405bff)
  • Confirmed no remaining rule pairs a primary fill with the white-locked label token

Note that unit tests cannot catch any of this — Button.spec.tsx has no style assertions, and the repo has no automated contrast test. Chromatic and the axe panel are the real gates, and Chromatic renders every story in both themes so each diff is doubled.

Flagged for reviewer decision

The deprecated @launchpad-ui/button is pinned to the previous blue rather than tracking the rebrand. Its --Button-color-text-primary and --Button-icon-color-fill-primary-* are shared by both its primary and destructive variants, so adopting an inverting label would mean splitting them across 11 call sites. Pinning keeps it visually unchanged — but it does mean a legacy primary button stays blue while the new one goes black, so an app using both will see a mismatch. Happy to do the split instead if the team would rather it track the rebrand.

Points bg.interactive.primary.* at the black/white primitive ramps so the
primary CTA follows the updated brand: black in light mode, white in dark.

These ramps are alpha steps over #07080C / #FFFFFF, so the .900 members
supply the hover and focus states by compositing over whatever sits behind
the control. That lightens on hover in light mode and dims in dark mode
automatically, with no per-theme CSS and no new tokens.

text.interactive.primary and fill.interactive.primary are deliberately left
alone. Both are white in *both* themes today, and Button's destructive
variant plus destructive Menu items depend on that for their white-on-red
labels -- flipping them would drop that label to 2.94:1 in dark mode, an AA
failure. shadow.interactive.focus (the blue focus ring) is also unchanged.
Every foreground drawn on bg.interactive.primary was hard-locked to white in
both themes, so the now-white dark-mode fill made each of them invisible.
They move to text-ui-primary-inverted, which already resolves to #FFFFFF in
light and #181A1F in dark:

- Button .primary label and icon fill. Its hover/pressed/focus rules also
  drop their redundant color declarations, since the label no longer varies
  by state.
- Checkbox checkmark and indeterminate dash.
- Radio dot, which otherwise left a selected radio with no affordance.
- DateField focused segment. caret-color is transparent there, so a matching
  label would have rendered the segment completely blank. This rule is also
  used by DatePicker, DateRangePicker and TimeField.
- Switch primary handle and label. The handle also gets an explicit neutral
  shadow: the selected rule tints it green for the default variant, and the
  primary block previously overrode that with a hardcoded blue glow that
  would clash with a black or white track.

Also adds .primary[data-selected], because the shared selected rule swaps the
fill but not the label, leaving a selected primary toggle at roughly 1.01:1 in
dark mode. The :where(button, label) guard mirrors the shared rule so this
does not reach Calendar's selected date cell, which React Aria renders as a
div and which should keep the inverted label on the CTA fill.

The Slider thumb straddles the filled and unfilled track, so it cannot invert
with the theme; it keeps its white fill and gains a 2px border for definition
against the white dark-mode fill.
This package's --Button-color-text-primary and --Button-icon-color-fill-primary-*
are shared by both its primary and destructive variants, so it cannot adopt an
inverting label without splitting them across 11 call sites. Rather than restyle
a deprecated package, its primary background is pinned to the blue it renders
today, leaving it visually unchanged.
The custom properties are declared on .Button itself, so an ancestor
[data-theme] rule cannot override them. The dark-mode half of the pin therefore
uses a selector that matches the element too.
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 921f646

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@launchpad-ui/tokens Minor
@launchpad-ui/components Patch
@launchpad-ui/button Patch
launchpad-design-system Patch
@launchpad-ui/box Patch
@launchpad-ui/drawer Patch
@launchpad-ui/dropdown Patch
@launchpad-ui/filter Patch
@launchpad-ui/form Patch
@launchpad-ui/icons Patch
@launchpad-ui/menu Patch
@launchpad-ui/modal Patch
@launchpad-ui/navigation Patch
@launchpad-ui/popover Patch
@launchpad-ui/table Patch
@launchpad-ui/tooltip Patch
@launchpad-ui/vars Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown
yarn add https://pkg.pr.new/@launchpad-ui/components@1957.tgz
yarn add https://pkg.pr.new/@launchpad-ui/icons@1957.tgz
yarn add https://pkg.pr.new/@launchpad-ui/tokens@1957.tgz

commit: 921f646

black.900 was too subtle against an opaque black base -- the hover was barely
perceptible in light mode. Moving hover and focus to black.800 roughly doubles
the step, from dL* +10.5 to +21.9, and the white label still clears AAA at
11.50:1. Dark mode keeps white.900, which already read well.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant