From 18c713727938fbf799c4d5cf96f83e8af6620d27 Mon Sep 17 00:00:00 2001 From: Oliver Pan <2216991777@qq.com> Date: Tue, 31 Oct 2023 01:08:28 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20Contrast=20Ratio=20?= =?UTF-8?q?plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/layout.tsx | 8 +- .../palette/base-color-palettes.tsx | 34 +++++--- .../palette/base-palette-button.tsx | 6 +- .../components/palette/readability-string.tsx | 58 ++++++++++++++ apps/web/components/svg/eye-cvd.tsx | 39 ++++++++++ apps/web/components/toolbar/cvd.tsx | 34 ++++++++ apps/web/components/toolbar/index.tsx | 4 + apps/web/components/toolbar/readability.tsx | 47 +++++++++++ .../ui/color-mode-dropdown-menu.tsx | 3 - .../ui/desktop-primary-toolbar-buttons.tsx | 2 + .../components/ui/primary-toolbar-menu.tsx | 15 +++- apps/web/lib/generateReadability.tsx | 78 +++++++++++++++++++ apps/web/lib/getServerColors.ts | 26 ++++++- apps/web/store/store.ts | 37 +++++++-- apps/web/types/app.ts | 19 +++++ 15 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 apps/web/components/palette/readability-string.tsx create mode 100644 apps/web/components/svg/eye-cvd.tsx create mode 100644 apps/web/components/toolbar/cvd.tsx create mode 100644 apps/web/components/toolbar/readability.tsx create mode 100644 apps/web/lib/generateReadability.tsx diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6d6b191..5242a07 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -52,17 +52,21 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const { baseColors, colorPalettes, colorMode } = await getServerColors(); + const { baseColors, colorPalettes, colorMode, showReadability } = + await getServerColors(); useColorStore.setState({ baseColors, colorPalettes, colorMode, + showReadability, }); return (
- + diff --git a/apps/web/components/palette/base-color-palettes.tsx b/apps/web/components/palette/base-color-palettes.tsx index 6c4d12f..955a218 100644 --- a/apps/web/components/palette/base-color-palettes.tsx +++ b/apps/web/components/palette/base-color-palettes.tsx @@ -6,9 +6,11 @@ import React from "react"; import PaletteButton from "./base-palette-button"; import ColorString from "./color-string"; +import ReadabilityString from "./readability-string"; function BaseColorPalettes() { - const { colorPalettes, colorMode } = useColorStore.getState(); + const { colorPalettes, colorMode, showReadability } = + useColorStore.getState(); const animation = (i, type) => { let baseDelay = 0.12; switch (type) { @@ -47,7 +49,7 @@ function BaseColorPalettes() { const step = i; return (
{colorStepMap[i]}
-
+
- + {showReadability ? ( + + ) : ( + + )}
diff --git a/apps/web/components/palette/base-palette-button.tsx b/apps/web/components/palette/base-palette-button.tsx index bda5850..287600d 100644 --- a/apps/web/components/palette/base-palette-button.tsx +++ b/apps/web/components/palette/base-palette-button.tsx @@ -2,7 +2,7 @@ import { BaseColorTypes, RawColor } from "@/types/app"; import { cn } from "@ui/lib/utils"; -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Copy } from "lucide-react"; @@ -97,7 +97,9 @@ const PaletteButton = ({ transitionDelay: `${animation}s`, transitionDuration: `${animation * 1.2}s`, }} - initial={false} + initial={{ + opacity: 1, + }} animate={ performance === Performance.high ? { diff --git a/apps/web/components/palette/readability-string.tsx b/apps/web/components/palette/readability-string.tsx new file mode 100644 index 0000000..b4d45d0 --- /dev/null +++ b/apps/web/components/palette/readability-string.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useColorStore } from "@/store/store"; +import { BaseColorTypes } from "@/types/app"; +import { cn } from "@ui/lib/utils"; + +function ReadabilityString({ + type, + step, +}: { + type: BaseColorTypes; + step: number; +}) { + const { colorPalettes } = useColorStore(); + const readability = colorPalettes[type][step]?.readability; + // Function to determine border style + const getBorderStyle = (readabilityValue: number) => { + if (readabilityValue >= 7) { + return "border-border ring-1 ring-inset ring-offset-2 ring-primary ring-offset-border"; + } else if (readabilityValue >= 4.5) { + return "border border-accent-foreground"; + } else if (readabilityValue >= 3) { + console.log(`readabilityValue: ${readabilityValue}`); + return "border border-dashed"; + } else { + return ""; + } + }; + + const foregroundBorderStyle = readability + ? getBorderStyle(readability.foreground.readability) + : ""; + + const backgroundBorderStyle = readability + ? getBorderStyle(readability.background.readability) + : ""; + return ( +
+ + {readability?.foreground?.readability} + + + {readability?.background?.readability} + +
+ ); +} + +export default ReadabilityString; diff --git a/apps/web/components/svg/eye-cvd.tsx b/apps/web/components/svg/eye-cvd.tsx new file mode 100644 index 0000000..b9b5a51 --- /dev/null +++ b/apps/web/components/svg/eye-cvd.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const EyeCVD = (props: SVGProps) => ( + + + + + + + + + + + + +); +export default EyeCVD; diff --git a/apps/web/components/toolbar/cvd.tsx b/apps/web/components/toolbar/cvd.tsx new file mode 100644 index 0000000..7adeaf2 --- /dev/null +++ b/apps/web/components/toolbar/cvd.tsx @@ -0,0 +1,34 @@ +import primaryToolbarMenu from "../ui/primary-toolbar-menu"; +import { useColorStore } from "@/store/store"; +import ToolbarMenuItem from "./toolbar-menu-item"; +import { cn } from "@ui/lib/utils"; +import { handleToggleReadability } from "@/app/actions"; + +function CVD() { + const menuItem = primaryToolbarMenu["Color Vision Deficiency"]; + const { showReadability } = useColorStore(); + return ( +
{ + e.preventDefault(); + handleToggleReadability(`${!showReadability}`); + useColorStore.setState({ showReadability: !showReadability }); + }} + > + +
+ ); +} + +export default CVD; + +CVD.displayName = "CVD"; diff --git a/apps/web/components/toolbar/index.tsx b/apps/web/components/toolbar/index.tsx index 25d25b8..c9dbd53 100644 --- a/apps/web/components/toolbar/index.tsx +++ b/apps/web/components/toolbar/index.tsx @@ -1,6 +1,8 @@ import ToolbarUploadImage from "./upload-image"; import ToolbarDownloadBasePalette from "./download-base-palette"; import ToolbarShareableLink from "./shareable-link"; +import Readability from "./readability"; +import CVD from "./cvd"; const ToolbarPrimative = ({ children }) => children; @@ -10,4 +12,6 @@ export const Toolbar = Object.assign(ToolbarPrimative, { UploadImage: ToolbarUploadImage, DownloadBasePalette: ToolbarDownloadBasePalette, ShareableLink: ToolbarShareableLink, + Readability: Readability, + CVD: CVD, }); diff --git a/apps/web/components/toolbar/readability.tsx b/apps/web/components/toolbar/readability.tsx new file mode 100644 index 0000000..5e1ebc5 --- /dev/null +++ b/apps/web/components/toolbar/readability.tsx @@ -0,0 +1,47 @@ +import primaryToolbarMenu from "../ui/primary-toolbar-menu"; +import { useColorStore } from "@/store/store"; +import ToolbarMenuItem from "./toolbar-menu-item"; +import { cn } from "@ui/lib/utils"; +import { handleToggleReadability } from "@/app/actions"; +import { useState } from "react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@ui/components/ui/popover"; + +import dynamic from "next/dynamic"; + +const ReadabilityPlugin = dynamic(() => import("./plugin/readability.plugin"), { + ssr: false, +}); + +function Readability() { + const menuItem = primaryToolbarMenu.Readability; + const [open, setOpen] = useState(false); + const { showReadability } = useColorStore(); + return ( + + + + + + {open && } + + + ); +} + +export default Readability; + +Readability.displayName = "Readability"; diff --git a/apps/web/components/ui/color-mode-dropdown-menu.tsx b/apps/web/components/ui/color-mode-dropdown-menu.tsx index f82f02c..392f3b0 100644 --- a/apps/web/components/ui/color-mode-dropdown-menu.tsx +++ b/apps/web/components/ui/color-mode-dropdown-menu.tsx @@ -10,10 +10,7 @@ import { } from "@ui/components/ui/dropdown-menu"; import { useColorStore } from "@/store/store"; import { ColorMode } from "@/types/app"; -import { ChevronDownIcon } from "lucide-react"; -import { useEffect, useState } from "react"; function ColorModeDropdownMenu() { - const [mounted, setMounted] = useState(false); const { colorMode, setColorMode } = useColorStore(); const handleChangeColorMode = async (mode: ColorMode) => { setColorMode(mode); diff --git a/apps/web/components/ui/desktop-primary-toolbar-buttons.tsx b/apps/web/components/ui/desktop-primary-toolbar-buttons.tsx index 0fde3de..3ce3d63 100644 --- a/apps/web/components/ui/desktop-primary-toolbar-buttons.tsx +++ b/apps/web/components/ui/desktop-primary-toolbar-buttons.tsx @@ -12,6 +12,8 @@ function DesktopPreviewToolbarButtons() { )} > + + {/* coming soon */} diff --git a/apps/web/components/ui/primary-toolbar-menu.tsx b/apps/web/components/ui/primary-toolbar-menu.tsx index 6091dac..8c5112c 100644 --- a/apps/web/components/ui/primary-toolbar-menu.tsx +++ b/apps/web/components/ui/primary-toolbar-menu.tsx @@ -1,9 +1,12 @@ -import { Download, ImagePlus, Link } from "lucide-react"; +import { Download, Eye, ImagePlus, Link } from "lucide-react"; +import EyeCVD from "../svg/eye-cvd"; export enum ToolbarMenus { UPLOAD_IMAGE = "Upload Image", DOWNLOAD = "Download", SHARE = "Share", + READABILITY = "Readability", + CVD = "Color Vision Deficiency", } export type ToolbarMenu = { @@ -17,6 +20,16 @@ export type PrimaryToolbarMenu = { }; const primaryToolbarMenu: PrimaryToolbarMenu = { + [ToolbarMenus.READABILITY]: { + title: "Readability", + description: "Check the contrast ratio of your palette", + icon: Eye, + }, + [ToolbarMenus.CVD]: { + title: "Color Vision Deficiency", + description: "Simulate color vision deficiency", + icon: EyeCVD, + }, [ToolbarMenus.UPLOAD_IMAGE]: { title: "Upload Image", description: "Generate a color palette from an image", diff --git a/apps/web/lib/generateReadability.tsx b/apps/web/lib/generateReadability.tsx new file mode 100644 index 0000000..0aa5480 --- /dev/null +++ b/apps/web/lib/generateReadability.tsx @@ -0,0 +1,78 @@ +import { ColorPalettes, ColorValue } from "@/types/app"; +import tinycolor from "tinycolor2"; + +export const generateReadability = ({ + foreground, + background, + primaryPalette, + secondaryPalette, + accentPalette, + grayPalette, +}: { + foreground: ColorValue; + background: ColorValue; + primaryPalette: ColorPalettes["primary"]; + secondaryPalette: ColorPalettes["secondary"]; + accentPalette: ColorPalettes["accent"]; + grayPalette: ColorPalettes["gray"]; +}): ColorPalettes => { + /* + ColorValue = { + step: number; + color: string; + raw: RawColor; + readability?: { + foreground: ColorReadability; + background: ColorReadability; + }; + + type ColorReadability = { + readability: number; + isReadable: boolean; +}; + +tinycolor refs: +readability: function(TinyColor, TinyColor) -> Object. Returns the contrast ratio between two colors. +isReadable: function(TinyColor, TinyColor, Object) -> Boolean. Ensure that foreground and background color combinations meet WCAG guidelines. +} + */ + const foregroundColor = tinycolor(foreground.color); + const backgroundColor = tinycolor(background.color); + // compare each color in the palette to the foreground and background + // and insert the readability score into the palette + const [primary, secondary, accent, gray] = [ + primaryPalette, + secondaryPalette, + accentPalette, + grayPalette, + ].map((palette) => { + return palette.map((color) => { + const colorValue = tinycolor(color.color); + return { + ...color, + readability: { + foreground: { + readability: + Math.round( + tinycolor.readability(foregroundColor, colorValue) * 100, + ) / 100, + isReadable: tinycolor.isReadable(foregroundColor, colorValue), + }, + background: { + readability: + Math.round( + tinycolor.readability(backgroundColor, colorValue) * 100, + ) / 100, + isReadable: tinycolor.isReadable(backgroundColor, colorValue), + }, + }, + }; + }); + }); + return { + primary, + secondary, + accent, + gray, + }; +}; diff --git a/apps/web/lib/getServerColors.ts b/apps/web/lib/getServerColors.ts index 6edfc15..ddeef83 100644 --- a/apps/web/lib/getServerColors.ts +++ b/apps/web/lib/getServerColors.ts @@ -3,11 +3,13 @@ import { generateBaseColors } from "./generateBaseColors"; import { generateColorPalette } from "./colorCalculator"; import { BaseColorTypes, ColorMode } from "@/types/app"; import { colorHelper } from "./colorHelper"; +import { generateReadability } from "./generateReadability"; export const getServerColors = async () => { const newBaseColors = generateBaseColors(); const cookieStore = cookies(); const mode = cookieStore.get("colorMode"); + const showReadability = cookieStore.get("showReadability"); // short-hand const [primaryPalette, secondaryPalette, accentPalette, grayPalette] = [ "primary", @@ -21,14 +23,30 @@ export const getServerColors = async () => { colorMode: mode ? (mode.value as ColorMode) : ColorMode.HEX, }), ); + /* Calculate WCAG 2.0 */ + const foreground = grayPalette[0]; + const background = grayPalette[10]; + const palettesWithReadability = generateReadability({ + foreground, + background, + primaryPalette, + secondaryPalette, + accentPalette, + grayPalette, + }); return { baseColors: newBaseColors, colorPalettes: { - primary: primaryPalette, - secondary: secondaryPalette, - accent: accentPalette, - gray: grayPalette, + primary: palettesWithReadability.primary, + secondary: palettesWithReadability.secondary, + accent: palettesWithReadability.accent, + gray: palettesWithReadability.gray, }, colorMode: mode ? (mode.value as ColorMode) : ColorMode.HEX, + showReadability: showReadability?.value + ? showReadability.value === "true" + ? true + : false + : false, }; }; diff --git a/apps/web/store/store.ts b/apps/web/store/store.ts index ae36735..544d960 100644 --- a/apps/web/store/store.ts +++ b/apps/web/store/store.ts @@ -20,12 +20,14 @@ import { } from "zustand/middleware"; import { generateColorPalette } from "@/lib/colorCalculator"; import { updateCSSVariables } from "@/lib/updateCssVariables"; +import { generateReadability } from "@/lib/generateReadability"; // Zustand store type export type ColorStore = { colorMode: ColorMode; baseColors: Omit; colorPalettes: ColorPalettes; + showReadability: boolean; setColorMode: (mode: ColorMode) => void; generatePalette: (existing?: boolean) => void; updateBaseColor: (newBaseColor: keyof BaseColors, newColor: RawColor) => void; @@ -40,6 +42,7 @@ let localAndUrlStore = (set, get) => ({ accent: [], gray: [], }, + showReadability: false, setColorMode: (mode) => set({ colorMode: mode }), updateBaseColor: (type: BaseColorTypes, newColor: RawColor) => { const [newPalette, grayPalette] = [ @@ -54,11 +57,22 @@ let localAndUrlStore = (set, get) => ({ colorMode: get().colorMode, }), ]; + /* Calculate WCAG 2.0 */ + const foreground = grayPalette[0]; + const background = grayPalette[10]; + const palettesWithReadability = generateReadability({ + foreground, + background, + primaryPalette: newPalette, + secondaryPalette: get().colorPalettes.secondary, + accentPalette: get().colorPalettes.accent, + grayPalette, + }); set( produce((state: ColorStore) => { state.baseColors[type] = newColor; - state.colorPalettes[type] = newPalette; - state.colorPalettes.gray = grayPalette; + state.colorPalettes[type] = palettesWithReadability[type]; + state.colorPalettes.gray = palettesWithReadability.gray; }), ); const { colorPalettes, baseColors, colorMode } = get(); @@ -88,14 +102,25 @@ let localAndUrlStore = (set, get) => ({ colorMode: get().colorMode, }), ); + /* Calculate WCAG 2.0 */ + const foreground = grayPalette[0]; + const background = grayPalette[10]; + const palettesWithReadability = generateReadability({ + foreground, + background, + primaryPalette, + secondaryPalette, + accentPalette, + grayPalette, + }); set( produce((state: ColorStore) => { state.baseColors = newBaseColors; state.colorPalettes = { - primary: primaryPalette, - secondary: secondaryPalette, - accent: accentPalette, - gray: grayPalette, + primary: palettesWithReadability.primary, + secondary: palettesWithReadability.secondary, + accent: palettesWithReadability.accent, + gray: palettesWithReadability.gray, }; }), ); diff --git a/apps/web/types/app.ts b/apps/web/types/app.ts index e9c1793..3b53064 100644 --- a/apps/web/types/app.ts +++ b/apps/web/types/app.ts @@ -21,11 +21,30 @@ export type BaseColorTypes = "primary" | "secondary" | "accent" | "gray"; export type BaseColors = Record; +export type ColorReadability = { + readability: number; + isReadable: boolean; +}; + +export enum CVDType { + DeficiencyProt = "protanomaly and protanopia", + DeficiencyDeuter = "deuteranomaly and deuteranopia", + DeficiencyTrit = "tritanomaly and tritanopia", +} + export type ColorValue = | { step: number; color: string; raw: RawColor; + /** + * Readability is calculated using the WCAG 2.0 formula + * Compares the contrast ratio between the foreground and background colors + */ + readability?: { + foreground: ColorReadability; + background: ColorReadability; + }; } | undefined;