with one click
ui4
// Manually invoked skill for reskinning Payload UI components. Requires Figma URL. Usage: /ui4
// Manually invoked skill for reskinning Payload UI components. Requires Figma URL. Usage: /ui4
| name | ui4 |
| description | Manually invoked skill for reskinning Payload UI components. Requires Figma URL. Usage: /ui4 |
Figma URL is REQUIRED. If not provided, ask before proceeding.
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/:
index.tsx + index.cssCompare Figma design to available icons:
Document findings:
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:
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:
fill="currentColor" instead of fill="var(--color-icon)"fillRule and clipRule (React camelCase) instead of kebab-caseReference 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.
Goal: Syntax conversion only. Component must look IDENTICAL after.
packages/ui/src/elements/ComponentName/ or packages/ui/src/fields/ComponentName/index.css with converted styles:
$var → var(--token)& (preferred)@use/@import (tokens are global)import './index.scss' → import './index.css'index.scss@layer payload-default {}var(--base) to --spacer tokens (see below)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:
spacing.css — spacers, gutters, layout spacing, breakpointscolors.css — color tokenstypography.css — font tokensradius.css — border-radius tokensutilities.css — accessibility, misc utilitiesCommon 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) |
var(--base) → --spacerWhat 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-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):
// 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.
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=falseBuild 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):
mcp_figma2_get_design_context(fileKey, variantNodeId)
Compare visual differences between variants:
If access fails: STOP. Ask user to share file.
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 tokensUpdate styles using tokens from files:
--bg-*, --text-*, --icon-*, --border-*--spacer-* (ALWAYS check file for matching value)--text-body-*, --text-heading-*--radius-none/small/medium/large/full--accessibility-focus-color (NEVER use --color-border-selected directly)Focus state rules:
--accessibility-focus-color for focus outlines/borders:focus-visible (not :focus) for keyboard-only focusoutline: 1px solid var(--accessibility-focus-color):has(:focus-visible) to detect child focusUse canonical shorthands — see the shorthand table in .claude/skills/ui4-review/SKILL.md.
Color rules — NEVER GUESS:
get_design_context response includes CSS with token namesSpacing rules:
--spacer-* tokenGoal: 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
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:
http://localhost:3000/admin/collections/{field-type}-fields/createHandling 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:
browser_handle_dialog({ accept: true }) to dismissIf the dialog persists after handling, call browser_close() to close the tab, then browser_navigate to reopen the page fresh.
Verification Steps:
browser_navigate to component pagebrowser_take_screenshot({ fullPage: true })browser_hover)Share screenshot and dev server URL. User validates or requests changes.
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);
}
}
}
| 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 |
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 })
})
// 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"
After user confirms the component looks correct, invoke the ui4-review skill.
This will:
packages/ui/src/elements/Button/index.csspackages/ui/src/css/*.cssvar(--base) → --spacer conversion tabletest/v4/ — dedicated collections per field type
defaultValue for visible contentpnpm run dev v4http://localhost:3000/admin/collections/{slug}/createtext-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-fieldstest/fields/collections/*/e2e.spec.ts for patterns
test/__helpers/e2e/helpers.jsAdminUrlUtil for URL constructioninitPayloadE2ENoConfig for test setup