chore(Icon): migrate to CSS Modules with visual regression baseline#1050
Conversation
🦋 Changeset detectedLatest commit: 714f8dd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
Address Copilot's standing review feedback on the migration PRs: tests/utils/ is reserved for shared helpers like getStoryUrl; component specs belong in component-family folders. Migration skill updated in PR #1049 to codify this for future migrations. Visual regression remains byte-for-byte: 37/37 snapshots match without regeneration.
There was a problem hiding this comment.
Pull request overview
Migrates the shared Icon component styling from styled-components to CSS Modules while adding Playwright visual regression coverage to lock in rendering parity across themes, sizes, states, overrides, and asset-backed variants (flags/logos/payments).
Changes:
- Added Storybook harness stories + Playwright screenshot spec and baselines for icon variants in light/dark.
- Replaced
styled-componentswrappers with CSS Modules +cva/cnforIconandSvgImageElement. - Updated four asset call sites to use
sizeinstead of the transient styled-components prop$size.
Reviewed changes
Copilot reviewed 11 out of 47 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/utils/icon.spec.ts | Adds Playwright visual regression coverage for Icon variants (light/dark). |
| src/components/Icon/SvgImageElement.tsx | Converts SvgImageElement from styled svg to a normal component using CSS Modules + cva. |
| src/components/Icon/Icon.tsx | Replaces SvgWrapper styled-component with CSS Module classes and CSS custom props for overrides. |
| src/components/Icon/Icon.stories.tsx | Adds harness stories used by visual tests; replaces responsive grid styled-component with CSS module class. |
| src/components/Icon/Icon.stories.module.css | Implements responsive grid styling previously in styled-components. |
| src/components/Icon/Icon.module.css | New CSS Module for wrapper sizing/state styling and asset svg sizing. |
| src/components/Assets/Payments/system/Payment.tsx | Renames $size prop usage to size for SvgImageElement. |
| src/components/Assets/Logos/system/Logo.tsx | Renames $size prop usage to size for SvgImageElement. |
| src/components/Assets/Icons/system/Icon.tsx | Renames $size prop usage to size for SvgImageElement. |
| src/components/Assets/Flags/system/Flag.tsx | Renames $size prop usage to size for SvgImageElement. |
| .changeset/migrate-icon-to-css-modules.md | Publishes the migration as a patch changeset. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… prettier Address Copilot review feedback on PR #1050: type ResponsiveGridContainer as `ComponentProps<typeof GridContainer>` and merge className via `cn` so callers can extend the wrapper without losing the responsive-grid class. Also runs prettier on the file to clear pre-existing code-quality CI failures from the migration commit (one-line JSX expressions that prettier wants on multiple lines). Auto-format, no behavior change. The unaddressed Copilot point — `circle[fill]` appearing in both the stroke and fill rules of Icon.module.css — is a pre-existing bug that the migration deliberately preserved per the byte-for-byte parity rule (`circle[stroke]` was missing from the original styled-components source too). Fix in a separate PR after this lands.
The IconHarness wrapper applied a hardcoded grey #888 background that made sense for Spacer/Separator (where you need contrast against an otherwise invisible primitive) but hurt Icon's stories — it muddied the state pills (success/warning/danger/info badges) and washed out glyphs whose visibility doesn't need any artificial contrast against Storybook's page background. Drop the backdrop and the 24px padding; keep the wrapper as a testid-only inline-flex container so the Playwright locator still attaches. All 36 Icon snapshots regenerated. No component code changes; stories/spec layout only.
Storybook Preview Deployed✅ Preview URL: https://click-g4twkisn6-clickhouse.vercel.app Built from commit: |
…1047) * test(Label): add visual regression baseline before CSS Modules migration * chore(Label): migrate styling from styled-components to CSS Modules * test(TextField): adapt label-color assertion to CSS Modules migration The previous assertion relied on styled-components injecting the default label color (rgb(179, 182, 189)) into the jsdom stylesheet at render time. Now that Label uses CSS Modules, jsdom does not load the `.label { color: var(--click-field-color-label-default) }` rule, and the computed color falls back to canvastext. The test's actual intent is to verify that no `labelColor` override is applied when the prop is unset. Assert that directly via the absence of an inline color style. Default-color rendering is covered by visual regression in tests/display/label.spec.ts. The second test (custom labelColor) is unchanged: the InputWrapper's StyledLabel wrapper is still styled-components and continues to inject its color rule into jsdom. * fix(Label, TextField test): resolve unit-tests and code-quality CI failures - Prettier wants the Label component destructure on one line; reformat to match. - The earlier `getAttribute('style')` assertion returned `null` when no style attribute was present, which `.not.toContain('color')` rejected. Use `element.style.color` instead, which returns `''` for "no inline color set" — the actual semantic we want to verify. * fix(Label): keep modifier specificity at 0-1-0 so InputWrapper labelColor still wins Bugbot flagged that the new .label.label_error and .label.label_disabled compound selectors raised modifier specificity to 0-2-0, while the original styled-components emitted state rules at single-class 0-1-0. InputWrapper's StyledLabel applies the `labelColor` override via a styled-components-generated single-class selector (0-1-0). In the original, `labelColor` won by cascade order; with the compound selectors it was silently overridden whenever error or disabled was active. Refactor the pseudo-class rules to use :where(), which contributes zero specificity, and drop the .label.label_modifier compound form for single-class .label_error / .label_disabled. The modifiers now sit at 0-1-0 — tied with StyledLabel's labelColor but losing on source order, which restores the original behavior where labelColor always wins. Label-in-isolation rendering is unchanged: 10/10 visual snapshots match byte-for-byte. The fix is observable only in combination with InputWrapper, which isn't covered by Label's own spec. * chore(Label): move spec from tests/utils/ to tests/display/ Match the convention established in PRs #1045, #1046, #1048, and #1050 and codified in the migration skill: tests/utils/ is reserved for shared helpers like getStoryUrl, primitives go in tests/display/. Visual regression remains byte-for-byte: 10/10 snapshots match without regeneration. * fix(Label stories): use unique ids per harness so labels target their own input Multiple stories rendered on the same docs page were sharing a hardcoded 'label-harness-input' id, so clicking any label highlighted the first input on the page. useId gives each harness instance its own stable id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(Label stories): nest input inside Label instead of pairing by id useId() resets per React root, and Storybook's autodocs renders each story in its own root — so every story still produced the same id. Drop the explicit htmlFor/id and let the implicit label-input association handle clicks per story instance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(Label): restore hover/focus colors when InputWrapper sets labelColor Hover/focus rules were wrapped in :where() so .label_error and .label_disabled (both 0-1-0) could be overridden by InputWrapper's labelColor styled-components class (also 0-1-0, injected later). That also dropped hover/focus to 0-1-0, so labelColor silently won over them in the default state too — regressing the original styled-components behavior, which only emitted hover/focus rules when neither disabled nor error was set. Replace :where() with :not(.label_error, .label_disabled), which both preserves the conditional behavior and raises specificity to 0-3-0 so hover/focus beat labelColor in the default state. The error/disabled modifiers stay at 0-1-0 so labelColor still wins there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commits
test(Icon): add visual regression baseline before CSS Modules migration— adds stories, Playwright spec, and fresh PNG snapshots that capture the current rendering across size, state, custom-color/width-height, and asset (flag/logo/payment) variants in light and dark themes.chore(Icon): migrate styling from styled-components to CSS Modules— replaces all three styled-components (the SvgWrapper inIcon.tsx, the polymorphicSvgImageElement.svg, and the galleryResponsiveGridContainerin stories) with.module.css+cva/cn. DOM-identical, byte-for-byte visual parity verified.Notes
SvgImageElementforces a tiny consumer prop rename:$size->sizeinFlag.tsx,Logo.tsx,Payment.tsx, andAssets/Icons/system/Icon.tsx. These four call-site updates are an unavoidable consequence of dropping the transient styled-components prop and are bundled into the migration commit so main stays green.width: ${$width}interpolation emitted invalid CSS (e.g.width: 40without unit), causing the SVG to fall back to its intrinsicwidth/heightattributes. That quirk is preserved by stringifying the value into a CSS custom property used insidevar(), which keeps the unit-less value invalid and triggers the same fallback path.Icon.stories.module.cssfor the responsive grid that previously lived instyled(GridContainer).Verification
yarn test:visual tests/utils/icon.spec.tspasses with zero snapshot regenerations (37 tests, 36 PNGs)yarn lint:css,yarn lint:code,yarn buildall greengrep -r 'styled-components' src/components/Icon/emptyCloses CUI-36
Note
Medium Risk
Icon is a core design-system surface; the migration is broad but guarded by extensive visual regression tests and a minimal public API tweak ($size→size on internal SvgImageElement consumers).
Overview
Icon styling moves off styled-components onto CSS Modules plus
cva/cn, with a patch changeset and the stated goal of unchanged visuals.The glyph wrapper (
SvgWrapperinIcon.tsx) and sharedSvgImageElementare reimplemented withIcon.module.css(sizes, semantic states, stroke/fill, custom color via--svg-icon-color, width/height via CSS variables).SvgImageElementis no longer a styledsvg; it is a small polymorphic component with a publicsizeprop, so Flag, Logo, Payment, and Assets/Icons call sites switch from transient$sizetosize.Storybook adds an
IconHarness(data-testid="icon-harness") and many focused stories (sizes, states, custom color/dimensions, flag/logo/payment assets); the icon gallery’s responsive grid moves fromstyled(GridContainer)toIcon.stories.module.css. A largetests/display/icon.spec.tsPlaywright suite snapshots those stories in light and dark (plus one a11y check onaria-label).Reviewed by Cursor Bugbot for commit 714f8dd. Bugbot is set up for automated code reviews on this repo. Configure here.