Skip to content

[WB-2328] 🚨 [CSS Modules] Migrate ActivityButton to CSS Modules - #3168

Open
jandrade wants to merge 2 commits into
WB-2328from
WB-2328-activity-button
Open

[WB-2328] 🚨 [CSS Modules] Migrate ActivityButton to CSS Modules#3168
jandrade wants to merge 2 commits into
WB-2328from
WB-2328-activity-button

Conversation

@jandrade

@jandrade jandrade commented Aug 5, 2026

Copy link
Copy Markdown
Member

Migrate ActivityButton from Aphrodite to CSS Modules, the piece that was
deliberately deferred when Button moved over (it had its own
_generateStyles). The public API is unchanged — same props, DOM structure and
styles.root / styles.box / styles.startIcon / styles.endIcon /
styles.label overrides — so this is an internal styling refactor.

The new activity-button.module.css follows the component-token surface
Button settled on: every value a variant axis can change is a
--wb-c-activity-button--* custom property, variant classes only assign
tokens, and the base + state rules only read them. The actionType × kind
colour matrix is six compound rules (.progressive.primary, …) because the
semantic tokens are keyed by both axes; disabled colours are keyed by kind
alone so they live on the kind classes. Structural values (border width, the
6px/8px/0 shadow depths) are written literally rather than codegen'd per theme,
matching the note the Aphrodite theme object carried — every theme reused
them.

The "chonky" box no longer gets its state styling from classes toggled in JS.
chonkyPressed / chonkyDisabled are gone; the box is styled by descendant
selectors from the root (.button:hover .box, .button.pressed .box,
.button[aria-disabled="true"] .box), all at the same specificity so source
order alone decides precedence: hover -> active -> disabled -> focus. The
plain chonky className stays on the element as a consumer/test hook, it just
no longer drives styling — same treatment data-kind got in Button.

The icon and label rules are qualified with .box on purpose. PhosphorIcon
(.medium) and BodyText (.mediumSemi) ship single-class rules in the same
@layer shared, and Aphrodite's !important used to settle that contest for
us; the compound selector makes the outcome depend on specificity instead of
which package's stylesheet happens to load first.

One intentional behaviour change: a disabled button that is both focused and
hovered now keeps the full focus ring. Previously the disabled :hover reset
was emitted after the :focus-visible rule at equal specificity and stripped
the outline, leaving only the inner box-shadow. That was an artifact of
Aphrodite's rule ordering, and Button already made the same call
(68ea60e).

Verified: pnpm jest packages/wonder-blocks-button (all 5 suites), typecheck,
eslint, stylelint, prettier, and a full rollup build all pass — the build emits
the ActivityButton rules into the package's dist/index.css with @apply --wb-focus-visible expanded and everything wrapped in @layer shared. Visual
parity still needs a Chromatic diff review on this PR.

Issue: WB-2328

Test plan:

Automated checks are green and cover behavior/structure/aria:

  • pnpm jest packages/wonder-blocks-button — all 5 suites pass
  • pnpm typecheck, eslint, stylelint — clean
  • pnpm rollup -c ./build-settings/rollup.config.mjs — emits the 28
    wb-activity-button-* selectors into packages/wonder-blocks-button/dist/index.css

Manual (visual — not covered by the above):

  1. Review the Chromatic diff on this PR. The only expected diff is the disabled
    • focus + hover cell of the ActivityButton StateSheet (see above); anything
      else must be investigated.
  2. In Storybook, spot-check ActivityButton across kind
    (primary/secondary/tertiary) x actionType (progressive/neutral) and the
    hover / active / focus-visible / disabled states, in the default,
    thunderblocks and syl-dark themes. Confirm the box still lifts on hover and
    drops flat on press, and that the WithCustomStyles story's overrides
    (root/box/startIcon/endIcon/label) still take effect.
    http://localhost:6061/?path=/docs/packages-button-activitybutton--docs

Review plan:

Please review these risky changes

  1. 🚨 activity-button.module.css
  2. ⚠️ activity-button.tsx

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7caf2ea

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

This PR includes changesets to release 4 packages
Name Type
@khanacademy/wonder-blocks-button Minor
@khanacademy/wonder-blocks-banner Patch
@khanacademy/wonder-blocks-tabs Patch
eslint-plugin-wonder-blocks-demo 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

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Size Change: -1.64 kB (-1.26%)

Total Size: 129 kB

📦 View Changed
Filename Size Change
packages/wonder-blocks-button/dist/es/index.js 2.78 kB -874 B (-23.94%) 🎉
packages/wonder-blocks-dropdown/dist/es/index.js 19.1 kB -768 B (-3.86%)
ℹ️ View Unchanged
Filename Size
packages/eslint-plugin-wonder-blocks/dist/es/index.js 7.11 kB
packages/wonder-blocks-accordion/dist/es/index.js 3.02 kB
packages/wonder-blocks-announcer/dist/es/index.js 2.43 kB
packages/wonder-blocks-badge/dist/es/index.js 2.03 kB
packages/wonder-blocks-banner/dist/es/index.js 2.01 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.93 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 798 B
packages/wonder-blocks-card/dist/es/index.js 1.09 kB
packages/wonder-blocks-cell/dist/es/index.js 2.19 kB
packages/wonder-blocks-clickable/dist/es/index.js 2.61 kB
packages/wonder-blocks-core/dist/es/index.js 2.59 kB
packages/wonder-blocks-data/dist/es/index.js 5.51 kB
packages/wonder-blocks-date-picker/dist/es/index.js 8.1 kB
packages/wonder-blocks-form/dist/es/index.js 6.39 kB
packages/wonder-blocks-grid/dist/es/index.js 1.25 kB
packages/wonder-blocks-icon-button/dist/es/index.js 4.06 kB
packages/wonder-blocks-icon/dist/es/index.js 1.84 kB
packages/wonder-blocks-labeled-field/dist/es/index.js 3.44 kB
packages/wonder-blocks-layout/dist/es/index.js 1.69 kB
packages/wonder-blocks-link/dist/es/index.js 1.54 kB
packages/wonder-blocks-modal/dist/es/index.js 7.34 kB
packages/wonder-blocks-pill/dist/es/index.js 1.32 kB
packages/wonder-blocks-popover/dist/es/index.js 4.41 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.49 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.12 kB
packages/wonder-blocks-styles/dist/es/index.js 464 B
packages/wonder-blocks-switch/dist/es/index.js 1.6 kB
packages/wonder-blocks-tabs/dist/es/index.js 5.42 kB
packages/wonder-blocks-testing-core/dist/es/index.js 4.09 kB
packages/wonder-blocks-testing/dist/es/index.js 978 B
packages/wonder-blocks-theming/dist/es/index.js 384 B
packages/wonder-blocks-timing/dist/es/index.js 1.53 kB
packages/wonder-blocks-tokens/dist/es/index.js 6.48 kB
packages/wonder-blocks-toolbar/dist/es/index.js 906 B
packages/wonder-blocks-tooltip/dist/es/index.js 6.19 kB
packages/wonder-blocks-typography/dist/es/index.js 1.32 kB

compressed-size-action

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (95fd296) and published all packages with changesets to npm.

You can install the packages in frontend by running:

./dev/tools/deploy_wonder_blocks.js --tag="PR3168"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR3168

@@ -0,0 +1,380 @@
/**

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 High risk — new file defining the whole visual matrix.

This file is the entire visual output for ActivityButton (colour tokens, the chonky box, and the hover / press / disabled / focus states) for a component shipped to every consuming app. Nothing in CI verifies the rendered pixels, so correctness here rests on the Chromatic diff.

What to look at closely:

  1. The colour matrix (6 compound rules). Each .progressive.primary-style rule maps to semanticColor.chonky[actionType][property][kind][state] in the deleted _generateStyles. Worth spot-checking a couple of cells against the old JS — a transposed kind/actionType would be invisible in one theme and obvious in another.
  2. Source order is load-bearing. The box state rules all sit at (0,3,0) specificity, so precedence comes from declaration order alone: hover → active → disabled → focus. Reordering any of these blocks silently changes behavior.
  3. .box .icon / .box .label are compound on purpose. PhosphorIcon (.medium, 2.4rem) and BodyText (.mediumSemi) ship single-class rules into the same @layer shared. Aphrodite's !important used to settle that contest; without the .box qualifier the winner would come down to which package's stylesheet loads first.
  4. Intentional behavior change. A disabled button that is both focused and hovered now keeps the full focus ring (.button[aria-disabled="true"]:focus-visible). Previously the disabled :hover reset was emitted after :focus-visible at equal specificity and stripped the outline, leaving only the inner box-shadow — an artifact of Aphrodite's rule ordering rather than a design decision. Button already made the same call in 68ea60e.

Expected Chromatic result: exactly one diff, in the hoverAndFocus cell of the disabled column on the ActivityButton StateSheet. Any other diff needs investigating before this lands.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, there wasn't a diff in Chromatic! I checked in Storybook and things seem to be looking okay though! Any thoughts on this?

@@ -1,26 +1,19 @@
import * as React from "react";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Medium risk — restyles a shared component; the box's state classes are no longer toggled in JS.

_generateStyles, the local theme object and the aphrodite / focusStyles / tokens imports are all gone (−235 lines). Styling now comes from activity-button.module.css composed through the style prop — processStyleList routes class-name strings to className, so no clsx.

The behavioural bits worth a careful read:

  1. chonkyStyles collapsed to [styles.box, stylesProp?.box]. chonkyPressed / chonkyDisabled no longer exist as JS-toggled classes; the box is styled by descendant selectors from the root (.button:hover .box, .button.pressed .box, .button[aria-disabled="true"] .box). Consumer overrides still win, because stylesProp?.box goes through Aphrodite (unlayered + !important) and beats everything in @layer shared.
  2. disabled is now selected in CSS, not JS. The [aria-disabled="true"] attribute comes from ButtonUnstyled, which keeps the element focusable. Note ButtonUnstyled renders a <button> (not an <a>) whenever disabled is set, even with an href, so the attribute is always present when it needs to be.
  3. pressed / focused still gate on !disabled in JS, matching the old array exactly — there is no .button[aria-disabled="true"].focused rule in the module because that combination can't occur.
  4. The plain chonky className is kept deliberately as a consumer/test hook even though it no longer drives styling — the same treatment data-kind got in the Button migration.

Behaviour is covered by the existing activity-button.test.tsx suite (roles, mouse handlers, icon aria-hidden), which passes unchanged. Visual parity is not covered — see the Chromatic note on the CSS file.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-skvgfsabpe.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 406
Tests with visual changes 0
Total stories 868
Inherited (not captured) snapshots [TurboSnap] 137
Tests on the build 543

@jandrade
jandrade marked this pull request as ready for review August 5, 2026 15:55
@khan-actions-bot
khan-actions-bot requested a review from a team August 5, 2026 15:56
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.00%. Comparing base (cd058a3) to head (08ad472).

Additional details and impacted files

Impacted file tree graph

@@       Coverage Diff       @@
##   WB-2328   #3168   +/-   ##
===============================
===============================

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update cd058a3...08ad472. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jandrade
jandrade force-pushed the WB-2328-activity-button branch from 08ad472 to ba5767a Compare August 5, 2026 19:27
@beaesguerra

Copy link
Copy Markdown
Member

@claude review once

@beaesguerra beaesguerra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

@@ -0,0 +1,380 @@
/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, there wasn't a diff in Chromatic! I checked in Storybook and things seem to be looking okay though! Any thoughts on this?

<>
{/* NOTE: Using a regular className to be able to use descendant selectors to account for the hover and press states */}
<View style={chonkyStyles} className="chonky">
<View style={chonkyStyles}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The box's className="chonky" was dropped during the Aphrodite→CSS Modules migration (old: <View style={chonkyStyles} className="chonky">, new: <View style={chonkyStyles}>), even though the changeset, PR description, and reviewer's inline comment all explicitly state it is being kept as a consumer/test hook.

Extended reasoning...

The diff removes className="chonky" from the box View in activity-button.tsx (line 74): the old code was <View style={chonkyStyles} className="chonky">, the new code is <View style={chonkyStyles}> with no className at all. This directly contradicts three places in this same PR where the author states the opposite intent: the shipped .changeset/css-modules-phase-4-activity-button.md says "The box keeps its plain chonky class name in the DOM as a consumer/test hook, it just no longer drives styling"; the PR description repeats this almost verbatim; and reviewer jandrade's inline comment on this file (point 4) says "The plain chonky className is kept deliberately as a consumer/test hook... the same treatment data-kind got in the Button migration." All three assert the class survives the migration — the code says otherwise.

The pattern is also the established convention for this component family: activity-icon-button.tsx:136 and node-icon-button.tsx:196 both still render their box View with className="chonky" specifically so it remains a stable DOM hook. ActivityButton is now the outlier among its siblings, and the only one where the documented behavior and the shipped code disagree.

A counter-argument was raised: the original code comment being deleted here ("Using a regular className to be able to use descendant selectors to account for the hover and press states") suggests chonky was originally just an Aphrodite implementation detail for descendant selectors, not a public contract, and that in this migration those descendant selectors move to the CSS-module .box class instead, making .chonky truly dead weight that was correctly deleted. That would be a reasonable read of the old code in isolation — but it doesn't resolve the contradiction: the author of this exact PR wrote, in the changeset and PR description, that the class is being deliberately retained going forward specifically as a consumer/test hook (not as a styling mechanism). Whether or not that was its original purpose, that is the documented intent for post-migration behavior, and the code doesn't match it.

Impact is bounded and this is why it lands as a nit rather than a blocking issue: styling is unaffected since the CSS Modules rules target .box, not .chonky (Chromatic reports 0 diffs), and no test or story in this repo queries .chonky for ActivityButton. The concrete risk is external: a downstream consumer or e2e test in the Khan frontend that queries the box via .chonky — a pattern the changeset explicitly promises still works — would silently break, and the shipped changeset text becomes factually inaccurate about the DOM output.

Proof:

  1. Before this PR: <View style={chonkyStyles} className="chonky"> renders <div class="chonky ...">.
  2. After this PR: <View style={chonkyStyles}> renders <div class="..."> with no chonky class.
  3. Any selector .chonky (browser test, snapshot, or consumer CSS override) that matched the box before this PR no longer matches it after.
  4. The changeset merged in this same commit claims the opposite: "The box keeps its plain chonky class name in the DOM."

Fix: re-add className="chonky" to the box View in activity-button.tsx (one line), matching activity-icon-button.tsx and node-icon-button.tsx.

Juan Andrade added 2 commits August 6, 2026 17:26
…utton to CSS Modules

Migrate `ActivityButton` from Aphrodite to CSS Modules, the piece that was
deliberately deferred when `Button` moved over (it had its own
`_generateStyles`). The public API is unchanged — same props, DOM structure and
`styles.root` / `styles.box` / `styles.startIcon` / `styles.endIcon` /
`styles.label` overrides — so this is an internal styling refactor.

The new `activity-button.module.css` follows the component-token surface
`Button` settled on: every value a variant axis can change is a
`--wb-c-activity-button--*` custom property, variant classes only *assign*
tokens, and the base + state rules only *read* them. The `actionType × kind`
colour matrix is six compound rules (`.progressive.primary`, …) because the
semantic tokens are keyed by both axes; disabled colours are keyed by `kind`
alone so they live on the kind classes. Structural values (border width, the
6px/8px/0 shadow depths) are written literally rather than codegen'd per theme,
matching the note the Aphrodite `theme` object carried — every theme reused
them.

The "chonky" box no longer gets its state styling from classes toggled in JS.
`chonkyPressed` / `chonkyDisabled` are gone; the box is styled by descendant
selectors from the root (`.button:hover .box`, `.button.pressed .box`,
`.button[aria-disabled="true"] .box`), all at the same specificity so source
order alone decides precedence: hover -> active -> disabled -> focus. The
plain `chonky` className stays on the element as a consumer/test hook, it just
no longer drives styling — same treatment `data-kind` got in `Button`.

The icon and label rules are qualified with `.box` on purpose. `PhosphorIcon`
(`.medium`) and `BodyText` (`.mediumSemi`) ship single-class rules in the same
`@layer shared`, and Aphrodite's `!important` used to settle that contest for
us; the compound selector makes the outcome depend on specificity instead of
which package's stylesheet happens to load first.

One intentional behaviour change: a disabled button that is both focused and
hovered now keeps the full focus ring. Previously the disabled `:hover` reset
was emitted after the `:focus-visible` rule at equal specificity and stripped
the outline, leaving only the inner box-shadow. That was an artifact of
Aphrodite's rule ordering, and `Button` already made the same call
(68ea60e).

Verified: `pnpm jest packages/wonder-blocks-button` (all 5 suites), typecheck,
eslint, stylelint, prettier, and a full rollup build all pass — the build emits
the ActivityButton rules into the package's `dist/index.css` with `@apply
--wb-focus-visible` expanded and everything wrapped in `@layer shared`. Visual
parity still needs a Chromatic diff review on this PR.

Issue: WB-2328

## Test plan:

Automated checks are green and cover behavior/structure/aria:
- `pnpm jest packages/wonder-blocks-button` — all 5 suites pass
- `pnpm typecheck`, `eslint`, `stylelint` — clean
- `pnpm rollup -c ./build-settings/rollup.config.mjs` — emits the 28
  `wb-activity-button-*` selectors into `packages/wonder-blocks-button/dist/index.css`

Manual (visual — not covered by the above):
1. Review the Chromatic diff on this PR. The only expected diff is the disabled
   + focus + hover cell of the ActivityButton StateSheet (see above); anything
   else must be investigated.
2. In Storybook, spot-check `ActivityButton` across `kind`
   (primary/secondary/tertiary) x `actionType` (progressive/neutral) and the
   hover / active / focus-visible / disabled states, in the default,
   thunderblocks and syl-dark themes. Confirm the box still lifts on hover and
   drops flat on press, and that the `WithCustomStyles` story's overrides
   (root/box/startIcon/endIcon/label) still take effect.
   http://localhost:6061/?path=/docs/packages-button-activitybutton--docs

## Review plan:

Please review these risky changes

1. 🚨 `packages/wonder-blocks-button/src/components/activity-button.module.css`:
   New file defining the entire visual matrix (colour tokens, the chonky box,
   hover/press/disabled/focus states) for a shared component; correctness of the
   visual output depends on Chromatic review.
2. ⚠️ `packages/wonder-blocks-button/src/components/activity-button.tsx`:
   Removes the Aphrodite `_generateStyles` / `theme` block and composes CSS
   Module classes through the `style` prop instead. The box's state classes are
   no longer toggled in JS.
@jandrade
jandrade force-pushed the WB-2328-activity-button branch from a2c2856 to 7caf2ea Compare August 6, 2026 22:26
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.

2 participants