con un clic
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
Review UI4 CSS migrations for proper token usage. Checks that CSS variables are used instead of hardcoded values.
Use when CI tests fail on main branch after PR merge, when investigating flaky test failures, or when user provides a PR URL/number to aggregate all failing tests
Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
Use when UI changes are complete and e2e tests need updating. Analyzes what changed in UI components and systematically finds/fixes affected tests.
Use when fixing dependency vulnerabilities, running pnpm audit, or when the audit-dependencies CI check fails
Use when new translation keys are added to packages to generate new translations strings
| 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