---
name: contrast-audit
description: Runs a WCAG 2.2 contrast audit on a provided color palette against given background colors (solid and gradient), grouping results into Pass/Fail with ratios, best-fit recommendations, and optional visual reference output on canvas.
---

# Contrast Audit

Run a comprehensive WCAG 2.2 contrast audit on a user-provided color palette against specified background colors. This skill works within a **fixed palette** — never suggest modifying, lightening, or darkening colors. Only evaluate combinations within the supplied palette.

---

## Running this with Claude and the Figma MCP

Written for Figma's built-in agent, adapted for Claude (Claude Code, Claude Desktop or claude.ai) connected to the official Figma MCP server.

- **Get the file.** If you don't have a Figma link, ask for one. The file key is the segment after `/design/`; a `node-id=1-2` parameter means node `1:2`.
- **Start a session.** Call `create_session` with the file key before any other Figma tool, and pass the returned session ID on every later call.
- **Read before writing.** `get_metadata` shows the structure and the user's current selection, `get_variable_defs` lists the variables on a node, `search_design_system` finds published components, variables and styles, and `get_screenshot` shows what's on the canvas.
- **Write with `use_figma`.** It runs Plugin API JavaScript in the file. Load the `figma-use` skill first (plus `figma-generate-library` when you create variables, styles or components) and list them in `skillNames`. Scripts use top-level `await` and `return` everything they create: nothing persists between calls, so pass node and variable IDs forward as string literals. Colors are 0–1, not 0–255. Switch pages with `await figma.setCurrentPageAsync(page)`, at most once per call; for multi-page work, run one call per page in parallel. `figma.notify()` isn't available.
- **Ask, then wait.** Wherever a step says to ask the user, ask in chat (in Claude Code, use the AskUserQuestion tool) and wait for the answer before building.
- **Clear helper fills.** `figma.createAutoLayout()` frames start with a white fill. Set `fills = []` on rows and wrappers that should be transparent, or they show up as white bars (most visibly in dark-mode previews).
- **Check the result.** After a build step, take one screenshot (`await node.screenshot()` inside `use_figma`, or `get_screenshot`) and fix only what's wrong.

## Step 1 — Gather Inputs

Read the user's request and selected layers. Identify:

1. **Foreground palette** — the set of colors to test (e.g. specialty colors, brand colors). Extract color names and hex values from the selected frames/swatches.
2. **Background set** — the surfaces the foreground colors will sit on. Extract from selected frames. These may be solid fills or gradient fills.
3. **Use case** — what the colors are for (e.g. avatar backgrounds, icon fills, tag colors, button fills).

If any of these are unclear or missing, ask the user before proceeding.

---

## Step 2 — Clarifying Questions

Before running the audit, ask the user these questions (skip any already answered in their request):

### 2a. Text layer

Ask: **"Is text part of this component?"**

- If **yes** → run two-pass analysis:
  - **Pass 1 (component vs background):** Test foreground palette colors against all backgrounds at **3:1** (WCAG non-text graphic threshold).
  - **Pass 2 (text vs component):** For each color that passes in Pass 1, test which palette colors achieve **4.5:1** (AA normal text) when used as text on that component fill. Text candidates should be pulled from the full available palette (including neutrals, if provided).
- If **no** → run single-pass analysis: test foreground palette against backgrounds at the appropriate threshold (3:1 for non-text graphics, 4.5:1 for text, based on use case).

### 2b. Strokes

Ask: **"Do you want stroke recommendations — with strokes, without strokes, or both?"**

- If **with strokes** or **both** → include a stroke analysis section testing neutral/available colors for viable strokes at 3:1 against each background group.
- If **without strokes** → skip stroke analysis entirely.

### 2c. Visual output

Ask: **"Would you like visual reference frames built on the canvas, or text-only analysis?"**

- If **visuals** → build component grids on each background (solid and gradient) after analysis is complete (see Step 6).
- If **text-only** → deliver results as formatted tables in chat.

### 2d. Interactive states

Ask: **"Does this component have interactive states (hover, active, focus, disabled)?"**

- If **yes** → identify ALL state-indicator elements:
  - **Focus rings/indicators** — outlines or rings shown on keyboard focus. These are non-text graphics requiring **3:1** against the background. Test the focus ring color against every background in the mode group.
  - **Hover/active fills** on transparent-base components (e.g. outline or ghost buttons) — these are **state indicators**, not standalone graphics (see Step 4e for correct contrast logic).
  - **Disabled states** — typically reduced opacity. Note the effective contrast at the disabled opacity level.
- Confirm which states to include (Default, Hover, Active, Focus, Disabled) and test **every state against every background** — never omit states from any background, including gradients and secondary surfaces.
- If **no** → test default appearance only.

---

## Step 3 — Classify Backgrounds

### 3a. Mode classification

Classify each background as **light** or **dark** mode using frame naming conventions:

- Look for `light`, `dark`, `day`, `night` in the frame name (case-insensitive).
- If naming is ambiguous or absent for any background, ask the user: **"I can't tell if [frame name] is a light or dark surface. Would you like me to auto-classify by luminance (≥50% = light, <50% = dark), or would you prefer to tell me which group each one belongs to?"**

### 3b. Test ALL surfaces per mode

**Critical:** Test against EVERY surface in each mode group — not just one representative surface. A design system typically has multiple surface levels (Surface 1, 2, 3), table backgrounds, system backgrounds, etc. A color passes only if it meets the threshold against ALL backgrounds in its group, using worst-case contrast.

Example: if the user provides Surface Light 1 (#f3f3f3), Surface Light 2 (#e7e7e7), Surface Light 3 (#cececf), Table Light (#fafafa), and Sys BG Light 2 (#def4ff) — test against ALL of them. Surface 3 is typically the most challenging because it's the darkest light surface, giving the least contrast with dark foreground colors.

### 3c. Gradient detection

Inspect each background frame's fills. If any background has a gradient fill (linear, radial, angular, or diamond), **automatically include it** in the audit — do not skip gradients or require the user to mention them separately. Note which backgrounds are gradients for the output.

### 3d. Gradient sampling

For gradient backgrounds, sample the gradient color at **5 evenly spaced points** (0%, 25%, 50%, 75%, 100%) along the gradient axis. Use the **worst-case contrast** (lowest ratio) across all sample points as the gradient's effective contrast for that foreground color.

Gradient color interpolation formula — for a position `t` between two stops:

```
r = stop1.r + (stop2.r - stop1.r) * ((t - stop1.position) / (stop2.position - stop1.position))
```

Same for g and b channels.

---

## Step 4 — Compute Contrast

### 4a. Color math

Use the following formulas (WCAG 2.2 relative luminance):

**sRGB to linear:**

```
channel_lin = (c <= 0.03928) ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4
```

where `c` = channel value 0–1 (i.e. 0–255 divided by 255).

**Relative luminance:**

```
L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
```

**Contrast ratio:**

```
ratio = (L_lighter + 0.05) / (L_darker + 0.05)
```

### 4b. Worst-case across backgrounds

For each foreground color, compute its contrast ratio against **every** background in its mode group (all light backgrounds OR all dark backgrounds, including gradients). The color's effective ratio for that mode is the **worst-case** (lowest ratio) across all backgrounds in the group. A color "passes" only if it meets the threshold against ALL backgrounds in its group.

### 4c. Thresholds

Apply the appropriate WCAG 2.2 threshold based on use case:

- **3:1** — non-text graphics (icons, avatar shapes, UI controls, borders, focus rings)
- **4.5:1** — normal text (AA), body copy, labels, captions
- **3:1** — large text (AA), 18pt+ regular or 14pt+ bold
- **7:1** — normal text (AAA), enhanced contrast

### 4d. WCAG level annotation

For each passing color, annotate its WCAG level based on the worst-case ratio:

- >= 7:1 → **AAA Text**
- >= 4.5:1 → **AA Text**
- >= 3:1 → **Non-text** (or **Large Text AA**)
- < 3:1 → **Fail**

### 4e. State-indicator fills (transparent-base components)

When a transparent-base component (e.g. outline button, ghost button) gains a subtle background fill on hover or active states, that fill is a **state indicator** — it signals interactivity, not a standalone graphic element.

**Correct contrast checks for state-indicator fills:**

- **Text vs fill** — readability: the user must still read the text, and the fill is now the effective background behind it. Check at **4.5:1** (or the text threshold in use).
- **Border vs page background** — visibility: if the component has a border, it still needs **3:1** against the actual page/surface background (not the fill).
- **Do NOT check fill vs page background** — this produces false failures because state-indicator fills are intentionally subtle (e.g. a light tint on hover). They are not standalone UI elements that need to be independently distinguishable.

**Wrong:** `minRatio = min(fill vs bg, text vs fill)` → flags subtle hover fills as FAIL.
**Right:** `minRatio = min(text vs fill, border vs bg)` → correctly evaluates readability + visibility.

### 4f. Focus ring / focus indicator contrast

Focus rings, focus outlines, and keyboard-focus indicators are **non-text graphics** requiring **3:1** against the background. This is a separate check from the component's own fill/text contrast.

**Common pitfall:** A bright accent color (e.g. a vivid blue like #2595f0) may look visually prominent but fail 3:1 against light surfaces because its luminance is too close to the background's. Always compute — do not assume bright colors pass.

**When recommending a focus ring color:**

- Light mode: the focus ring needs 3:1 against the **darkest** light surface (e.g. Surface Light 3), which is the worst-case.
- Dark mode: the focus ring needs 3:1 against the **lightest** dark surface (e.g. Surface Dark 3), which is the worst-case.
- If the same focus ring color cannot pass both modes, use **separate focus ring colors per mode** (e.g. a darker blue for light mode, a brighter blue for dark mode).

Include focus ring contrast in the annotations for Focus state columns: `Ring X.X:1 · Fill X.X:1 · Text X.X:1`.

---

## Step 5 — Format Results

### 5a. Group and sort

Organize results into **Pass** and **Fail** groups:

- **Pass group** — sorted **highest ratio to lowest** (strongest contrast first).
- **Fail group** — sorted **lowest ratio to highest** (worst failures first, so the user sees the most problematic colors at the top).

### 5b. Borderline flagging

Flag any color whose worst-case ratio is **within 0.3** of the threshold as **Borderline**. These technically pass but are at risk and should be used with caution.

### 5c. Output format (text mode)

For each mode (light/dark), present:

**Pass table:**

| # | Color | Hex | Worst Ratio | Level |
|---|-------|-----|------------|-------|
| 1 | Name | #hex | X.XX:1 | AAA/AA/Non-text/Borderline |

**Fail table:**

| # | Color | Hex | Worst Ratio |
|---|-------|-----|------------|
| 1 | Name | #hex | X.XX:1 |

### 5d. Pass 2 results (if text layer = yes)

For each foreground color that passed in Pass 1, list which text colors achieve 4.5:1 against it:

| Component Fill | Best Text Color(s) | Best Ratio | # Passing Text Colors |
|---------------|-------------------|-----------|----------------------|
| Name #hex | White, Neutral-2-7 | XX.XX:1 | N colors |

Flag any component fill where **fewer than 3 text colors pass** as **Limited text options**.

### 5e. Stroke recommendations (if requested)

Test neutral/available palette colors for 3:1 against each background group:

- Light mode: recommend dark neutrals that maintain 3:1+ against all light surfaces.
- Dark mode: recommend light neutrals that maintain 3:1+ against all dark surfaces.
- Suggest 1-2px weight.

### 5f. Gradient impact section

If any gradient backgrounds were tested, include a dedicated section:

- Note which colors are affected differently by gradients vs solids.
- Call out **gradient casualties** — colors that pass on solid surfaces but fail on a gradient. Show the solid ratio to gradient ratio comparison.
- State the reduced safe count (e.g. "Dark mode safe list: 28 on solids, 23 with radial gradient included").

### 5g. Cross-mode analysis

If both light AND dark mode backgrounds were tested, automatically include:

- **Universal colors** — any foreground colors that pass in BOTH modes. Note if all are borderline.
- **Best-fit recommendations** per mode — top 3 colors with strongest contrast + most text color flexibility (if Pass 2 was run).

---

## Step 6 — Visual Output (if requested)

When the user requests visual references, build frames on the canvas:

### 6a. Per-background grid — complete matrix

For **each** background (solid and gradient), create a frame showing the **complete state matrix**:

- **Every state must appear on every background.** Never omit Focus or Disabled from gradient or secondary surface testing. The full matrix is: all component types × all states × every background = complete coverage.
- Frame fill = the actual background color/gradient.
- Title text at top: background name + pass/fail count.
- Grid of component shapes (circles for avatars, rectangles for buttons, etc.) filled with each foreground color.
- Each component shows the correct text color (if Pass 2 was run).
- Below each component: contrast annotation with all relevant ratios.
- For Focus state: include focus ring contrast in the annotation (e.g. "Ring 5.0:1 · Fill 10.6:1").
- Borderline colors annotated with a warning indicator.
- Failed colors shown with red stroke, X overlay, dimmed text, and "FAIL" label.

### 6b. Layout rules

- Place frames in clear canvas space: scan `figma.currentPage.children` and start to the right of the rightmost node (the Plugin API has no auto-placement helper).
- Place frames sequentially: light backgrounds first, then dark backgrounds, then gradients.
- Grid: 5 columns for 5 states (Default, Hover, Active, Focus, Disabled), rows for component types.
- Use Inter font family (Medium, Semi Bold, Bold, Regular weights).
- Light backgrounds: dark label text, green ratio text for passes, orange for borderline.
- Dark backgrounds: light label text, green ratio text for passes, red for failures.

### 6c. Best Picks summary frame

After the grids, create a summary frame on white background:

- Top 3 recommended colors per mode (larger component preview, more detail).
- Universal colors section (if cross-mode analysis ran) with split light/dark preview.
- Stroke guidance (if requested).
- Focus ring color recommendation per mode with contrast ratios.
- WCAG threshold legend at bottom.

---

## Step 7 — Deliver and Offer Next Steps

Before reporting, re-check every fix you recommend (a stroke color, a different step) against **every** surface in its mode group, the same worst-case way as the audit. A fix that passes on one surface can fail on another (for example, on a tinted "selected" card).

Present the results and ask:

- "How does this look? Would you like me to adjust any thresholds, test additional colors, or drill into any specific combinations?"
- If visual output was produced, link to all created frames.
- If gradient casualties were found, explicitly note: "N colors pass on solid surfaces but fail on [gradient name]. Do you want to keep them as conditionally safe, or remove them from the safe list?"
