| 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.
-
Scan component files for icon imports:
grep -E "from.*icons|import.*Icon" packages/ui/src/elements/ComponentName/
-
List existing icons in packages/ui/src/icons/:
- Each icon has its own folder with
index.tsx + index.css
-
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?
-
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:
- Note the icon name from Figma design (e.g., "close", "chevron-down")
- Check both 16px and 24px variants if they exist
- Read the corresponding files and extract the SVG paths for each size
Icon implementation rules:
-
Props: Icon components MUST accept these props (keep existing props when updating):
type IconProps = {
readonly className?: string
readonly size?: 16 | 24
}
-
Multi-size support: Store path data keyed by size:
const paths = {
16: 'M4.854 4.146...',
24: 'M6.854 6.146...',
}
-
SVG rendering: Use the size prop to select path and viewBox:
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none">
<path d={paths[size]} fill="currentColor" />
</svg>
-
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)
-
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.
- Read component files:
packages/ui/src/elements/ComponentName/ or packages/ui/src/fields/ComponentName/
- Create
index.css with converted styles:
$var → var(--token)
- Keep CSS nesting with
& (preferred)
- Remove
@use/@import (tokens are global)
- Inline any mixins
- Update import:
import './index.scss' → import './index.css'
- Delete
index.scss
- Wrap in
@layer payload-default {}
- Convert legacy
var(--base) to --spacer tokens (see below)
- Check for SCSS-only variables (see below)
SCSS Variable Dependencies
CRITICAL: The packages/ui/src/scss/ folder is being deprecated. Any CSS variables defined there must be migrated to packages/ui/src/css/ before use.
Before using a variable, verify it exists in the CSS folder:
grep -r "variable-name" packages/ui/src/css/
If a variable is only in SCSS:
- Check if there's an equivalent in the CSS folder
- 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:
-
Direct match: If the result equals a spacer token, use it directly:
padding: var(--base);
padding: var(--spacer-4);
-
Calculated values: When exact pixel value is important, use calc():
gap: calc(var(--base) * 0.5);
gap: calc(var(--spacer-1) * 2.5);
-
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) | |
-
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.
-
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):
.block {
&__element {
color: red;
}
&__other {
color: blue;
}
}
.block { ... }
.block__element { color: red; }
.block__other { color: blue; }
2. BEM modifier concatenation (&--modifier):
.block {
&--active {
background: blue;
}
}
.block { ... }
.block--active { background: blue; }
3. Parent reference from child:
.child {
opacity: 0.5;
.parent--active & {
opacity: 1;
}
}
.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.
-
Get metadata first to discover variants:
mcp_figma2_get_metadata(fileKey, nodeId)
-
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
-
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] |
-
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)
-
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
-
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
-
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)
-
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
-
Use canonical shorthands — see the shorthand table in .claude/skills/ui4-review/SKILL.md.
-
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
-
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.
-
Read the test collection config:
test/v4/collections/{ComponentName}/index.ts
-
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' } } |
-
Add missing variants if any are missing. Include defaultValue for disabled/readOnly so there's visible content to test.
-
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:
- Call
browser_handle_dialog({ accept: true }) to dismiss
- 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:
- Navigate:
browser_navigate to component page
- Screenshot:
browser_take_screenshot({ fullPage: true })
- Compare to Figma design
- Check:
- Verify ALL variant states:
- If wrong: fix CSS → goto step 1
- 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:
@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.
-
Create test file in test/v4/collections/{ComponentName}/e2e.spec.ts
-
Test structure:
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 })
})
})
-
Write tests for each Figma variant:
Map the variant matrix from Step 2 to test cases:
test('default state renders correctly', async () => {
await page.goto(url.create)
const field = page.locator('#field-componentName')
await expect(field).toBeVisible()
})
test('hover state shows correct styling', async () => {
await page.goto(url.create)
const field = page.locator('#field-componentName')
await field.hover()
})
test('focus state shows correct styling', async () => {
await page.goto(url.create)
const field = page.locator('#field-componentName')
await field.focus()
})
test('error state renders correctly', async () => {
await page.goto(url.create)
await page.locator('button#action-save').click()
const field = page.locator('#field-requiredComponent')
})
test('disabled state renders correctly', async () => {
await page.goto(url.create)
const field = page.locator('#field-disabledComponent')
await expect(field).toBeDisabled()
})
test('read-only state renders correctly', async () => {
await page.goto(url.create)
const field = page.locator('#field-readOnlyComponent')
await expect(field).toHaveAttribute('readonly')
})
-
Collection variants already configured: (See Step 4)
The test collection should already have all required variants from Step 4.
-
Run tests to verify:
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:
- Scan all changed CSS files
- Auto-fix any remaining hardcoded values
- 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