Skip to content

feat: Color pairing tool#524

Draft
nicoledbelcher wants to merge 9 commits intocoinbase:masterfrom
nicoledbelcher:nicole/colorpicker
Draft

feat: Color pairing tool#524
nicoledbelcher wants to merge 9 commits intocoinbase:masterfrom
nicoledbelcher:nicole/colorpicker

Conversation

@nicoledbelcher
Copy link
Copy Markdown

@nicoledbelcher nicoledbelcher commented Mar 20, 2026

What changed? Why?

Added a new sidebar to the docs site called extras and created a new color pairing tool inside it.
What it is
The Color Pairing Tool is an internal design utility built into the CDS documentation site. It helps designers and engineers find the closest CDS spectrum primitives for any color and automatically generates accessible, theme-aware color pairings that work across light and dark modes.

How it works

  1. Color Input (two methods)

Image upload — Users can upload up to 10 images (PNG, JPG, or WebP) via file picker or drag-and-drop. The tool extracts dominant colors from each image using a k-means clustering algorithm run client-side on an .
Manual hex entry — Users can type one or more comma-separated hex codes directly into a text input.
2. Token Matching

For each extracted or entered color, the tool runs a hue-aware matching algorithm against the full CDS spectrum (both light and dark). This isn't a simple "nearest Euclidean distance" match — it's purpose-built for CDS tokens and factors in perceptual hue so the matched primitive feels like a natural fit, not just the mathematically closest one.

  1. Contrast & Accessibility

Once a primary background token is matched, the tool:

Selects a foreground text color that meets WCAG AA contrast requirements (minimum 4.5:1 for normal text).
Computes a secondary/complementary token pairing.
Offers a high-contrast mode toggle that enforces even stricter contrast ratios.
Displays live WCAG contrast ratios (AA Normal, AA Large, AAA) for every pairing.
4. Component Preview

The results feed into a Component Playground that renders real CDS components — Card, Button, MessagingCard, LineChart, ProgressBar, and more — using the matched token pairings. This gives an immediate preview of how the colors will look in a production UI across both light and dark themes.

  1. Resampling

For image uploads, users can drag a hotspot around the image to resample the dominant color from a different region, which recalculates the token match and all downstream pairings in real time.

  1. Export

Results can be exported as structured JSON containing the matched tokens, hex values, text colors, and button state variants for both light and dark modes — ready to hand off to engineering.

Key technical details
Runs entirely client-side — no server calls. All color math, image processing, and token matching happen in the browser.
Uses CDS design tokens as the source of truth (imported from tokens.ts), so matches always stay in sync with the design system.
Theme-aware throughout — every visual (checkerboards, previews, component playground) adapts to the user's current light/dark mode setting.

UI changes

Screenshot 2026-03-27 at 9 48 52 AM Screenshot 2026-03-27 at 9 49 28 AM Screenshot 2026-03-27 at 9 49 19 AM

Multiple outputs carousel:
Screenshot 2026-03-27 at 11 14 15 AM

Testing

You should test selecting a color from the color picker without uploading an image, and then test uploading one or multiple images. Users can only upload, PNG, JGP and WEBP files, no video/animated gif files allowed.

How has it been tested?

  • Unit tests
  • Interaction tests
  • Pseudo State tests
  • Manual - Web
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

Illustrations/Icons Checklist

Required if this PR changes files under packages/illustrations/** or packages/icons/**

  • verified visreg changes with Terran (include link to visreg run/approval)
  • all illustration/icons names have been reviewed by Dom and/or Terran

Change management

type=routine
risk=low
impact=sev5

automerge=false

@cb-heimdall
Copy link
Copy Markdown
Collaborator

cb-heimdall commented Mar 20, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1
CODEOWNERS 🟡 See below

🟡 CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 🟡 0/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

Copy link
Copy Markdown
Contributor

@hcopp hcopp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the idea! Hopefully this can help you talk with Cursor about how to clean things up.

Also if you could get sample screenshots, description of your intended changes, and a link to the figma in the PR description that would be very helpful!

CDS layout components use inline styles generated from their style props,
which have higher specificity than CSS class rules. !important is required
here to override those inline styles at mobile breakpoints.
────────────────────────────────────────────────────────────────────────── */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need to use style props, but instead use our regular component props which support responsive values.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cursor:

Here's a comment you can post:

Acknowledged — the !important overrides in ResultCard.module.css exist because CDS layout components (HStack, VStack, Box) apply style props as inline styles, which have higher specificity than CSS class rules. The fix is to replace these with responsive CDS component props (e.g. flexDirection={{ base: 'column', tablet: 'row' }}, width={{ base: '100%', tablet: '50%' }}).

We've already done this for ColorPicker.module.css (removed the .pickerRow !important override in this round). The remaining overrides in ResultCard.module.css touch layout across ResultCard, ComponentPlayground, and ContrastPanel simultaneously and need visual QA at both desktop and mobile breakpoints. Deferring to a dedicated follow-up PR.

@hcopp
Copy link
Copy Markdown
Contributor

hcopp commented Mar 27, 2026

@nicoledbelcher are you able to fill out the PR description? This is done in GitHub UI manually. You can look at other PRs to see what we normally do to fill it out.

image

Also your commits are unverified, you should follow https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account. This is a requirement to merge in code.

Lastly, can you pull in the latest from master? Cursor can help with this.

@nicoledbelcher nicoledbelcher changed the title Nicole/colorpicker feat: Color pairing tool Mar 27, 2026
nicoledbelcher and others added 9 commits March 27, 2026 13:18
Adds ColorPairingTool to docs site, color-pairing utilities and components to vite-app, cds-finder app, guidelines documentation, and various supporting updates.

Made-with: Cursor
…heming

- Remove Color matching / Color pairs tab strip; single-flow layout
- Scope LineChart scrubber overlay/line CSS vars to chart card for light playground
- Hue-aware primitive matching and related adjustments

Made-with: Cursor
…ixes

- Checkerboard thumbnails in UploadZone now respect light/dark color mode
- Light mode thumb borders use subtle dark borders instead of invisible white
- Restore top padding on contrast panel for proper spacing on mobile
- Increase mobile image preview height to 300px for balanced blur padding
- Fix hotspot drifting off image on mobile by removing align-items stretch
- Fix cards row clipping LineChart on mobile by overriding fixed height
- Strip trailing commas in color input instead of creating phantom results
- Remove red error outline when typing a trailing comma
- Remove token tag next to "Color match to components" heading

Made-with: Cursor
- Move reducer/initialState into parent component (index.tsx), rename
  state.ts to types.ts for shared type definitions only
- Remove sharedStyles.ts (CoinbaseMono font not available in docs)
- Move hotspot label inline styles to ResultCard.module.css
- Use CDS Box props instead of inline styles in ContrastPanel
- Replace checker-placeholder.png with theme-aware inline SVG
- Replace banner PNGs with SVGs for light/dark modes
- Simplify LineChart height prop (remove unnecessary responsive object)
- Hide MetadataLinks on pages with no metadata (e.g. playground)
- Run docs:lint --fix (all files pass)

Note: the style props → responsive CDS props refactor (removing
!important overrides in ResultCard.module.css) is deferred to a
follow-up. It touches the responsive layout across multiple components
and needs careful visual QA at both desktop and mobile breakpoints.

Made-with: Cursor
…mprove playground

- Extract PlaygroundContent into its own file for cleaner separation
- Extract generic FileDropZone component and useFileUpload hook for reuse
- Replace hardcoded spectrum values and types in tokens.ts with CDS theme imports
- Remove unused CSS modules and useImageUpload hook
- Simplify ContrastPanel and WcagBadge to use CDS spacing props
- Update LineChart card background to use "bg" token
- Add hideLlmLink option to MetadataLinks component
- Fix ContentHeader bannerHeight JSDoc format

Made-with: Cursor
toast.show('Failed to copy to clipboard');
}
}, [llmDocUrl, toast]);

Copy link
Copy Markdown
Contributor

@hcopp hcopp Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add this back? And the comment above and toast

/** URL to Figma */
figma?: string;
/** Hide the "View as Markdown" link */
hideLlmLink?: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we rename this to hideLlmLinks and have it hide both "View as Markdown" and "Copy for LLM"?


/**
* Displays metadata links (Source, Storybook, Changelog, Figma) and LLM doc buttons.
* Displays metadata links (Source, Storybook, Changelog, Figma) and View as Markdown.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can you undo this change

source={source}
storybook={storybook}
/>
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get rid of this conditional? This will hide the buttons for all pages that don't have any of these four links.

Instead we should start passing in the logic to conditionally hide the buttons as well.

If needed, the logic inside of MetadataLinks can return nothing if needed - to prevent extra padding at the bottom.


export type TokenFamily = ThemeVars.SpectrumHue;
export type TokenStep = ThemeVars.SpectrumHueStep;
export type ColorToken = ThemeVars.SpectrumColor;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we shouldn't need to re-export TokenFamily, TokenStep, ColorToken, lightSpectrum, and darkSpectrum. Files importing from here should instead import from useTheme(), or, if unable, import from defaultTheme.

Subtitle
</Text>
<HStack alignItems="center" gap={0.5}>
<Text color="fg" font="headline">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: shouldn't need to set "fg" here, that is the default color.

style={{ height: '100%', width: '100%' }}
>
<VStack gap={1}>
<Box padding={2} paddingBottom={0}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: don't need paddingBottom={0}, that is the default.

<HStack
className={styles.cardsRow}
gap={2}
style={{ alignItems: 'stretch', height: 200 }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can set

<HStack
          className={styles.cardsRow}
          gap={2}
          alignItems="stretch"
          height={200}

style={{ color: pText, border: 'none', fontWeight: 600, fontSize: 15 }}
>
Button
</Interactable>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be using here, setting style={{ backgroundColor: 'X' }} if needed, unless there is a very specific reason.

const spectrum = selectedMode === 'light' ? lightSpectrum : darkSpectrum;
const checkerSvg = `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="20" height="20" fill="rgb(${spectrum.gray15})"/><rect x="20" y="20" width="20" height="20" fill="rgb(${spectrum.gray15})"/><rect x="20" width="20" height="20" fill="rgb(${spectrum.gray10})"/><rect y="20" width="20" height="20" fill="rgb(${spectrum.gray10})"/></svg>`,
)}`;
Copy link
Copy Markdown
Contributor

@hcopp hcopp Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a separate .svg file, like we have for color-pairing-tool.svg - it can still use theme variables since these are set as css variables accessible in dom.

error: null,
};

function reducer(state: AppState, action: Action): AppState {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be following a redux style state management.

Cursor should rework this to use regular state management, meaning we have a root component which has the main state, then inside of the upload file it manages drag enter/drag leave, etc and then it calls to colorpairingtool when files have been added.

Then the parent can manage loading and then sending to results state where that new component showing state can manage carousel progress, contrast interaction, etc.

imgSrc: string | null;
imgDataURL: string | null;
imgDispW: number;
imgDispH: number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can this be imgWidth and imgHeight? feels much easier to read.

export type ResultEntry = {
filename: string;
imgSrc: string | null;
imgDataURL: string | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally we prefer undefined over null values, across all types

box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.2),
0 1px 4px rgba(0, 0, 0, 0.3);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should replace these with linaria css or ideally props directly on primitives. We can use Polymorphic components to use a Box and even set it to a span or whatever component is needed.

border: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
padding: 8,
}}
Copy link
Copy Markdown
Contributor

@hcopp hcopp Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we should use bordered and use inline props such as padding={1}. Box is flex by default so won't need that here or anywhere else

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants