ui4

star 43.1k

Manually invoked skill for reskinning Payload UI components. Requires Figma URL. Usage: /ui4

payloadcms By payloadcms schedule Updated 5/22/2026

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.

  1. Scan component files for icon imports:

    grep -E "from.*icons|import.*Icon" packages/ui/src/elements/ComponentName/
    
  2. List existing icons in packages/ui/src/icons/:

    • Each icon has its own folder with index.tsx + index.css
  3. 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?
  4. 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:

  1. Note the icon name from Figma design (e.g., "close", "chevron-down")
  2. Check both 16px and 24px variants if they exist
  3. Read the corresponding files and extract the SVG paths for each size

Icon implementation rules:

  1. 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
    }
    
  2. 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
    }
    
  3. 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>
    
  4. 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)
  5. 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.

  1. Read component files: packages/ui/src/elements/ComponentName/ or packages/ui/src/fields/ComponentName/
  2. Create index.css with converted styles:
    • $varvar(--token)
    • Keep CSS nesting with & (preferred)
    • Remove @use/@import (tokens are global)
    • Inline any mixins
  3. Update import: import './index.scss'import './index.css'
  4. Delete index.scss
  5. Wrap in @layer payload-default {}
  6. Convert legacy var(--base) to --spacer tokens (see below)
  7. 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:

  1. Check if there's an equivalent in the CSS folder
  2. 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:

  1. 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 */
    
  2. 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);
    
  3. 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)
  4. 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.

  5. 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.

  1. Get metadata first to discover variants:

    mcp_figma2_get_metadata(fileKey, nodeId)
    
  2. 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
  3. 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]
  4. 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)
    
  5. 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

  1. 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
  2. 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)
  3. 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
  4. Use canonical shorthands — see the shorthand table in .claude/skills/ui4-review/SKILL.md.

  5. 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
  6. 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.

  1. Read the test collection config:

    test/v4/collections/{ComponentName}/index.ts
    
  2. 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' } }
  3. Add missing variants if any are missing. Include defaultValue for disabled/readOnly so there's visible content to test.

  4. 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:

  1. Call browser_handle_dialog({ accept: true }) to dismiss
  2. 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:

  1. Navigate: browser_navigate to component page
  2. Screenshot: browser_take_screenshot({ fullPage: true })
  3. Compare to Figma design
  4. Check:
    • Padding correct?
    • Margin correct?
    • Gap correct?
    • Flex alignment correct?
    • Colors match?
  5. 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)
  6. If wrong: fix CSS → goto step 1
  7. 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.

  1. Create test file in test/v4/collections/{ComponentName}/e2e.spec.ts

  2. 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
    })
    
  3. 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
    })
    
  4. Collection variants already configured: (See Step 4)

    The test collection should already have all required variants from Step 4.

  5. 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:

  1. Scan all changed CSS files
  2. Auto-fix any remaining hardcoded values
  3. 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
Install via CLI
npx skills add https://github.com/payloadcms/payload --skill ui4
Repository Details
star Stars 43,066
call_split Forks 3,805
navigation Branch main
article Path SKILL.md
More from Creator