-
Notifications
You must be signed in to change notification settings - Fork 76
feat: Color pairing tool #524
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
nicoledbelcher
wants to merge
9
commits into
coinbase:master
Choose a base branch
from
nicoledbelcher:nicole/colorpicker
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
372e731
Add color pairing tool and related changes
nicoledbelcher 1ba92f9
docs(ColorPairingTool): UI fixes — remove tabs, playground scrubber t…
nicoledbelcher 4d40ebb
revert changes
cb-ekuersch 51c0c4e
refactor with cursor
cb-ekuersch 2e83fa2
more refactoring with cursor
cb-ekuersch 45f185b
move color picker to extras
cb-ekuersch a01d48f
fix(ColorPairingTool): mobile layout, light mode theming, and input f…
nicoledbelcher 4ac6075
refactor(ColorPairingTool): address PR review feedback
nicoledbelcher 2c12130
refactor(ColorPairingTool): extract components, use CDS tokens, and i…
nicoledbelcher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| --- | ||
| id: color-pairing-tool | ||
| title: Color Pairing Tool | ||
| hide_title: true | ||
| --- | ||
|
|
||
| import { VStack } from '@coinbase/cds-web/layout'; | ||
| import { ContentHeader } from '@site/src/components/page/ContentHeader'; | ||
| import { ColorPairingTool } from '@site/src/components/page/ColorPairingTool'; | ||
|
|
||
| <VStack gap={5}> | ||
| <ContentHeader | ||
| title="Color Pairing Tool" | ||
| description="Upload any image to extract colors, find the closest CDS spectrum primitives, and check WCAG accessibility contrast automatically." | ||
| banner="/img/campaignCardBanners/color-pairing-tool.svg" | ||
| bannerDark="/img/campaignCardBanners/color-pairing-tool_dark.svg" | ||
| /> | ||
|
|
||
| <ColorPairingTool /> | ||
| </VStack> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
16 changes: 16 additions & 0 deletions
16
apps/docs/src/components/page/ColorPairingTool/ColorPairingTool.module.css
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| @media (max-width: 767px) { | ||
| .toolbarRow { | ||
| position: relative; | ||
| } | ||
|
|
||
| .startOverLabel { | ||
| display: none; | ||
| } | ||
|
|
||
| .carouselCenter { | ||
| position: absolute; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| pointer-events: auto; | ||
| } | ||
| } | ||
nicoledbelcher marked this conversation as resolved.
Show resolved
Hide resolved
|
||
32 changes: 32 additions & 0 deletions
32
apps/docs/src/components/page/ColorPairingTool/ColorPicker.module.css
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| .pickerWrapper { | ||
| flex: 1; | ||
| min-width: 0; | ||
| } | ||
|
|
||
| /* react-colorful overrides — scoped to avoid affecting other pickers */ | ||
| .pickerWrapper :global(.react-colorful) { | ||
| width: 100%; | ||
| height: auto; | ||
| gap: 0; | ||
| } | ||
|
|
||
| .pickerWrapper :global(.react-colorful__saturation) { | ||
| min-height: 110px; | ||
| border-radius: 10px 10px 0 0; | ||
| flex: 1; | ||
| } | ||
|
|
||
| .pickerWrapper :global(.react-colorful__hue) { | ||
| height: 14px; | ||
| border-radius: 0 0 8px 8px; | ||
| margin-top: 4px; | ||
| } | ||
|
|
||
| .pickerWrapper :global(.react-colorful__pointer) { | ||
| width: 18px; | ||
| height: 18px; | ||
| border-width: 2.5px; | ||
| box-shadow: | ||
| 0 0 0 1px rgba(0, 0, 0, 0.2), | ||
| 0 1px 4px rgba(0, 0, 0, 0.3); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
156 changes: 156 additions & 0 deletions
156
apps/docs/src/components/page/ColorPairingTool/ColorPicker.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| import React, { memo, useCallback, useRef, useState } from 'react'; | ||
| import type { HsvColor } from 'react-colorful'; | ||
| import { HsvColorPicker } from 'react-colorful'; | ||
| import { Button } from '@coinbase/cds-web/buttons'; | ||
| import { TextInput } from '@coinbase/cds-web/controls'; | ||
| import { Icon } from '@coinbase/cds-web/icons'; | ||
| import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; | ||
| import { Text } from '@coinbase/cds-web/typography'; | ||
|
|
||
| import styles from './ColorPicker.module.css'; | ||
nicoledbelcher marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import { hsbToRgb, parseColorInput, rgbToHsb, toHex } from './colorUtils'; | ||
|
|
||
| type ColorPickerProps = { | ||
| onApply: (inputValue: string) => void; | ||
| }; | ||
|
|
||
| function getLastSegment(val: string): string { | ||
| const lastComma = val.lastIndexOf(','); | ||
| return lastComma === -1 ? val.trim() : val.slice(lastComma + 1).trim(); | ||
| } | ||
|
|
||
| export const ColorPicker = memo(function ColorPicker({ onApply }: ColorPickerProps) { | ||
| const [hsv, setHsv] = useState<HsvColor>({ h: 210, s: 72, v: 68 }); | ||
| const [inputValue, setInputValue] = useState(''); | ||
| const [hasError, setHasError] = useState(false); | ||
|
|
||
| // Refs so callbacks always see the latest values without stale closures | ||
| const hsvRef = useRef(hsv); | ||
| hsvRef.current = hsv; | ||
| const inputValueRef = useRef(inputValue); | ||
| inputValueRef.current = inputValue; | ||
|
|
||
| const currentHex = toHex(hsbToRgb(hsv.h, hsv.s / 100, hsv.v / 100)); | ||
|
|
||
| const writeLastSegment = useCallback((hex: string) => { | ||
| const val = inputValueRef.current; | ||
| const lastComma = val.lastIndexOf(','); | ||
| setInputValue(lastComma === -1 ? hex : val.slice(0, lastComma + 1) + ' ' + hex); | ||
| }, []); | ||
|
|
||
| const handlePickerChange = useCallback( | ||
| ({ h, s, v }: HsvColor) => { | ||
| setHsv({ h, s, v }); | ||
| writeLastSegment(toHex(hsbToRgb(h, s / 100, v / 100))); | ||
| setHasError(false); | ||
| }, | ||
| [writeLastSegment], | ||
| ); | ||
|
|
||
| const handleTextInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const val = e.target.value; | ||
| setInputValue(val); | ||
| if (!val.trim()) { | ||
| setHasError(false); | ||
| return; | ||
| } | ||
| const lastSeg = getLastSegment(val); | ||
| if (!lastSeg) { | ||
| setHasError(false); | ||
| return; | ||
| } | ||
| const parsed = parseColorInput(lastSeg); | ||
| if (parsed) { | ||
| const hsb = rgbToHsb(parsed.r, parsed.g, parsed.b); | ||
| setHsv({ h: hsb.h, s: hsb.s * 100, v: hsb.b * 100 }); | ||
| setHasError(false); | ||
| } else { | ||
| setHasError(true); | ||
| } | ||
| }, []); | ||
|
|
||
| const handleApply = useCallback(() => { | ||
| const val = inputValueRef.current.trim(); | ||
| const lastSeg = getLastSegment(val); | ||
| let finalValue = inputValueRef.current; | ||
| if (!lastSeg) { | ||
| // Trailing comma with no value after it — strip it | ||
| const lastComma = finalValue.lastIndexOf(','); | ||
| if (lastComma !== -1) { | ||
| finalValue = finalValue.slice(0, lastComma).trim(); | ||
| setInputValue(finalValue); | ||
| } | ||
| if (!finalValue) { | ||
| const { h, s, v } = hsvRef.current; | ||
| finalValue = toHex(hsbToRgb(h, s / 100, v / 100)); | ||
| setInputValue(finalValue); | ||
| } | ||
| } else if (!parseColorInput(lastSeg)) { | ||
| const { h, s, v } = hsvRef.current; | ||
| const hex = toHex(hsbToRgb(h, s / 100, v / 100)); | ||
| const lastComma = finalValue.lastIndexOf(','); | ||
| finalValue = lastComma === -1 ? hex : finalValue.slice(0, lastComma + 1) + ' ' + hex; | ||
| setInputValue(finalValue); | ||
| } | ||
| onApply(finalValue); | ||
| }, [onApply]); | ||
|
|
||
| return ( | ||
| <VStack gap={1.5}> | ||
| <Text font="headline">Or enter a color value</Text> | ||
| <HStack | ||
| alignItems="stretch" | ||
| flexDirection={{ phone: 'column', tablet: 'row', desktop: 'row' }} | ||
| gap={3} | ||
| > | ||
| {/* Left: react-colorful HSV picker */} | ||
| <div className={styles.pickerWrapper}> | ||
| <HsvColorPicker color={hsv} onChange={handlePickerChange} /> | ||
| </div> | ||
|
|
||
| {/* Right: Swatch, input, hint, button */} | ||
| <VStack gap={1.5} justifyContent="space-between" style={{ flex: 1, minWidth: 0 }}> | ||
| <VStack gap={0.75}> | ||
| <HStack alignItems="center" gap={1.5}> | ||
| <Box | ||
| borderRadius={200} | ||
| height={40} | ||
| style={{ | ||
| background: currentHex, | ||
| border: '1px solid rgba(0,0,0,0.08)', | ||
| flexShrink: 0, | ||
| }} | ||
| width={40} | ||
| /> | ||
| <Box flexGrow={1}> | ||
| <TextInput | ||
| compact | ||
| label="" | ||
| onChange={handleTextInputChange} | ||
| placeholder="#2342AD" | ||
| value={inputValue} | ||
| variant={hasError ? 'negative' : undefined} | ||
| /> | ||
| </Box> | ||
| </HStack> | ||
| <HStack alignItems="center" gap={0.5} style={{ marginLeft: 52 }}> | ||
| <Icon active color="fgMuted" name="info" size="s" /> | ||
| <Text color="fgMuted" font="legal"> | ||
| Insert commas between multiple values | ||
| </Text> | ||
| </HStack> | ||
| </VStack> | ||
| <Button | ||
| compact | ||
| disabled={!inputValue.trim()} | ||
| endIcon="forwardArrow" | ||
| onClick={handleApply} | ||
| variant="secondary" | ||
| > | ||
| Find closest primitive | ||
| </Button> | ||
| </VStack> | ||
| </HStack> | ||
| </VStack> | ||
| ); | ||
| }); | ||
90 changes: 90 additions & 0 deletions
90
apps/docs/src/components/page/ColorPairingTool/ComponentPlayground.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import React, { memo, useState } from 'react'; | ||
| import type { ThemeVars } from '@coinbase/cds-common/core/theme'; | ||
| import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; | ||
| import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; | ||
| import { ThemeProvider } from '@coinbase/cds-web/system/ThemeProvider'; | ||
| import { SegmentedTabs } from '@coinbase/cds-web/tabs'; | ||
| import { Text } from '@coinbase/cds-web/typography'; | ||
| import { useColorMode } from '@docusaurus/theme-common'; | ||
|
|
||
| import { useDocsTheme } from '../../../theme/Layout/Provider/UnifiedThemeContext'; | ||
|
|
||
| import { PlaygroundContent } from './PlaygroundContent'; | ||
| import styles from './ResultCard.module.css'; | ||
|
|
||
| const PLAYGROUND_TABS: TabValue[] = [ | ||
| { id: 'light', label: 'Light' }, | ||
| { id: 'dark', label: 'Dark' }, | ||
| ]; | ||
|
|
||
| type ComponentPlaygroundProps = { | ||
| /** CDS spectrum token name for the light-mode brand color (e.g. "blue40"). */ | ||
| lightToken: ThemeVars.SpectrumColor; | ||
| /** CDS spectrum token name for the dark-mode brand color (e.g. "blue50"). */ | ||
| darkToken: ThemeVars.SpectrumColor; | ||
| /** Hex value of lightToken — used for contrast ratio calculation only. */ | ||
| lightHex: string; | ||
| /** Hex value of darkToken — used for contrast ratio calculation only. */ | ||
| darkHex: string; | ||
| imgSrc: string | null; | ||
| }; | ||
|
|
||
| export const ComponentPlayground = memo(function ComponentPlayground({ | ||
| lightToken, | ||
| darkToken, | ||
| lightHex, | ||
| darkHex, | ||
| imgSrc, | ||
| }: ComponentPlaygroundProps) { | ||
| const { colorMode } = useColorMode(); | ||
| const { theme } = useDocsTheme(); | ||
|
|
||
| const [selectedMode, setSelectedMode] = useState<'light' | 'dark'>(() => | ||
| colorMode === 'light' ? 'light' : 'dark', | ||
| ); | ||
|
|
||
| const [prevColorMode, setPrevColorMode] = useState(colorMode); | ||
| if (prevColorMode !== colorMode) { | ||
| setPrevColorMode(colorMode); | ||
| setSelectedMode(colorMode === 'light' ? 'light' : 'dark'); | ||
| } | ||
|
|
||
| const activeTab = PLAYGROUND_TABS.find((t) => t.id === selectedMode) ?? PLAYGROUND_TABS[0]; | ||
|
|
||
| const selectedToken = selectedMode === 'light' ? lightToken : darkToken; | ||
| const selectedHex = selectedMode === 'light' ? lightHex : darkHex; | ||
|
|
||
| return ( | ||
| <Box padding={4} paddingTop={3} width="100%"> | ||
| <VStack gap={2} width="100%"> | ||
| <HStack | ||
| alignItems="center" | ||
| className={styles.playgroundHeader} | ||
| justifyContent="space-between" | ||
| width="100%" | ||
| > | ||
| <Text as="h3" font="title3"> | ||
| Color match to components | ||
| </Text> | ||
| <SegmentedTabs | ||
| accessibilityLabel="Switch light or dark mode preview" | ||
| activeTab={activeTab} | ||
| onChange={(tab) => { | ||
| if (tab) setSelectedMode(tab.id); | ||
| }} | ||
| tabs={PLAYGROUND_TABS} | ||
| /> | ||
| </HStack> | ||
|
|
||
| <ThemeProvider activeColorScheme={selectedMode} display="contents" theme={theme}> | ||
| <PlaygroundContent | ||
| imgSrc={imgSrc} | ||
| selectedHex={selectedHex} | ||
| selectedMode={selectedMode} | ||
| selectedToken={selectedToken} | ||
| /> | ||
| </ThemeProvider> | ||
| </VStack> | ||
| </Box> | ||
| ); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.