From 8499dc6ec496c10ed71c804f5d769ae9df9bb904 Mon Sep 17 00:00:00 2001 From: lorenzopalaia Date: Thu, 17 Apr 2025 17:16:29 +0200 Subject: [PATCH] feat: Implement contrast checker and color picker refactor --- README.md | 2 +- components/editor/color-picker.tsx | 25 ++- components/editor/contrast-checker.tsx | 217 ++++++++++++++++++++++ components/editor/theme-control-panel.tsx | 30 +-- hooks/use-contrast-checker.ts | 56 ++++++ store/editor-store.ts | 37 +++- types/index.ts | 1 + utils/contrast-checker.ts | 45 +++++ 8 files changed, 383 insertions(+), 30 deletions(-) create mode 100644 components/editor/contrast-checker.tsx create mode 100644 hooks/use-contrast-checker.ts create mode 100644 utils/contrast-checker.ts diff --git a/README.md b/README.md index 6ed59958..5011d645 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ npm install npm run dev ``` -4. Open [http://localhost:8080](http://localhost:8080) in your browser. +4. Open [http://localhost:3000](http://localhost:3000) in your browser. ## Contributors diff --git a/components/editor/color-picker.tsx b/components/editor/color-picker.tsx index a64875bc..479da3cb 100644 --- a/components/editor/color-picker.tsx +++ b/components/editor/color-picker.tsx @@ -3,7 +3,12 @@ import { Label } from "@/components/ui/label"; import { ColorPickerProps } from "@/types"; import { debounce } from "@/utils/debounce"; -const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => { +const ColorPicker = ({ + color, + onChange, + label, + onlyShowPicker, +}: ColorPickerProps) => { const [isOpen, setIsOpen] = useState(false); const [localColor, setLocalColor] = useState(color); @@ -31,6 +36,24 @@ const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => { }; }, [debouncedOnChange]); + if (onlyShowPicker) { + return ( +
setIsOpen(!isOpen)} + > + +
+ ); + } + return (
diff --git a/components/editor/contrast-checker.tsx b/components/editor/contrast-checker.tsx new file mode 100644 index 00000000..8898973e --- /dev/null +++ b/components/editor/contrast-checker.tsx @@ -0,0 +1,217 @@ +import React from "react"; +import { useContrastChecker } from "../../hooks/use-contrast-checker"; +import { ThemeStyleProps } from "@/types/theme"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogDescription, +} from "../ui/dialog"; +import ColorPicker from "./color-picker"; +import { useEditorStore } from "@/store/editor-store"; +import { Contrast, Check, AlertTriangle } from "lucide-react"; + +type ContrastCheckerProps = { + currentStyles: ThemeStyleProps | Partial; +}; + +const MIN_CONTRAST_RATIO = 4.5; + +type ColorPair = { + id: string; + foregroundId: keyof ThemeStyleProps; + backgroundId: keyof ThemeStyleProps; + foreground: string | undefined; + background: string | undefined; + label: string; +}; + +const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => { + const colorPairsToCheck: ColorPair[] = [ + { + id: "primary", + foregroundId: "primary-foreground", + backgroundId: "primary", + foreground: currentStyles?.["primary-foreground"], + background: currentStyles?.["primary"], + label: "Primary", + }, + { + id: "secondary", + foregroundId: "secondary-foreground", + backgroundId: "secondary", + foreground: currentStyles?.["secondary-foreground"], + background: currentStyles?.["secondary"], + label: "Secondary", + }, + { + id: "accent", + foregroundId: "accent-foreground", + backgroundId: "accent", + foreground: currentStyles?.["accent-foreground"], + background: currentStyles?.["accent"], + label: "Accent", + }, + { + id: "base", + foregroundId: "foreground", + backgroundId: "background", + foreground: currentStyles?.["foreground"], + background: currentStyles?.["background"], + label: "Base", + }, + { + id: "card", + foregroundId: "card-foreground", + backgroundId: "card", + foreground: currentStyles?.["card-foreground"], + background: currentStyles?.["card"], + label: "Card", + }, + { + id: "popover", + foregroundId: "popover-foreground", + backgroundId: "popover", + foreground: currentStyles?.["popover-foreground"], + background: currentStyles?.["popover"], + label: "Popover", + }, + { + id: "muted", + foregroundId: "muted-foreground", + backgroundId: "muted", + foreground: currentStyles?.["muted-foreground"], + background: currentStyles?.["muted"], + label: "Muted", + }, + { + id: "destructive", + foregroundId: "destructive-foreground", + backgroundId: "destructive", + foreground: currentStyles?.["destructive-foreground"], + background: currentStyles?.["destructive"], + label: "Destructive", + }, + { + id: "sidebar", + foregroundId: "sidebar-foreground", + backgroundId: "sidebar", + foreground: currentStyles?.["sidebar-foreground"], + background: currentStyles?.["sidebar"], + label: "Sidebar Base", + }, + { + id: "sidebar-primary", + foregroundId: "sidebar-primary-foreground", + backgroundId: "sidebar-primary", + foreground: currentStyles?.["sidebar-primary-foreground"], + background: currentStyles?.["sidebar-primary"], + label: "Sidebar Primary", + }, + { + id: "sidebar-accent", + foregroundId: "sidebar-accent-foreground", + backgroundId: "sidebar-accent", + foreground: currentStyles?.["sidebar-accent-foreground"], + background: currentStyles?.["sidebar-accent"], + label: "Sidebar Accent", + }, + ]; + + const { updateStyle } = useEditorStore(); + + const validColorPairsToCheck = colorPairsToCheck.filter( + (pair): pair is ColorPair & { foreground: string; background: string } => + !!pair.foreground && !!pair.background + ); + const contrastResults = useContrastChecker(validColorPairsToCheck); + + return ( + + + + + + + Contrast Checker + + Check the contrast ratios of your theme colors to ensure they meet + accessibility standards. You can also adjust the colors using the + color pickers below. The recommended minimum contrast ratio is{" "} + {MIN_CONTRAST_RATIO}. + +
+ {colorPairsToCheck.map((pair) => { + const result = contrastResults?.find((res) => res.id === pair.id); + + return ( +
+

+ {pair.label} +

+
+ + updateStyle(pair.backgroundId, color) + } + label={pair.label} + onlyShowPicker + /> +
+ {pair.foreground && pair.background && ( +

+ Aa +

+ )} + {(!pair.foreground || !pair.background) && ( +

N/A

+ )} +
+ + updateStyle(pair.foregroundId, color) + } + label={pair.label} + onlyShowPicker + /> +
+

+ {result ? ( + <> + Contrast Ratio: {result.contrastRatio.toFixed(2)}{" "} + {result.contrastRatio >= MIN_CONTRAST_RATIO ? ( + + ) : ( + + )} + + ) : ( + "N/A" + )} +

+
+ ); + })} +
+
+
+
+ ); +}; + +export default ContrastChecker; diff --git a/components/editor/theme-control-panel.tsx b/components/editor/theme-control-panel.tsx index 3340f558..bd3d5214 100644 --- a/components/editor/theme-control-panel.tsx +++ b/components/editor/theme-control-panel.tsx @@ -22,7 +22,6 @@ import { DEFAULT_FONT_MONO, DEFAULT_FONT_SANS, DEFAULT_FONT_SERIF, - COMMON_STYLES, defaultThemeState, } from "../../config/theme"; import { Separator } from "../ui/separator"; @@ -32,6 +31,7 @@ import { toast } from "../ui/use-toast"; import { parseCssInput } from "../../utils/parse-css-input"; import ShadowControl from "./shadow-control"; import ThemeControlActions from "./theme-control-actions"; +import ContrastChecker from "./contrast-checker"; const ThemeControlPanel = ({ styles, @@ -45,6 +45,7 @@ const ThemeControlPanel = ({ resetToDefault, hasDefaultThemeChanged, hasCurrentPresetChanged, + updateStyle, } = useEditorStore(); const [cssImportOpen, setCssImportOpen] = useState(false); @@ -56,32 +57,6 @@ const ThemeControlPanel = ({ [currentMode, styles] ); - const updateStyle = React.useCallback( - ( - key: K, - value: (typeof currentStyles)[K] - ) => { - // apply common styles to both light and dark modes - if (COMMON_STYLES.includes(key)) { - onChange({ - ...styles, - light: { ...styles.light, [key]: value }, - dark: { ...styles.dark, [key]: value }, - }); - return; - } - - onChange({ - ...styles, - [currentMode]: { - ...currentStyles, - [key]: value, - }, - }); - }, - [onChange, styles, currentMode, currentStyles] - ); - const handleCssImport = (css: string) => { // This just shows a success toast for now const { lightColors, darkColors } = parseCssInput(css); @@ -110,6 +85,7 @@ const ThemeControlPanel = ({

Theme Editor

+
([]); + + const debouncedCalculation = useCallback( + debounce((pairs: ColorPair[]) => { + if (!pairs.length) { + setContrastResults([]); + return; + } + + try { + const results = pairs.map((pair) => { + const ratio = parseFloat( + getContrastRatio(pair.foreground, pair.background) + ); + return { + id: pair.id, + contrastRatio: ratio, + }; + }); + + setContrastResults(results); + } catch (error) { + console.error("Error checking contrast:", error); + setContrastResults([]); + } + }, 750), + [] + ); + + useEffect(() => { + debouncedCalculation(colorPairs); + }, [colorPairs, debouncedCalculation]); + + return contrastResults; +} diff --git a/store/editor-store.ts b/store/editor-store.ts index e1942f20..8485b567 100644 --- a/store/editor-store.ts +++ b/store/editor-store.ts @@ -1,9 +1,10 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { ThemeEditorState } from "@/types/editor"; +import { ThemeStyleProps } from "@/types/theme"; // @ts-expect-error: owned by ngard import { isEqual } from "@ngard/tiny-isequal"; -import { defaultThemeState } from "@/config/theme"; +import { defaultThemeState, COMMON_STYLES } from "@/config/theme"; import { getPresetThemeStyles } from "@/utils/theme-presets"; interface EditorStore { @@ -15,6 +16,10 @@ interface EditorStore { hasDefaultThemeChanged: () => boolean; hasCurrentPresetChanged: () => boolean; hasChangedThemeFromDefault: boolean; + updateStyle: ( + key: K, + value: ThemeStyleProps[K] + ) => void; } export const useEditorStore = create()( @@ -63,6 +68,36 @@ export const useEditorStore = create()( ); return !isEqual(state.themeState.styles, presetStyles); }, + updateStyle: (key, value) => + set((state) => { + const currentMode = state.themeState.currentMode; + const currentStyles = state.themeState.styles; + + let newStyles = { ...currentStyles }; + + if (COMMON_STYLES.includes(key as string)) { + newStyles = { + ...newStyles, + light: { ...newStyles.light, [key]: value }, + dark: { ...newStyles.dark, [key]: value }, + }; + } else { + newStyles = { + ...newStyles, + [currentMode]: { + ...(newStyles[currentMode] || {}), + [key]: value, + }, + }; + } + + return { + themeState: { + ...state.themeState, + styles: newStyles, + }, + }; + }), }), { name: "editor-storage", // unique name for localStorage diff --git a/types/index.ts b/types/index.ts index 763eddf4..74e0940c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -10,6 +10,7 @@ export type ColorPickerProps = { color: string; onChange: (color: string) => void; label: string; + onlyShowPicker?: boolean; }; export type SliderInputProps = { diff --git a/utils/contrast-checker.ts b/utils/contrast-checker.ts new file mode 100644 index 00000000..7dcbb5f1 --- /dev/null +++ b/utils/contrast-checker.ts @@ -0,0 +1,45 @@ +import * as culori from "culori"; + +/** + * Calculates the luminance of a color according to WCAG standard + * @param colorValue - The color in any supported format + * @returns The relative luminance of the color (0-1) + */ +function getLuminance(colorValue: string): number { + try { + const color = culori.parse(colorValue); + if (!color) { + console.warn(`Invalid color: ${colorValue}`); + return 0; + } + + // Culori directly provides the luminance according to WCAG standard + return culori.wcagLuminance(color); + } catch (error) { + console.error(`Error calculating luminance: ${colorValue}`, error); + return 0; + } +} + +/** + * Calculates the contrast ratio between two colors according to WCAG guidelines + * @param color1 - First color (in any format) + * @param color2 - Second color (in any format) + * @returns The contrast ratio as a string (e.g. "4.50") + */ +export function getContrastRatio(color1: string, color2: string): string { + try { + const lum1 = getLuminance(color1); + const lum2 = getLuminance(color2); + + // WCAG contrast ratio formula + const ratio = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); + return ratio.toFixed(2); + } catch (error) { + console.error( + `Error calculating contrast between ${color1} and ${color2}:`, + error + ); + return "1.00"; // Fallback value indicating low contrast + } +}