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
- 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: 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 // Add more sizes as needed // ... keep any existing component-specific props }Multi-size support: Store path data keyed by size:
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:
<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 offill="var(--color-icon)" - Use
fillRuleandclipRule(React camelCase) instead of kebab-case - Default size should match most common usage (typically 24)
- Use
Reference implementation: See
packages/ui/src/icons/Chevron/index.tsxfor 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/orpackages/ui/src/fields/ComponentName/ - Create
index.csswith 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--spacertokens (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, breakpointscolors.css— color tokenstypography.css— font tokensradius.css— border-radius tokensutilities.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:
/* 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
calc():/* 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
calc()to preserve non-standard pixel values. Round all calculated values to the nearest token:Pixel Range Token Notes 0-2px --spacer-0Use 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.255px --spacer-1(4px)var(--base) * 0.36px --spacer-1(4px)var(--base) * 0.48px --spacer-2var(--base) * 0.510px --spacer-2(8px)var(--base) * 0.612px --spacer-2-5var(--base) / 1.513.3px --spacer-2-5(12px)var(--base) * 0.7515px --spacer-3(16px)var(--base) * 0.816px --spacer-3var(--base)20px --spacer-3(16px) or--spacer-4(24px)var(--base) * 1.224px --spacer-4var(--base) * 1.530px --spacer-5(32px)var(--base) * 240px --spacer-6var(--base) * 360px calc(var(--spacer-4) * 2.5)— only use calc for values > 40pxRule: 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):
// SCSS - WORKS (produces .block__element)
.block {
&__element {
color: red;
}
&__other {
color: blue;
}
}
/* 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 - WORKS (produces .block--active)
.block {
&--active {
background: blue;
}
}
/* CSS - DOES NOT WORK! Use flat selector: */
.block { ... }
.block--active { background: blue; }
3. Parent reference from child:
// SCSS - WORKS
.child {
opacity: 0.5;
.parent--active & {
opacity: 1;
}
}
/* 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.
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 :hoverFocus None false false :focus-visibleDefault Invalid false false .erroror&--errorDefault None true false .is-selectedDefault 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 tokenspackages/ui/src/css/colors.css— color tokenspackages/ui/src/css/typography.css— text tokenspackages/ui/src/css/radius.css— border-radius tokenspackages/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-selecteddirectly)
- Colors:
Focus state rules:
- Always use
--accessibility-focus-colorfor 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
- Always use
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_contextresponse 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
- Always extract exact token from Figma design context — the
Spacing rules:
- First choice: use
--spacer-*token - If no match: use rem and tell user
- NEVER use px (except 1px borders)
- First choice: use
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.tsCheck 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
defaultValuefor 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_navigateto component page - Screenshot:
browser_take_screenshot({ fullPage: true }) - Compare to Figma design
- Check:
- Padding correct?
- Margin correct?
- Gap correct?
- Flex alignment correct?
- Colors match?
- 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)
- 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.tsTest 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 }) }) // Test each variant from the Figma design })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() // 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:
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)→--spacerconversion 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
defaultValuefor 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.tsfor patterns- Test helper imports from
test/__helpers/e2e/helpers.js - Use
AdminUrlUtilfor URL construction - Use
initPayloadE2ENoConfigfor test setup
- Test helper imports from