--- name: migrate-canvas-styled-to-tailwind description: Migrate an apollo-react canvas component from Emotion styled-components to apollo-wind Tailwind classes following the BaseNode reference migration patterns --- # Migrate Canvas Styled Components to Tailwind ## Overview Migrate canvas components in `packages/apollo-react/src/canvas/` from Emotion (`@emotion/styled`, `@emotion/react`) to Tailwind classes provided by `apollo-wind`. The goal is zero runtime CSS-in-JS: every component should use static Tailwind class strings (or CSS custom properties for dynamic dimensions), with `cn()` from `apollo-wind` used only where class-level overrides are needed (e.g. conditional border colors that must beat a base class). ## When to Use - Migrating any `*.styles.ts` file under `packages/apollo-react/src/canvas/` - Component currently imports from `@emotion/styled` or `@emotion/react` - Component uses `styled.*` or the `css` helper ## Reference Files (live in repo) These files are the canonical examples of the completed migration. Read them before starting: - `packages/apollo-react/src/canvas/components/BaseNode/BaseNodeContainer.tsx` — Pattern C (conditional classes with `cn()`) - `packages/apollo-react/src/canvas/components/BaseNode/BaseNodeInnerShape.tsx` — Pattern A (inline static class string + CSS custom properties) - `packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBadgeSlot.tsx` — Pattern D (inline styles for positional offsets) - `packages/apollo-react/src/canvas/components/BaseNode/NodeLabel.tsx` — Decomposed sub-components (Header, SubHeader, EditableLabel, EmptyLabelPlaceholder) using `cx()` for non-conflicting conditional classes - `packages/apollo-react/src/canvas/components/BaseNode/BaseNodeMissingManifest.tsx` — Simple composition of the above primitives - `packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx` — Parent component: CSS custom property setup via `useMemo`, wiring to sub-components - `packages/apollo-react/src/canvas/constants.ts` — Extracted magic numbers - `packages/apollo-wind/src/styles/tailwind.utilities.css` — Custom Tailwind `@keyframes` and `@utility` definitions (see `animate-glow`) ## Step-by-Step Process ### Step 1: Audit the Styles File Read the `*.styles.ts` file and catalog every styled component. For each one, identify: - **Static styles** — CSS that never changes (borders, flex layout, font sizes) - **Prop-driven styles** — CSS that varies by prop (shape, status, size) - **Dynamic numeric values** — Pixel values computed from props (widths, heights, offsets) - **Animations / keyframes** — Any `@emotion/react` keyframe usage Create a migration plan showing which pattern (see Step 2) applies to each styled component. **Get user approval before proceeding.** ### Step 2: Choose the Right Pattern for Each Styled Component #### Pattern A: Static Tailwind Classes (preferred) Use when all styles are known at build time. Inline the class string directly in the JSX — a string literal is the simplest and most readable form. **Before (Emotion):** ```tsx const BaseIconWrapper = styled.div<{ shape?: string }>` display: flex; align-items: center; justify-content: center; background: var(--canvas-background-secondary); color: var(--canvas-foreground); border-radius: ${({ shape }) => shape === 'circle' ? '50%' : '8px'}; `; ``` **After (Tailwind):** ```tsx export const BaseInnerShape = memo(({ children }: Props) => (
{children}
)); ``` Note: the shape-dependent border-radius moved to a CSS custom property (`--inner-radius`) computed by the parent. This lets the child's className be fully static. #### Pattern B: CSS Custom Properties for Dynamic Dimensions Use when numeric dimensions are computed from props (node width/height, scale factors). Set `--custom-props` via `style` on a **single parent wrapper**, then reference them with static Tailwind classes on children using `w-(--varname)` or `[border-radius:var(--varname)]` syntax. **Before (Emotion):** ```tsx // Styled component with complex dimension calculations in template literal const BaseContainer = styled.div<{ shape?: string; width?: number; height?: number }>` width: ${({ shape, width }) => { const defaultWidth = shape === 'rectangle' ? 288 : 96; return width ?? defaultWidth; }}px; height: ${({ height }) => height ?? 96}px; border-radius: ${({ shape }) => shape === 'circle' ? '50%' : '16px'}; `; ``` **After (Tailwind + CSS vars):** ```tsx // Parent computes vars once in useMemo const nodeVars = useMemo((): React.CSSProperties => ({ '--node-w': `${containerWidth}px`, '--node-h': numH ? `${numH}px` : 'auto', '--node-radius': shape === 'circle' ? '50%' : `${radius}px`, } as React.CSSProperties), [containerWidth, numH, shape]); // Wrapper applies vars; all children use static classes
...
``` Key points: - Compute CSS variable values in `useMemo` with relevant deps. - Cast to `React.CSSProperties` (TS doesn't know about custom properties). - Children's `className` strings are **static** — they never change, which is the perf win. #### Pattern C: Conditional Classes with `cn()` Use when Tailwind classes must **override** each other (e.g. `border-brand` must beat the base `border-border`). Import `cn` from `@uipath/apollo-wind`. Wrap in `useMemo` to avoid recomputation. **Before (Emotion):** ```tsx const BaseContainer = styled.div<{ selected?: boolean; executionStatus?: string }>` border: 1.5px solid var(--canvas-border-de-emp); ${({ executionStatus }) => getExecutionStatusBorder(executionStatus)} ${({ selected }) => selected && css` border-color: var(--canvas-primary); outline: 4px solid var(--canvas-secondary-pressed); `} `; ``` **After (Tailwind + cn):** ```tsx import { cn } from '@uipath/apollo-wind'; const className = useMemo( () => cn( 'border-2 border-border bg-surface-overlay', // base getStatusBorder(activeStatus), // override isSelected && 'border-brand', // override interactionState === 'disabled' && 'opacity-50 cursor-not-allowed', ), [activeStatus, isSelected, interactionState] ); ``` Use `cn()` **only** when you need class-level override semantics (conflicting utilities on the same CSS property). For classes that don't conflict, plain string concatenation or the local `cx()` utility from `../../utils/CssUtil` is sufficient and faster. #### Pattern D: Minimal Inline Styles for Truly Dynamic One-Off Values Use for values that are both dynamic and don't warrant a CSS custom property (e.g. a single `background` color from a prop, or positional offsets from a constant). **Before (Emotion):** ```tsx const BaseBadgeSlot = styled.div<{ position: string; shape?: string }>` position: absolute; width: 20px; height: 20px; ${({ position, shape }) => { const offset = shape === 'circle' ? '12px' : '6px'; switch (position) { case 'top-left': return `top: ${offset}; left: ${offset};`; ... } }} `; ``` **After:** ```tsx export const BaseBadgeSlot = memo(({ position, shape, children }: Props) => { const offset = shape === 'circle' ? NODE_BADGE_INSET_CIRCLE : NODE_BADGE_INSET_SQUARE; const style: React.CSSProperties = { width: NODE_BADGE_SIZE, height: NODE_BADGE_SIZE }; switch (position) { case 'top-left': style.top = offset; style.left = offset; break; ... } return (
{children}
); }); ``` Pass `undefined` (not `{}`) for `style` when there's no dynamic value, so React skips the style attribute entirely. ### Step 3: Extract Magic Numbers to Constants Move all pixel literals, grid multiples, and ratio calculations to `packages/apollo-react/src/canvas/constants.ts`. Name them descriptively and document the design reference. **Before (buried in styled component):** ```tsx const GRID_UNIT = 16; const NODE_HEIGHT_DEFAULT = GRID_UNIT * 6; // 96px ``` **After (in constants.ts):** ```ts export const GRID_SPACING = 16; export const NODE_HEIGHT_DEFAULT = GRID_SPACING * 6; // 96px export const NODE_CONTAINER_RADIUS_RATIO = 32 / DEFAULT_NODE_SIZE; // ~0.333 ``` ### Step 4: Decompose the Monolithic Styles File Replace the single `*.styles.ts` with focused component files. Each new file should: - Export **one** component (or a small cohesive group) - Own its own `interface` for props - Use `memo` where the component is a leaf / pure-render - Live alongside the parent component (e.g. `BaseNodeContainer.tsx` next to `BaseNode.tsx`) Naming convention: `{ParentComponent}{Role}.tsx` (e.g. `BaseNodeContainer.tsx`, `BaseNodeInnerShape.tsx`, `BaseNodeBadgeSlot.tsx`). ### Step 5: Convert Styled Components One by One For each styled component: 1. **Create the replacement file** with the appropriate pattern from Step 2. 2. **Update imports** in the parent component to use the new file instead of `*.styles.ts`. 3. **Move prop logic out of CSS** — conditional styles that were in template literals become: - Ternary expressions in `className` for simple cases - `cn()` calls for override semantics - Switch/map helper functions that return full literal class strings for multi-value mappings (see `getStatusBorder()` in `BaseNodeContainer.tsx`) 4. **Remove emotion imports** (`css`, `styled`, `keyframes`) from the parent. 5. **Delete the styled component** from the styles file once its replacement is wired in. ### Step 6: Handle Animations and Keyframes If the styles file uses `@emotion/react` keyframes: 1. Define the `@keyframes` rule in `packages/apollo-wind/src/styles/tailwind.utilities.css`. 2. Create a `@utility` that applies the animation (see `animate-glow` in that file). 3. Use the utility as a Tailwind class in the component. **Important: lightningcss bug.** Avoid `color-mix()` or `var()` inside `@keyframes` blocks — lightningcss drops them silently. Instead, compute the mixed value in the `@utility` body using an internal `--_private-var` and reference that var in the keyframe: ```css @keyframes apollo-glow { 0% { box-shadow: 0 0 0 0 var(--_glow-shadow, currentColor); } 70% { box-shadow: 0 0 0 10px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } } @utility animate-glow { --_glow-shadow: color-mix( in srgb, var(--glow-color, currentColor) var(--glow-strength, 40%), transparent ); will-change: box-shadow; animation: apollo-glow 2s infinite; } ``` Consumers parameterize via `[--glow-color:var(--error)]` arbitrary value classes. ### Step 7: Update Tests - Mock `cn` from `@uipath/apollo-wind` in unit tests (simple concatenation is fine): ```ts vi.mock('@uipath/apollo-wind', () => ({ cn: (...args: unknown[]) => args.flat(Infinity).filter((v): v is string => typeof v === 'string' && v.length > 0).join(' '), })); ``` - Update any test assertions that relied on emotion-generated class names or inline style objects. - Add `data-testid` and `data-*` attributes on containers for test queries (e.g. `data-execution-status`, `data-interaction-state`). ### Step 8: Delete the Styles File Once every styled component has been replaced and the parent no longer imports from `*.styles.ts`, delete the file. ### Step 9: Verify ```bash pnpm build pnpm test pnpm lint ``` Check Storybook visually for regressions if stories exist for the component. ## Tailwind Class Reference (apollo-wind) ### Common Mappings | Emotion pattern | Tailwind equivalent | |---|---| | `display: flex` | `flex` | | `align-items: center` | `items-center` | | `justify-content: center` | `justify-center` | | `position: relative` | `relative` | | `position: absolute` | `absolute` | | `cursor: pointer` | `cursor-pointer` | | `border: 2px solid var(--canvas-border)` | `border-2 border-border` | | `border-radius: 50%` | `rounded-full` | | `border-radius: 16px` | `rounded-2xl` | | `font-size: 13px` | `text-sm` | | `font-size: 11px` | `text-xs` | | `font-weight: 600` | `font-semibold` | | `opacity: 0.5` | `opacity-50` | | `overflow: hidden` | `overflow-hidden` | | `white-space: nowrap; text-overflow: ellipsis` | `whitespace-nowrap text-ellipsis` | | `-webkit-line-clamp: 3` | `line-clamp-3` | | `word-break: break-word` | `wrap-break-word` | | Dynamic width via CSS var | `w-(--varname)` | | Dynamic height via CSS var | `h-(--varname)` | | Arbitrary CSS property via var | `[border-radius:var(--varname)]` | | Child selector `svg { width: X }` | `[&>svg]:w-(--icon-size)` | ### Color Token Mappings | Emotion CSS variable | Tailwind class | |---|---| | `var(--canvas-foreground)` | `text-foreground` | | `var(--canvas-foreground-de-emp)` | `text-foreground-muted` | | `var(--canvas-background)` | `bg-surface-overlay` | | `var(--canvas-background-secondary)` | `bg-surface` | | `var(--canvas-border-de-emp)` | `border-border` | | `var(--canvas-primary)` | `border-brand` | | `var(--canvas-error-icon)` | `border-error` or `text-error` | | `var(--canvas-success-icon)` | `border-success` | | `var(--canvas-warning-icon)` | `border-warning` | | `var(--canvas-info)` | `border-info` | ### Tailwind v4 Gotchas - **All class names must be literal strings.** Tailwind v4 statically scans source files. Never interpolate class names: `` `border-${color}` `` won't work. Use a switch/map that returns full literal strings. - **`color-mix()` with `var()` inside `@keyframes`** is dropped by lightningcss. Compute it outside the keyframe in a `@utility` body. - **`field-sizing: content`** — use `field-sizing-content` (custom utility if needed). ## Anti-Patterns to Avoid | Don't | Do instead | |---|---| | Keep `@emotion/styled` alongside Tailwind in the same component | Fully migrate the component; no hybrid state | | Use `cn()` everywhere | Use `cn()` only when classes conflict/override; use static strings or `cx()` otherwise | | Put `useMemo` around static class strings | Inline static strings directly in JSX | | Create inline style objects on every render | Use CSS custom properties on a parent + static Tailwind classes on children | | Add new `*.styles.ts` files | Colocate styles as Tailwind classes in the component file | | Use Tailwind `@apply` in CSS files | Use Tailwind classes directly in JSX | ## Checklist Before marking migration complete: - [ ] `*.styles.ts` file deleted - [ ] No `@emotion/styled` or `@emotion/react` imports remain in the component tree - [ ] All magic numbers moved to `constants.ts` - [ ] New sub-components use `memo` where appropriate - [ ] Tests updated and passing - [ ] Storybook stories render correctly (visual check) - [ ] Build passes (`pnpm build`) - [ ] Lint passes (`pnpm lint`)