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/
-
- Each icon has its own folder with +
-
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:
(e.g.,
,
)
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):
typescript
type IconProps = {
readonly className?: string
readonly size?: 16 | 24 // Add more sizes as needed
// ... keep any existing component-specific props
}
-
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
}
-
SVG rendering: Use the size prop to select path and viewBox:
tsx
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none">
<path d={paths[size]} fill="currentColor" />
</svg>
-
Payload conventions:
- Use instead of
- Use and (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 with converted styles:
- →
- Keep CSS nesting with (preferred)
- Remove / (tokens are global)
- Inline any mixins
- Update import: →
- Delete
- Wrap in
@layer payload-default {}
- Convert legacy to tokens (see below)
- Check for SCSS-only variables (see below)
SCSS Variable Dependencies
CRITICAL: The
folder has been removed. All global tokens now live in
. 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:
- Check if there's an equivalent in the CSS folder
- If not, add it to the appropriate CSS file:
- — spacers, gutters, layout spacing, breakpoints
- — color tokens
- — font tokens
- — border-radius tokens
- — accessibility, misc utilities
Common SCSS-only variables to watch for:
| SCSS Variable | CSS Equivalent / Action |
|---|
| Defined in |
| Defined in (1024px) |
| Defined in (768px) |
| Defined in |
| Use var(--breakpoint-m-width)
in media queries |
| Use @media (max-width: 1024px)
|
| Use @media (max-width: 768px)
|
Legacy Token Migration: →
What is ? A legacy spacing token equal to
(1.25rem). It must be replaced with
tokens.
Spacer token values:
| Token | Value | Pixels |
|---|
| 0 | 0px |
| 4px | 4px |
| 8px | 8px |
| 12px | 12px |
| 16px | 16px |
| 24px | 24px |
| 32px | 32px |
| 40px | 40px |
Conversion strategy:
-
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 */
-
Calculated values: When exact pixel value is important, use
:
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);
-
ALWAYS round to nearest spacer token. Never use
to preserve non-standard pixel values. Round all calculated values to the nearest token:
| Pixel Range | Token | Notes |
|---|
| 0-2px | | Use 0 |
| 3-6px | (4px) | 5-6px rounds to 4px |
| 7-10px | (8px) | 10px rounds DOWN to 8px |
| 11-14px | (12px) | 13.33px rounds to 12px |
| 15-20px | (16px) | 15px, 20px both round to 16px |
| 21-28px | (24px) | |
| 29-36px | (32px) | 30px rounds to 32px |
| 37-48px | (40px) | |
-
Common conversions (base = 20px):
| Original | Pixels | Rounded Token |
|---|
| 5px | (4px) |
| 6px | (4px) |
| 8px | |
| 10px | (8px) |
| 12px | |
| 13.3px | (12px) |
| 15px | (16px) |
| 16px | |
| 20px | (16px) or (24px) |
| 24px | |
| 30px | (32px) |
| 40px | |
| 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
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
token directly.
CRITICAL: SCSS nesting patterns that DON'T work in CSS:
1. BEM element concatenation ():
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 ():
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:
- , , (pseudo-classes)
- , (pseudo-elements)
- (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 | |
| Focus | None | false | false | |
| Default | Invalid | false | false | or |
| Default | None | true | false | |
| Default | None | false | true | , |
-
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: , , ,
- Spacing: (ALWAYS check file for matching value)
- Typography: ,
- Radius:
--radius-none/small/medium/large/full
- Focus states:
--accessibility-focus-color
(NEVER use directly)
-
Focus state rules:
- Always use
--accessibility-focus-color
for focus outlines/borders
- Use (not ) for keyboard-only focus
- Standard focus outline:
outline: 1px solid var(--accessibility-focus-color)
- For parent containers: use to detect child focus
-
Use canonical shorthands — see the shorthand table in
.agents/skills/ui4-review/SKILL.md
.
-
Color rules — NEVER GUESS:
- Always extract exact token from Figma design context — the 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 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
for disabled/readOnly so there's visible content to test.
-
Restart dev server if collection was modified:
Step 5: Verify with Playwright (LOOP)
Dev Server: Use
when working on field components. The
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
to close the tab, then
to reopen the page fresh.
Verification Steps:
- 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
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.
-
Create test file in
test/v4/collections/{ComponentName}/e2e.spec.ts
-
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
})
-
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
})
-
Collection variants already configured: (See Step 4)
The test collection should already have all required variants from Step 4.
-
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 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 → conversion table
- v4 test suite: — dedicated collections per field type
- Each collection should have: default, required, disabled, readOnly field variants
- Disabled/readOnly fields need for visible content
- Run with:
- URL:
http://localhost:3000/admin/collections/{slug}/create
- Available: , , , , , , , , , , , , , , , , , , , ,
- E2E test examples: See
test/fields/collections/*/e2e.spec.ts
for patterns
- Test helper imports from
test/__helpers/e2e/helpers.js
- Use for URL construction
- Use for test setup