Skip to content

chore(Icon): migrate to CSS Modules with visual regression baseline#1050

Merged
DreaminDani merged 5 commits into
mainfrom
chore/migrate-icon-css-modules
May 28, 2026
Merged

chore(Icon): migrate to CSS Modules with visual regression baseline#1050
DreaminDani merged 5 commits into
mainfrom
chore/migrate-icon-css-modules

Conversation

@DreaminDani
Copy link
Copy Markdown
Contributor

@DreaminDani DreaminDani commented May 27, 2026

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 in Icon.tsx, the polymorphic SvgImageElement.svg, and the gallery ResponsiveGridContainer in stories) with .module.css + cva/cn. DOM-identical, byte-for-byte visual parity verified.

Notes

  • Removing styled-components from SvgImageElement forces a tiny consumer prop rename: $size -> size in Flag.tsx, Logo.tsx, Payment.tsx, and Assets/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.
  • The original width: ${$width} interpolation emitted invalid CSS (e.g. width: 40 without unit), causing the SVG to fall back to its intrinsic width/height attributes. That quirk is preserved by stringifying the value into a CSS custom property used inside var(), which keeps the unit-less value invalid and triggers the same fallback path.
  • Stories use a small co-located Icon.stories.module.css for the responsive grid that previously lived in styled(GridContainer).

Verification

  • yarn test:visual tests/utils/icon.spec.ts passes with zero snapshot regenerations (37 tests, 36 PNGs)
  • yarn lint:css, yarn lint:code, yarn build all green
  • grep -r 'styled-components' src/components/Icon/ empty

Closes 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 (SvgWrapper in Icon.tsx) and shared SvgImageElement are reimplemented with Icon.module.css (sizes, semantic states, stroke/fill, custom color via --svg-icon-color, width/height via CSS variables). SvgImageElement is no longer a styled svg; it is a small polymorphic component with a public size prop, so Flag, Logo, Payment, and Assets/Icons call sites switch from transient $size to size.

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 from styled(GridContainer) to Icon.stories.module.css. A large tests/display/icon.spec.ts Playwright suite snapshots those stories in light and dark (plus one a11y check on aria-label).

Reviewed by Cursor Bugbot for commit 714f8dd. Bugbot is set up for automated code reviews on this repo. Configure here.

@DreaminDani DreaminDani self-assigned this May 27, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 27, 2026

🦋 Changeset detected

Latest commit: 714f8dd

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

This PR includes changesets to release 1 package
Name Type
@clickhouse/click-ui 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

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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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-components wrappers with CSS Modules + cva/cn for Icon and SvgImageElement.
  • Updated four asset call sites to use size instead 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.

Comment thread src/components/Icon/Icon.module.css
Comment thread src/components/Icon/Icon.stories.tsx Outdated
… 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.
DreaminDani added a commit that referenced this pull request May 27, 2026
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.
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.
@workflow-authentication-public
Copy link
Copy Markdown
Contributor

Storybook Preview Deployed

✅ Preview URL: https://click-g4twkisn6-clickhouse.vercel.app

Built from commit: 6c0612aebf9a638402293eb1d0c8e491864e3624

@DreaminDani DreaminDani merged commit 969de1a into main May 28, 2026
10 checks passed
@DreaminDani DreaminDani deleted the chore/migrate-icon-css-modules branch May 28, 2026 17:59
DreaminDani added a commit that referenced this pull request May 28, 2026
…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>
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.

3 participants