Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/docs/docs/extras/color-pairing-tool.mdx
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>
18 changes: 18 additions & 0 deletions apps/docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,24 @@ const sidebars: SidebarsConfig = {
},
],
},
{
type: 'category',
label: 'Extras',
customProps: {
icon: 'sparkle',
kbar: {
icon: 'sparkle',
description: 'Extra tools and resources',
},
},
items: [
{
type: 'doc',
id: 'extras/color-pairing-tool',
label: 'Color Pairing Tool',
},
],
},
{
type: 'category',
label: `Changelogs`,
Expand Down
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;
}
}
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);
}
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.

156 changes: 156 additions & 0 deletions apps/docs/src/components/page/ColorPairingTool/ColorPicker.tsx
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';
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>
);
});
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>
);
});
Loading
Loading