Skip to content

refactor: migrate Button (next) colours to beta tokens - #5222

Draft
pomfrida wants to merge 4 commits into
mainfrom
refactor/button-beta-colour-tokens
Draft

refactor: migrate Button (next) colours to beta tokens#5222
pomfrida wants to merge 4 commits into
mainfrom
refactor/button-beta-colour-tokens

Conversation

@pomfrida

@pomfrida pomfrida commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Resolves #5208 (phase 1 of #5119). First component migrated onto the Tokens Studio beta colour tokens — the test case for the migration pattern.

Updated 2026-08-06 after tokens release #5280: the branch is merged with main and rewritten from the interim --_tone-N step indirection to the semantic interactive token group that release added. The Storybook wiring has landed on main (#5224/#5239), so the diff is now button.css only.

Updated 2026-08-07: design confirmed in #5283 that the missing neutral text/icon interactive tokens are deliberately out of scope — neutral interactive text uses --eds-text-primary by design, so the --eds-text-primary mapping in this PR is the intended final state, not a stand-in. That was the last token gap Button was waiting on.

Token availability for consumers: the interactive token group is published — @equinor/eds-tokens@3.0.0-beta.4 (cut right after #5280 merged) ships it in the Tokens Studio bundle under the publish-time ./next/css/* export (ADR-0009: consumers import @equinor/eds-tokens/next/css/variables.css). Note that the legacy ./css/variables export does not contain the group, so apps on the /next beta need the next/css import. Prod Storybook predates the #5224/#5239 wiring; the next Storybook deploy from main picks the tokens up automatically.

Changes

button.css:

  • All colours come from the semantic interactive group:
    • Primary fill: --eds-background-interactive-{tone}-emphasis-{default,hover,pressed}; label: --eds-text-on-emphasis
    • Secondary/ghost hover/pressed backgrounds: --eds-background-interactive-{tone}-muted-{hover,pressed}; text: --eds-text-interactive-{tone}-default
    • Secondary border: --eds-border-interactive-{tone}-emphasis-default
    • Focus: --eds-border-interactive-focus
    • Disabled: --eds-{background,text,border}-interactive-disabled — the legacy-token fallbacks and the TODO(🎨 Implement Alpha‑Channel approach for disabled states #4741) are gone. If 🎨 Implement Alpha‑Channel approach for disabled states #4741 ships alpha-channel values they land as value changes on these same tokens, with no component change needed.
  • A small --_ indirection remains at the component root purely so data-color-appearance can switch the accent/neutral/danger token set; states read those variables.

Design notes

  • The text-interactive group deliberately has no neutral variant (confirmed in Semantic interactive tokens are missing success and info tones #5283) — the neutral tone maps secondary/ghost text to --eds-text-primary (neutral-12) by design.
  • Secondary border uses the emphasis border token (step 9) — confirmed with design; the muted border tokens (step 4) are for lighter surfaces.

Export gaps and bugs found (historical, since resolved)

Captured as learnings on #5208; fixed at the source via #5221/#5280:

Verification

  • 71/71 Jest tests pass, snapshot unchanged
  • Visually verified in local Storybook after the rewrite: all variants × tones (accent/neutral/danger) × light/dark, including disabled states
  • Verified against prod Storybook (2026-08-07) by injecting this branch's button.css + main's token export over the deployed stories: before/after screenshots of All Variants / Tones / Disabled Variants in light and dark are visually equivalent (pixel diff < 2 %, mostly anti-aliasing). All 27 token references resolve in both schemes — none missing. Two deliberate value shifts from feat: update tokens from Tokens Studio release #5280 are visible on close inspection: emphasis fills sit one step lighter than legacy, and the dark-scheme disabled fill is now a dark subtle surface (oklch(0.226) on the oklch(0.19) canvas) instead of medium slate.

@pomfrida pomfrida self-assigned this Jul 22, 2026
pomfrida added a commit that referenced this pull request Jul 28, 2026
…th the widened semantic block

The widened semantic layer and the color-scheme scope rules both apply
to [data-color-scheme] elements at equal specificity, so source order
decides the three duplicate names tracked in #5221. With the previous
alphabetical order the semantic block sorted last and won - and its
--eds-border-focus is a self-reference, computing to the
guaranteed-invalid value, which would have dropped focus outlines for
the incoming Chip (#5225) and Button (#5222) migrations inside scoped
subtrees (both use var(--eds-border-focus) for their focus ring).

The bundler now ranks color-scheme/* after every other layer, so the
scheme-specific values win on scoped elements and every resolved value
matches the pre-widening state. Verified in the browser:
--eds-border-focus resolves to the light/dark blue per subtree, and
text-disabled/border-disabled keep their scheme values.
pomfrida added a commit that referenced this pull request Jul 30, 2026
…#5239)

* fix: emit the beta semantic token layer under the color-scheme scopes

The Tokens Studio CSS export declared the semantic layer at :root while
the scale aliases it references only exist under [data-color-scheme]
scopes. Custom properties substitute where they are declared, so the
semantic tokens resolved once at the root and never re-resolved inside
[data-color-scheme] subtrees, breaking subtree colour-scheme switching
(ADR-0004/ADR-0013).

The saved EDS-CSS export configuration now sets
rootSelector: ':root, [data-color-scheme]' (was the ':root' default),
so every non-dimensional layer is also declared on any element carrying
the attribute and var() references substitute against the nearest
scope. Token content is unchanged - the diff is selector lines only.

The <html>-mirror workaround in the Storybook decorator is no longer
needed and is removed.

Closes #5226

* chore: use beta semantic tokens for the storybook wrapper canvas

The wrapper used the legacy --eds-color-bg-neutral-surface, which has no
dark declaration in the 2.x bundle, so the story canvas stayed light in
dark mode. The beta semantic tokens re-resolve per [data-color-scheme]
subtree after the rootSelector change, so the canvas now follows the
toolbar toggle. Legacy is left untouched - it is being replaced, not
maintained.

* fix: widen only the semantic layer to [data-color-scheme]

Review follow-up on #5239. Widening every non-dimensional layer via the
export's global rootSelector regressed the density cascade: the density
base (comfortable) declares the same --eds-density-* names as the
compact/relaxed variants, so re-declaring it on every
[data-color-scheme] element clobbered a [data-density] ancestor's
values for the whole subtree.

The export format has no per-layer selector, so the saved EDS-CSS
configuration is reverted to the default rootSelector (:root) and the
semantic layer - the only layer that references the scheme-scoped scale
aliases - is widened repo-side by scripts/widen-semantic-scope.mjs,
chained before generate-css-bundle.mjs in the generate:css-bundle
package script so the release workflow needs no change. The script is
idempotent and fails loudly if the export layout changes.

Also drops a comment reference to ADR-0013 (not merged yet, PR #5200)
and documents the #5221 name-collision caveat in the bundle header.

* chore: assert the semantic layer is widened before bundling

The widen step is chained in the generate:css-bundle package script,
but running the bundler directly would silently bundle an unwidened
semantic layer and regress subtree colour-scheme switching (#5226).
The bundler now fails loudly instead.

* chore: harden the semantic-widening against formatting and export drift

Review follow-up on #5239, round two.

- The widened selector is matched with whitespace-tolerant regexes and
  emitted in Prettier's canonical one-selector-per-line form, shared
  between the widen script and the bundler via semantic-scope.mjs, so
  a prettier --write no longer breaks the pipeline with a misleading
  'export layout changed' error.
- Every semantic/*.css file is widened and asserted, mirroring the
  bundler's directory glob - a file the export adds later cannot slip
  through with the narrow :root selector.
- src/tokens/* is added to .prettierignore (root and package-local):
  the directory is machine output and was never Prettier-conformant
  (the export emits double-quoted attribute selectors and no trailing
  newline, failing prettier:check on main too), and hand-formatting it
  would be reverted by the next export run. The package prettier:check
  now passes.

* chore: widen nested semantic files and document the widening in the release workflow

Review follow-up on #5239, round three.

- The widen script now globs semantic/ recursively, matching the
  bundler's assertion glob - a nested file the export adds later gets
  widened instead of deadlocking the pipeline with a remedy message
  that is a no-op.
- A selector that matches the tolerant regex but not the canonical
  form is normalised, so the committed bytes are a function of content
  only.
- The release workflow's bundle step and auto-PR body now mention the
  semantic-layer widening, so the selector diff in tokens-release PRs
  is traceable to #5226 instead of looking like an export change.

* fix: concatenate the color-scheme layer last so it wins collisions with the widened semantic block

The widened semantic layer and the color-scheme scope rules both apply
to [data-color-scheme] elements at equal specificity, so source order
decides the three duplicate names tracked in #5221. With the previous
alphabetical order the semantic block sorted last and won - and its
--eds-border-focus is a self-reference, computing to the
guaranteed-invalid value, which would have dropped focus outlines for
the incoming Chip (#5225) and Button (#5222) migrations inside scoped
subtrees (both use var(--eds-border-focus) for their focus ring).

The bundler now ranks color-scheme/* after every other layer, so the
scheme-specific values win on scoped elements and every resolved value
matches the pre-widening state. Verified in the browser:
--eds-border-focus resolves to the light/dark blue per subtree, and
text-disabled/border-disabled keep their scheme values.
@pomfrida

pomfrida commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown

Claude finished @pomfrida's task in 6m 41s —— View job


Review: refactor: migrate Button (next) colours to beta tokens

  • Gather context (diff, button.css, Button.tsx, token exports)
  • Verify every interactive token the PR uses exists in the export
  • Review CSS against EDS 2.0 conventions
  • Check state/appearance coverage and resolved token values
  • Run tests + stylelint

Verified locally: 71/71 Jest tests pass, snapshot unchanged. All 20 --eds-{background,text,border}-interactive-* tokens the PR references are defined in packages/eds-tokens/src/tokens/css/semantic/default.css. --eds-text-interactive-neutral-* is genuinely absent, so the --eds-text-primary stand-in and the #5283 blocker are correctly diagnosed.

The structure is good — the --_ tone indirection at the root with states reading those vars is the right shape, it follows the "override the variable, never the property" rule from AGENTS.md, and dropping the TODO(#4741) fallbacks is a real simplification. Comments below, roughly by impact.


1. The beta palette has no :root default — colours and the focus ring collapse without data-color-scheme

packages/eds-tokens/src/tokens/css/color-scheme/light.css:1 is scoped to [data-color-scheme="light"] only, and the bundle's selector list confirms there's no unqualified fallback:

variables.css:590  :root, [data-color-scheme]      ← semantic layer (fixed by #5226)
variables.css:838  [data-color-scheme="dark"]      ← palette
variables.css:1004 [data-color-scheme="light"]     ← palette, no :root

So on a page that imports the beta bundle but doesn't set data-color-scheme, --eds-accent-9 / --eds-neutral-15 / --eds-border-focus are undefined. That makes every var() chain in button.css invalid-at-computed-value-time:

  • background-color: var(--_bg-color)unset → not inherited → transparent
  • color: var(--_color)unset → inherited → whatever the parent is
  • outline: var(--eds-sizing-stroke-thick) solid var(--eds-border-interactive-focus) on :focus-visible → the whole declaration is dropped → no visible focus ring (WCAG 2.4.7)

Storybook masks this: .storybook/preview.mjs:61 always sets data-color-scheme on the wrapper, which is why the visual verification passed. #5226 fixed the semantic layer's scoping, but the palette layer underneath it still needs the attribute — scoping the semantic layer under [data-color-scheme] is necessary but not sufficient.

Worth establishing before this pattern is copied: did the legacy --eds-color-* tokens Button used pre-PR resolve without the attribute? If yes this is a consumer-visible regression and the palette needs a :root default; if no, it's a hard prerequisite that should be stated in the Button docs and the migration note. Either way it belongs in the tokens package, not button.css.

2. Disabled secondary loses its outline

Both resolve to the same step:

--eds-background-interactive-disabled: var(--eds-neutral-2);  /* semantic/default.css:23 */
--eds-border-interactive-disabled:     var(--eds-neutral-2);  /* semantic/default.css:61 */

With --eds-background-canvas: var(--eds-neutral-1), the 1px outline at button.css:202-203 is drawn in neutral-2 on a neutral-1 canvas — effectively invisible. Pre-PR it was --eds-color-border-disabled--eds-color-neutral-7, clearly visible. A disabled secondary button becomes indistinguishable from a disabled ghost. Upstream value issue (probably belongs with #4741), but it ships with this PR.

3. Disabled label contrast is a step worse than before

pre-PR (neutral appearance) this PR
primary bg neutral-3 neutral-2
label neutral-7 neutral-5
step delta 4 3

WCAG 1.4.3 exempts inactive controls, so this isn't a violation — but it's a regression in legibility on the token values, not the CSS. Worth confirming with design or folding into #4741 rather than landing silently.

4. The export models hover/pressed text and border shifts; the component doesn't use them

These exist and are unreferenced:

--eds-text-interactive-accent-hover:   var(--eds-accent-11);
--eds-text-interactive-accent-pressed: var(--eds-accent-12);
--eds-border-interactive-accent-emphasis-hover:   var(--eds-accent-10);
--eds-border-interactive-accent-emphasis-pressed: var(--eds-accent-11);

Secondary/ghost labels stay at -default (step 10) through hover and pressed, and the secondary border stays at -default (step 9). Since the token set explicitly models the shift, worth confirming the omission is intentional rather than inherited from the pre-PR CSS, which had no equivalent tokens to shift to.

5. #5283 won't be a no-op rename — flag that on the issue

accent and danger use text-interactive-{tone}-default = step 10. The neutral stand-in --eds-text-primary is neutral-12. So neutral secondary/ghost labels currently render two steps darker than their accent/danger siblings, and when --eds-text-interactive-neutral-default lands it will most likely be neutral-10 — i.e. lighter than what this PR ships. (Pre-PR neutral used --eds-color-text-subtle, lighter again.) Worth a line on #5283 so the swap isn't mistaken for a value-preserving rename.

6. data-color-appearance is now load-bearing for two different systems

Button.tsx:41 already set this attribute pre-PR, so it isn't introduced here — but this PR makes it the tone switch. It's also the attribute the legacy dynamic bundle keys off (build/css/color/dynamic/variables.css:11,37,63), and documentation/agent-instructions/BUILDING_EDS_2_COMPONENTS.md:62-84 says to put it on the smallest element, not the component root, precisely because descendants inherit it. Setting accent/danger on the button root means any /next child still reading dynamic --eds-color-* tokens inherits that appearance.

Harmless today because Button's own colours no longer read dynamic tokens. But as the reference migration, every subsequent component will copy this. Either decouple the tone switch onto its own attribute (data-tone) or add a comment in button.css recording that root-level data-color-appearance is a deliberate exception to the smallest-element rule.

7. disabled ? 'neutral' : tone is now dead for Button's own colours

Button.tsx:41. All three disabled states use appearance-agnostic *-interactive-disabled tokens, so the forced neutral no longer changes anything Button renders — it only changes what descendants inherit. Drop it or comment why it stays.

Nits

  • button.css:25-26 duplicate 168-169--_color: var(--eds-text-on-emphasis) and --_bg-color: var(--_bg-emphasis) are declared identically at the root and under [data-variant='primary']. Pre-existing shape, but these exact lines are being rewritten anyway. Fix this →
  • button.css:207-209 is redundant[data-variant='secondary']:focus-visible sets the same outline-offset: var(--eds-sizing-stroke-thin) that the general :focus-visible rule at line 245 already applies.
  • Focus ring mixes bundles — the width is legacy (--eds-sizing-stroke-thick), the colour is beta (--eds-border-interactive-focus). Expected for a colours-only PR, just noting it for the sizing follow-up.
  • Pre-existing stylelint failures, not from this PR: selector-class-pattern rejects .label at lines 68 and 78 ("Expected class '.label' to follow BEM syntax"). The stylelint config contradicts the flat-inner-class convention in AGENTS.md. Separate config fix.

Suggested before undraft

  1. Resolve or explicitly document the data-color-scheme prerequisite (Michael is testing #1) — the missing focus ring is the part I'd not want to ship.
  2. Track the disabled border/background collision (fake-bot added new tokens #2) upstream; it's a visible regression today.
  3. Confirm removed color. Should only trigger storefront? #4 and removed color. Should only trigger storefront? #5 with design, and note removed color. Should only trigger storefront? #5 on Semantic interactive tokens are missing success and info tones #5283.

Nothing here needs a change to the CSS structure — the --_ indirection pattern is sound and I'd keep it as the template for the remaining components.
• branch refactor/button-beta-colour-tokens

pomfrida added a commit that referenced this pull request Aug 7, 2026
Follows the Button pattern (#5222): background-interactive-{tone}-{muted,emphasis}-*,
text-interactive-{tone}-default, border-interactive-{tone}-emphasis-default, and
border-interactive-focus for the focus ring. Neutral maps text to --eds-text-primary
(no interactive neutral text/icon by design, #5283). success/info have no interactive
group yet (#5283) and stay on raw steps mirroring the semantic ladders.
millus pushed a commit that referenced this pull request Aug 12, 2026
Follows the Button pattern (#5222): background-interactive-{tone}-{muted,emphasis}-*,
text-interactive-{tone}-default, border-interactive-{tone}-emphasis-default, and
border-interactive-focus for the focus ring. Neutral maps text to --eds-text-primary
(no interactive neutral text/icon by design, #5283). success/info have no interactive
group yet (#5283) and stay on raw steps mirroring the semantic ladders.
millus pushed a commit that referenced this pull request Aug 13, 2026
Follows the Button pattern (#5222): background-interactive-{tone}-{muted,emphasis}-*,
text-interactive-{tone}-default, border-interactive-{tone}-emphasis-default, and
border-interactive-focus for the focus ring. Neutral maps text to --eds-text-primary
(no interactive neutral text/icon by design, #5283). success/info have no interactive
group yet (#5283) and stay on raw steps mirroring the semantic ladders.
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.

[Button] Migrate to new beta colour tokens

1 participant