--- name: component-css-modules-migration description: Migrate a click-ui component from styled-components to CSS Modules with byte-for-byte visual regression coverage. Use whenever a component still uses styled-components and is on the CSS Modules migration list. --- # Component CSS Modules Migration A repeatable procedure for migrating one click-ui component at a time from `styled-components` to CSS Modules + `cva` / `cn`. The pattern was first executed end-to-end in PR #1034 (ButtonGroup), which is the canonical reference if anything here is ambiguous. The procedure is built around a strong, single claim: **the migration commit changes nothing visible**. Visual regression tests captured against the styled-components rendering must pass byte-for-byte against the CSS Modules rendering. No tolerance for "looks the same"; the snapshots either pass or they don't. ## Scope rule (hard line) The migration PR is a **pure styling refactor**. Everything below is **out of scope** even when tempting: - ARIA refinements (adding `aria-disabled`, removing redundant `role="button"`, plumbing `aria-label`) - Adding HTML attributes (`type="button"`, etc.) - CSS correctness fixes (`:hover:not(:disabled)`, `:focus-visible` outlines, explicit disabled+active rules that the original didn't have) - Consumer updates (e.g. adding `aria-label="..."` to existing consumers) - New props or JSDoc If the original styled-components has a bug (hover firing on disabled, missing focus ring, wrong disabled+active color), the migration **preserves the bug**. Fix it in a separate PR *after* the migration lands. The reason this rule exists: the visual regression test is credible *only* because the migration commit changed nothing visible. If you bundle in a "small" a11y improvement, snapshots regenerate, and the "byte-for-byte" guarantee evaporates. The rule also makes the migration mechanical — there are no judgment calls about which improvements to include. The only collateral change you may need: a narrow TypeScript widening (e.g. `HTMLAttributes` → `ButtonHTMLAttributes`) when the new test stories need a prop like `disabled` to typecheck. That's pure TS, no runtime effect — include it in the baseline commit and call it out. ## Prerequisites 1. The component still uses `styled-components` (check `src/components//.tsx`). 2. The component's design tokens already exist as CSS variables in `packages/design-tokens/dist/tokens.css` (search for `--click--*`). 3. The `Button` precedent is readable: `src/components/Button/Button.tsx` and `src/components/Button/Button.module.css`. 4. `yarn test:visual` and `yarn test:visual:update` work (they invoke `.scripts/bash/playwright-docker`, which runs Playwright inside a Linux container; both take a component name to scope the run, e.g. `yarn test:visual `). If they don't work, **fix the tooling first as its own commit** — don't work around it locally. ## Commit structure Two commits. Each must leave `main` green on its own. | # | Subject | One-line purpose | |---|---|---| | 1 | `test(): add visual regression baseline before CSS Modules migration` | Capture the current styled-components rendering as snapshots. | | 2 | `chore(): migrate styling from styled-components to CSS Modules` | Replace styled-components with `.module.css` + `cva`/`cn`. Snapshots from #1 still pass byte-for-byte. | If you need to fix repo tooling (broken script, version mismatch) to even run `yarn test:visual`, that goes in its own prep commit *before* commit 1 — e.g. `chore(test:visual): `. Don't bundle tooling fixes into the migration. ## Commit 1 — Visual regression baseline ### Files - `src/components//.stories.tsx` — Extend the existing stories. There must be exactly one named story per visual variant the spec wants to screenshot. Reuse the structure from `src/components/ButtonGroup/ButtonGroup.stories.tsx`. Required scenarios: each `type` variant, selected/active state, disabled state (including disabled+active if applicable), fill-width / size variants, multi-select if applicable. - **Each story must render the real component**, via `args` (preferred) or a `render` that returns the actual component. **Never** define a `*Harness`/wrapper component and render it inside `render` (e.g. `render: () => `). Storybook's "Show code" copies the story body verbatim, so a harness makes the copyable snippet `` instead of the real `` a consumer would write. - **Put any surrounding styling in a decorator, not in the story body.** When a story needs padding, a backdrop, or (for invisible primitives like Spacer/Separator) a contrasting block to make the component measurable, move that wrapper into a `decorators` entry — decorators are *not* included in "Show code". Use a meta-level decorator when every story in the file shares the wrapper (see `src/components/Alert/Alert.stories.tsx`); use a story-level decorator (a shared `Decorator` const spread into each story's `decorators`) when only some stories need it and others have bespoke renders (see `src/components/Icon/Icon.stories.tsx`, whose Playground and `Icons` gallery must stay unwrapped). A decorator can read the story's args via its second argument (`(Story, { args }) => …`) to vary layout per story (see `src/components/Separator/Separator.stories.tsx`). - **The decorator carries the `data-testid="-harness"` that the Playwright spec screenshots.** Keep the exact same test-id, element structure, and inline styles the wrapper had — the screenshot region and the snapshots stay byte-for-byte, and the spec's `harnessLocator` needs no change. - `tests//.spec.ts` — New Playwright spec. Pick `` carefully: - **Never put component specs under `tests/utils/`.** That folder is reserved for shared test helpers like `getStoryUrl` (`tests/utils/index.ts`). Mixing specs and helpers there confuses both human readers and Copilot reviewers. - Use an existing component-family folder if the component fits one (e.g. `tests/buttons/`, `tests/cards/`). - For primitives that just display content (Spacer, Separator, Text, Title, Label, GenericLabel, Icon, etc.), use **`tests/display/`**. - Establish a new folder only when the component genuinely starts a new family. Name it for the family (e.g. `tests/forms/`, `tests/overlays/`), not the component. **Required header — `@covers` directive.** Begin the spec file with a comment declaring which source directory it guards: ```ts // Affected-spec coverage for scoped visual-regression runs in CI. // See .scripts/js/affected-visual-specs // @covers src/components/ ``` CI reads these directives to run only the specs a PR's diff touches instead of the whole suite (`.github/workflows/visual-regression-tests.yml` → `.scripts/js/affected-visual-specs`). The same directives let you scope a local run by name — `yarn test:visual ` — so adding one immediately pays off in your own feedback loop. The resolver **throws if any spec lacks a `@covers` directive**, so the visual-regression job fails fast until you add one. Point it at the component's source directory — the resolver verifies the path exists. If the spec screenshots more than one component (e.g. an overview spec), add one `@covers` line per component directory. The visual specs navigate to Storybook stories by string id rather than importing the component, so this directive is the *only* link the resolver has between a `src/` change and its spec — there is no fallback. Mirror the structure from `tests/buttons/button.spec.ts` or `tests/buttons/buttongroup.spec.ts`. Cover: - Light + dark theme via `getStoryUrl(storyId, theme)` from `tests/utils/index.ts` (imported as `from '../utils'` from a sibling test folder) - Each variant story → snapshot - Interactive states (hover, focus) — call `page.locator('body').click()` before `Tab` to anchor focus into page content (not browser chrome — known flakiness fix) - Keyboard activation if relevant (Space, Enter) — works on native `