--- name: ui4 description: Manually invoked skill for reskinning Payload UI components. Requires Figma URL. Usage: /ui4 --- # Payload UI Reskin (ui4) **Figma URL is REQUIRED.** If not provided, ask before proceeding. --- ## Process ### Step 0: Icon Scan **Goal:** Identify icon dependencies before starting work. 1. **Scan component files** for icon imports: ``` grep -E "from.*icons|import.*Icon" packages/ui/src/elements/ComponentName/ ``` 2. **List existing icons** in `packages/ui/src/icons/`: - Each icon has its own folder with `index.tsx` + `index.css` 3. **Compare Figma design** to available icons: - Does the design use icons not currently in the component? - Does the design use icons that don't exist yet? 4. **Document findings:** - **Existing & used:** No action needed - **Existing but not imported:** Will need to add import - **Missing from codebase:** Flag for user — need to source/create icon **Figma Icons Source:** When updating or creating icons, reference the Figma icon library at: ``` ~/figma/figma/fpl/icons/src/icons/ ``` Icon naming convention: `icon-{size}-{name}.tsx` (e.g., `icon-16-close.tsx`, `icon-24-chevron-down.tsx`) To find the correct icon: 1. Note the icon name from Figma design (e.g., "close", "chevron-down") 2. Check both 16px and 24px variants if they exist 3. Read the corresponding files and extract the SVG paths for each size **Icon implementation rules:** 1. **Props:** Icon components MUST accept these props (keep existing props when updating): ```typescript type IconProps = { readonly className?: string readonly size?: 16 | 24 // Add more sizes as needed // ... keep any existing component-specific props } ``` 2. **Multi-size support:** Store path data keyed by size: ```typescript const paths = { 16: 'M4.854 4.146...', // from icon-16-{name}.tsx 24: 'M6.854 6.146...', // from icon-24-{name}.tsx } ``` 3. **SVG rendering:** Use the size prop to select path and viewBox: ```tsx ``` 4. **Payload conventions:** - Use `fill="currentColor"` instead of `fill="var(--color-icon)"` - Use `fillRule` and `clipRule` (React camelCase) instead of kebab-case - Default size should match most common usage (typically 24) 5. **Reference implementation:** See `packages/ui/src/icons/Chevron/index.tsx` for the pattern. **If icons are missing from Figma source:** Ask user how to proceed before continuing. --- ### Step 1: SCSS → CSS Migration **Goal:** Syntax conversion only. Component must look IDENTICAL after. 1. Read component files: `packages/ui/src/elements/ComponentName/` or `packages/ui/src/fields/ComponentName/` 2. Create `index.css` with converted styles: - `$var` → `var(--token)` - Keep CSS nesting with `&` (preferred) - Remove `@use`/`@import` (tokens are global) - Inline any mixins 3. Update import: `import './index.scss'` → `import './index.css'` 4. Delete `index.scss` 5. Wrap in `@layer payload-default {}` 6. **Convert legacy `var(--base)` to `--spacer` tokens** (see below) 7. **Check for SCSS-only variables** (see below) --- #### SCSS Variable Dependencies **CRITICAL:** The `packages/ui/src/scss/` folder has been removed. All global tokens now live in `packages/ui/src/css/`. Any CSS variable you use must exist there. **Before using a variable, verify it exists in the CSS folder:** ```bash grep -r "variable-name" packages/ui/src/css/ ``` **If a variable is only in SCSS:** 1. Check if there's an equivalent in the CSS folder 2. If not, add it to the appropriate CSS file: - `spacing.css` — spacers, gutters, layout spacing, breakpoints - `colors.css` — color tokens - `typography.css` — font tokens - `radius.css` — border-radius tokens - `utilities.css` — accessibility, misc utilities **Common SCSS-only variables to watch for:** | SCSS Variable | CSS Equivalent / Action | | ----------------------- | ------------------------------------------------ | | `--spacing-view-bottom` | Defined in `spacing.css` | | `--breakpoint-m-width` | Defined in `spacing.css` (1024px) | | `--breakpoint-s-width` | Defined in `spacing.css` (768px) | | `--gutter-h` | Defined in `spacing.css` | | `$breakpoint-m-width` | Use `var(--breakpoint-m-width)` in media queries | | `@include mid-break` | Use `@media (max-width: 1024px)` | | `@include small-break` | Use `@media (max-width: 768px)` | --- #### Legacy Token Migration: `var(--base)` → `--spacer` **What is `--base`?** A legacy spacing token equal to `20px` (1.25rem). It must be replaced with `--spacer-*` tokens. **Spacer token values:** | Token | Value | Pixels | | -------------- | ----- | ------ | | `--spacer-0` | 0 | 0px | | `--spacer-1` | 4px | 4px | | `--spacer-2` | 8px | 8px | | `--spacer-2-5` | 12px | 12px | | `--spacer-3` | 16px | 16px | | `--spacer-4` | 24px | 24px | | `--spacer-5` | 32px | 32px | | `--spacer-6` | 40px | 40px | **Conversion strategy:** 1. **Direct match:** If the result equals a spacer token, use it directly: ```css /* Before: var(--base) = 20px → closest is --spacer-3 (16px) or --spacer-4 (24px) */ padding: var(--base); /* After: Choose semantically correct size */ padding: var(--spacer-4); /* if 24px is acceptable */ ``` 2. **Calculated values:** When exact pixel value is important, use `calc()`: ```css /* Before: calc(var(--base) * 0.5) = 10px */ gap: calc(var(--base) * 0.5); /* After: calc(var(--spacer-1) * 2.5) = 10px */ gap: calc(var(--spacer-1) * 2.5); ``` 3. **ALWAYS round to nearest spacer token.** Never use `calc()` to preserve non-standard pixel values. Round all calculated values to the nearest token: | Pixel Range | Token | Notes | | ----------- | --------------------- | ----------------------------- | | 0-2px | `--spacer-0` | Use 0 | | 3-6px | `--spacer-1` (4px) | 5-6px rounds to 4px | | 7-10px | `--spacer-2` (8px) | 10px rounds DOWN to 8px | | 11-14px | `--spacer-2-5` (12px) | 13.33px rounds to 12px | | 15-20px | `--spacer-3` (16px) | 15px, 20px both round to 16px | | 21-28px | `--spacer-4` (24px) | | | 29-36px | `--spacer-5` (32px) | 30px rounds to 32px | | 37-48px | `--spacer-6` (40px) | | 4. **Common `var(--base)` conversions** (base = 20px): | Original | Pixels | Rounded Token | | -------------------- | ------ | --------------------------------------------------------------- | | `var(--base) * 0.25` | 5px | `--spacer-1` (4px) | | `var(--base) * 0.3` | 6px | `--spacer-1` (4px) | | `var(--base) * 0.4` | 8px | `--spacer-2` | | `var(--base) * 0.5` | 10px | `--spacer-2` (8px) | | `var(--base) * 0.6` | 12px | `--spacer-2-5` | | `var(--base) / 1.5` | 13.3px | `--spacer-2-5` (12px) | | `var(--base) * 0.75` | 15px | `--spacer-3` (16px) | | `var(--base) * 0.8` | 16px | `--spacer-3` | | `var(--base)` | 20px | `--spacer-3` (16px) or `--spacer-4` (24px) | | `var(--base) * 1.2` | 24px | `--spacer-4` | | `var(--base) * 1.5` | 30px | `--spacer-5` (32px) | | `var(--base) * 2` | 40px | `--spacer-6` | | `var(--base) * 3` | 60px | `calc(var(--spacer-4) * 2.5)` — only use calc for values > 40px | **Rule:** For values ≤ 40px, ALWAYS use a single token. For values > 40px, use `calc()` with a spacer token. 5. **Check Figma design:** The best approach is to check the Figma design for the intended spacing value and use the matching `--spacer-*` token directly. --- **CRITICAL: SCSS nesting patterns that DON'T work in CSS:** **1. BEM element concatenation (`&__element`):** ```scss // SCSS - WORKS (produces .block__element) .block { &__element { color: red; } &__other { color: blue; } } ``` ```css /* CSS - DOES NOT WORK! &__element is invalid */ /* You must use flat selectors: */ .block { ... } .block__element { color: red; } .block__other { color: blue; } ``` **2. BEM modifier concatenation (`&--modifier`):** ```scss // SCSS - WORKS (produces .block--active) .block { &--active { background: blue; } } ``` ```css /* CSS - DOES NOT WORK! Use flat selector: */ .block { ... } .block--active { background: blue; } ``` **3. Parent reference from child:** ```scss // SCSS - WORKS .child { opacity: 0.5; .parent--active & { opacity: 1; } } ``` ```css /* CSS - DOES NOT WORK! Restructure: */ .child { opacity: 0.5; } .parent--active .child { opacity: 1; } ``` **What DOES work in CSS nesting:** - `&:hover`, `&:focus`, `&:active` (pseudo-classes) - `&::before`, `&::after` (pseudo-elements) - `.parent { .child { } }` (descendant nesting with space) **Migration rule:** Convert all `&__` and `&--` to flat BEM selectors. **Post-migration validation:** After creating the CSS file, run the ui4-review skill to catch any remaining violations (SCSS nesting patterns, hardcoded values, legacy variables). Fix any issues before proceeding. ### Step 2: Analyze Figma Component Variants **Goal:** Understand ALL visual states before implementing CSS. 1. **Get metadata first** to discover variants: ``` mcp_figma2_get_metadata(fileKey, nodeId) ``` 2. **Parse variant properties** from symbol names. Common patterns: - `State=Default, Validation=None, Selected=false, Read Only=false` - Properties often include: State, Validation, Selected, Disabled, Read Only, Size 3. **Build a variant matrix:** | State | Validation | Selected | Read Only | CSS Mapping | | ------- | ---------- | -------- | --------- | -------------------------- | | Default | None | false | false | base styles | | Hover | None | false | false | `:hover` | | Focus | None | false | false | `:focus-visible` | | Default | Invalid | false | false | `.error` or `&--error` | | Default | None | true | false | `.is-selected` | | Default | None | false | true | `.read-only`, `[disabled]` | 4. **Fetch design context for key variants** (in parallel): - Default unselected - Selected - Hover - Focus - Invalid/Error - Disabled/Read-only ``` mcp_figma2_get_design_context(fileKey, variantNodeId) ``` 5. **Compare visual differences** between variants: - Border color changes? - Background color changes? - Inner element visibility/opacity? - Focus ring/outline? - Text color changes? **If access fails:** STOP. Ask user to share file. ### Step 3: Restyle to Match Figma 1. **Read token files** (do this BEFORE writing CSS): - `packages/ui/src/css/spacing.css` — spacer tokens - `packages/ui/src/css/colors.css` — color tokens - `packages/ui/src/css/typography.css` — text tokens - `packages/ui/src/css/radius.css` — border-radius tokens - `packages/ui/src/css/utilities.css` — accessibility tokens 2. **Update styles** using tokens from files: - Colors: `--bg-*`, `--text-*`, `--icon-*`, `--border-*` - Spacing: `--spacer-*` (ALWAYS check file for matching value) - Typography: `--text-body-*`, `--text-heading-*` - Radius: `--radius-none/small/medium/large/full` - **Focus states: `--accessibility-focus-color`** (NEVER use `--color-border-selected` directly) 3. **Focus state rules:** - Always use `--accessibility-focus-color` for focus outlines/borders - Use `:focus-visible` (not `:focus`) for keyboard-only focus - Standard focus outline: `outline: 1px solid var(--accessibility-focus-color)` - For parent containers: use `:has(:focus-visible)` to detect child focus 4. **Use canonical shorthands** — see the shorthand table in `.claude/skills/ui4-review/SKILL.md`. 5. **Color rules — NEVER GUESS:** - **Always extract exact token from Figma design context** — the `get_design_context` response includes CSS with token names - **Don't assume hierarchy** — e.g., don't assume "less prominent = tertiary". Check the design. - **When creating new elements** (icons, buttons, etc.), fetch the specific Figma node to get correct colors - If Figma shows a raw hex value, map it to the closest token and note this for user review 6. **Spacing rules:** - First choice: use `--spacer-*` token - If no match: use rem and tell user - NEVER use px (except 1px borders) ### Step 4: Ensure Test Collection Has All Variants **Goal:** Before visual verification, ensure the test collection has field variants for all states. 1. **Read the test collection config:** ``` test/v4/collections/{ComponentName}/index.ts ``` 2. **Check for required variants** based on Figma variant matrix from Step 2: | Figma Variant | Required Field Config | | ---------------- | ----------------------------------------------------------------------------------------- | | Default | `{ name: 'default', type: 'component' }` | | Required | `{ name: 'required', type: 'component', required: true }` | | Disabled | `{ name: 'disabled', type: 'component', admin: { disabled: true }, defaultValue: '...' }` | | Read Only | `{ name: 'readOnly', type: 'component', admin: { readOnly: true }, defaultValue: '...' }` | | With Description | `{ name: 'withDescription', type: 'component', admin: { description: 'Help text' } }` | 3. **Add missing variants** if any are missing. Include `defaultValue` for disabled/readOnly so there's visible content to test. 4. **Restart dev server** if collection was modified: `pnpm run dev v4` --- ### Step 5: Verify with Playwright (LOOP) **Dev Server:** Use `pnpm run dev v4` when working on field components. The `test/v4` suite has dedicated collections for each field type with various states (default, required, disabled). **URL Pattern:** - Fields: `http://localhost:3000/admin/collections/{field-type}-fields/create` - Elements: Use the appropriate page that displays the element **Handling Modal Dialogs (beforeunload):** When the browser has unsaved changes, a "beforeunload" dialog may block ALL Playwright operations. You'll see this error pattern: ``` ### Error Error: Tool "browser_snapshot" does not handle the modal state. ### Modal state - ["beforeunload" dialog with message ""]: can be handled by browser_handle_dialog ``` **BEFORE retrying any operation**, you MUST dismiss the dialog: 1. Call `browser_handle_dialog({ accept: true })` to dismiss 2. Then retry your intended operation (navigate, snapshot, screenshot, etc.) If the dialog persists after handling, call `browser_close()` to close the tab, then `browser_navigate` to reopen the page fresh. **Verification Steps:** 1. Navigate: `browser_navigate` to component page 2. Screenshot: `browser_take_screenshot({ fullPage: true })` 3. Compare to Figma design 4. Check: - [ ] Padding correct? - [ ] Margin correct? - [ ] Gap correct? - [ ] Flex alignment correct? - [ ] Colors match? 5. **Verify ALL variant states:** - [ ] Default state matches Figma default variant? - [ ] Hover state matches Figma hover variant? (use `browser_hover`) - [ ] Focus state matches Figma focus variant? (tab to element) - [ ] Error state matches Figma invalid variant? (trigger validation) - [ ] Disabled/read-only matches Figma disabled variant? - [ ] Selected state matches Figma selected variant? (if applicable) 6. **If wrong:** fix CSS → goto step 1 7. **If correct:** continue ### Step 6: User Confirmation Share screenshot and dev server URL. User validates or requests changes. --- ## CSS Structure Always use `@layer` and CSS nesting: ```css @layer payload-default { .component { display: flex; gap: var(--spacer-2); padding: var(--spacer-2) var(--spacer-3); background: var(--bg-default-secondary); border: 1px solid var(--border-default-default); border-radius: var(--radius-medium); &__header { display: flex; gap: var(--spacer-1); } &--error { border-color: var(--border-danger-strong); } &:hover { background: var(--bg-default-secondary-hover); } } } ``` --- ## Red Flags - STOP | Thought | Reality | | ------------------------------ | ---------------------------------------- | | "I'll use flat selectors" | Use CSS nesting with `&` | | "I'll use 8px here" | Read spacing.css, use token | | "No matching spacer" | Did you actually check spacing.css? | | "I'll guess the colors" | Read colors.css, use exact token | | "Close enough" | Screenshot and compare to Figma | | "Skip verification" | Always run Playwright loop | | "Just need the default state" | Get metadata first, analyze ALL variants | | "I'll figure out states later" | Build variant matrix BEFORE writing CSS | --- ## Step 7: Write Variant E2E Tests **Goal:** Create e2e tests that verify all visual variants from the Figma design. 1. **Create test file** in `test/v4/collections/{ComponentName}/e2e.spec.ts` 2. **Test structure:** ```typescript import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' import { ensureCompilationIsDone, initPageConsoleErrorCatch, } from '../../../__helpers/e2e/helpers.js' import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js' import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' import { componentFieldsSlug } from '../../slugs.js' const filename = fileURLToPath(import.meta.url) const currentFolder = path.dirname(filename) const dirname = path.resolve(currentFolder, '../../') const { beforeAll, describe } = test let page: Page let serverURL: string let url: AdminUrlUtil describe('ComponentName Field Variants', () => { beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) url = new AdminUrlUtil(serverURL, componentFieldsSlug) const context = await browser.newContext() page = await context.newPage() initPageConsoleErrorCatch(page) await ensureCompilationIsDone({ page, serverURL }) }) // Test each variant from the Figma design }) ``` 3. **Write tests for each Figma variant:** Map the variant matrix from Step 2 to test cases: ```typescript test('default state renders correctly', async () => { await page.goto(url.create) const field = page.locator('#field-componentName') await expect(field).toBeVisible() // Verify visual properties match Figma default variant }) test('hover state shows correct styling', async () => { await page.goto(url.create) const field = page.locator('#field-componentName') await field.hover() // Verify hover styles match Figma hover variant }) test('focus state shows correct styling', async () => { await page.goto(url.create) const field = page.locator('#field-componentName') await field.focus() // Verify focus ring/outline matches Figma focus variant }) test('error state renders correctly', async () => { await page.goto(url.create) // Trigger validation by submitting without required field await page.locator('button#action-save').click() const field = page.locator('#field-requiredComponent') // Verify error styling matches Figma invalid variant }) test('disabled state renders correctly', async () => { await page.goto(url.create) const field = page.locator('#field-disabledComponent') await expect(field).toBeDisabled() // Verify disabled styling matches Figma disabled variant }) test('read-only state renders correctly', async () => { await page.goto(url.create) const field = page.locator('#field-readOnlyComponent') await expect(field).toHaveAttribute('readonly') // Verify read-only styling matches Figma read-only variant }) ``` 4. **Collection variants already configured:** (See Step 4) The test collection should already have all required variants from Step 4. 5. **Run tests to verify:** ```bash pnpm run test:e2e --grep "ComponentName Field Variants" ``` --- ## Step 8: Run ui4-review **After user confirms the component looks correct, invoke the `ui4-review` skill.** This will: 1. Scan all changed CSS files 2. Auto-fix any remaining hardcoded values 3. Report what was fixed/flagged --- ## Reference - Example migrated component: `packages/ui/src/elements/Button/index.css` - Token files: `packages/ui/src/css/*.css` - **Legacy token migration:** See Step 1 for `var(--base)` → `--spacer` conversion table - **v4 test suite:** `test/v4/` — dedicated collections per field type - Each collection should have: default, required, disabled, readOnly field variants - Disabled/readOnly fields need `defaultValue` for visible content - Run with: `pnpm run dev v4` - URL: `http://localhost:3000/admin/collections/{slug}/create` - Available: `text-fields`, `textarea-fields`, `email-fields`, `number-fields`, `password-fields`, `checkbox-fields`, `select-fields`, `relationship-fields`, `upload-fields`, `slug-fields`, `code-fields`, `json-fields`, `collapsible-fields`, `group-fields`, `tabs-fields`, `point-fields`, `radio-fields`, `row-fields`, `array-fields`, `blocks-fields`, `date-fields` - **E2E test examples:** See `test/fields/collections/*/e2e.spec.ts` for patterns - Test helper imports from `test/__helpers/e2e/helpers.js` - Use `AdminUrlUtil` for URL construction - Use `initPayloadE2ENoConfig` for test setup