From fa639645660f3c253aef4b9820618935b312c56e Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:17:59 +0530 Subject: [PATCH] feat: custom theming enhancements (#8342) --- apps/space/core/store/profile.store.ts | 4 - .../settings/account/preferences/page.tsx | 6 +- .../web/app/(all)/profile/appearance/page.tsx | 70 ++-- apps/web/app/layout.tsx | 2 +- .../components/preferences/theme-switcher.tsx | 56 +-- .../components/core/theme/config-handler.tsx | 135 +++++++ .../core/theme/custom-theme-selector.tsx | 333 +++++++----------- .../components/core/theme/theme-switch.tsx | 3 + apps/web/core/lib/wrappers/store-wrapper.tsx | 29 +- apps/web/core/store/user/profile.store.ts | 16 +- packages/tailwind-config/index.css | 2 +- packages/types/src/users.ts | 16 +- packages/utils/package.json | 2 + packages/utils/src/index.ts | 1 + packages/utils/src/theme-legacy.ts | 21 ++ packages/utils/src/theme.ts | 125 ------- packages/utils/src/theme/color-conversion.ts | 138 ++++++++ packages/utils/src/theme/color-validation.ts | 91 +++++ packages/utils/src/theme/constants.ts | 114 ++++++ packages/utils/src/theme/index.ts | 53 +++ packages/utils/src/theme/palette-generator.ts | 214 +++++++++++ packages/utils/src/theme/theme-application.ts | 112 ++++++ packages/utils/src/theme/theme-inversion.ts | 93 +++++ pnpm-lock.yaml | 32 +- 24 files changed, 1203 insertions(+), 465 deletions(-) create mode 100644 apps/web/core/components/core/theme/config-handler.tsx create mode 100644 packages/utils/src/theme-legacy.ts delete mode 100644 packages/utils/src/theme.ts create mode 100644 packages/utils/src/theme/color-conversion.ts create mode 100644 packages/utils/src/theme/color-validation.ts create mode 100644 packages/utils/src/theme/constants.ts create mode 100644 packages/utils/src/theme/index.ts create mode 100644 packages/utils/src/theme/palette-generator.ts create mode 100644 packages/utils/src/theme/theme-application.ts create mode 100644 packages/utils/src/theme/theme-inversion.ts diff --git a/apps/space/core/store/profile.store.ts b/apps/space/core/store/profile.store.ts index 009b46ca49..84455bc08d 100644 --- a/apps/space/core/store/profile.store.ts +++ b/apps/space/core/store/profile.store.ts @@ -32,13 +32,9 @@ export class ProfileStore implements IProfileStore { last_workspace_id: undefined, theme: { theme: undefined, - text: undefined, - palette: undefined, primary: undefined, background: undefined, darkPalette: undefined, - sidebarText: undefined, - sidebarBackground: undefined, }, onboarding_step: { workspace_join: false, diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx index 1df1df643c..f3098f675a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -10,7 +10,7 @@ import { SettingsHeading } from "@/components/settings/heading"; // hooks import { useUserProfile } from "@/hooks/store/user"; -function ProfileAppearancePage() { +const ProfileAppearancePage = observer(() => { const { t } = useTranslation(); // hooks const { data: userProfile } = useUserProfile(); @@ -34,6 +34,6 @@ function ProfileAppearancePage() { ); -} +}); -export default observer(ProfileAppearancePage); +export default ProfileAppearancePage; diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx index 68ba9779e6..33102b062b 100644 --- a/apps/web/app/(all)/profile/appearance/page.tsx +++ b/apps/web/app/(all)/profile/appearance/page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports @@ -6,9 +6,7 @@ import type { I_THEME_OPTION } from "@plane/constants"; import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setPromiseToast } from "@plane/propel/toast"; -import type { IUserTheme } from "@plane/types"; // components -import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; import { LogoSpinner } from "@/components/common/logo-spinner"; import { PageHead } from "@/components/core/page-title"; import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; @@ -19,46 +17,36 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti import { useUserProfile } from "@/hooks/store/user"; function ProfileAppearancePage() { - const { t } = useTranslation(); - const { setTheme } = useTheme(); - // states - const [currentTheme, setCurrentTheme] = useState(null); - // hooks + // store hooks const { data: userProfile, updateUserTheme } = useUserProfile(); - - useEffect(() => { - if (userProfile?.theme?.theme) { - const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); - if (userThemeOption) { - setCurrentTheme(userThemeOption); - } - } + // theme + const { setTheme } = useTheme(); + // translation + const { t } = useTranslation(); + // derived values + const currentTheme = useMemo(() => { + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); + return userThemeOption || null; }, [userProfile?.theme?.theme]); - const handleThemeChange = (themeOption: I_THEME_OPTION) => { - applyThemeChange({ theme: themeOption.value }); - - const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); - setPromiseToast(updateCurrentUserThemePromise, { - loading: "Updating theme...", - success: { - title: "Success!", - message: () => "Theme updated successfully!", - }, - error: { - title: "Error!", - message: () => "Failed to Update the theme", - }, - }); - }; - - const applyThemeChange = (theme: Partial) => { - setTheme(theme?.theme || "system"); - - if (theme?.theme === "custom" && theme?.palette) { - applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false); - } else unsetCustomCssVariables(); - }; + const handleThemeChange = useCallback( + (themeOption: I_THEME_OPTION) => { + setTheme(themeOption.value); + const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updateCurrentUserThemePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully.", + }, + error: { + title: "Error!", + message: () => "Failed to update the theme.", + }, + }); + }, + [updateUserTheme] + ); return ( <> @@ -75,7 +63,7 @@ function ProfileAppearancePage() { - {userProfile?.theme?.theme === "custom" && } + {userProfile?.theme?.theme === "custom" && } ) : (
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 3638de072c..47acca9984 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
+
{children}
diff --git a/apps/web/ce/components/preferences/theme-switcher.tsx b/apps/web/ce/components/preferences/theme-switcher.tsx index 3b8906fd90..cdb8ecb40f 100644 --- a/apps/web/ce/components/preferences/theme-switcher.tsx +++ b/apps/web/ce/components/preferences/theme-switcher.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports @@ -6,8 +6,6 @@ import type { I_THEME_OPTION } from "@plane/constants"; import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setPromiseToast } from "@plane/propel/toast"; -import type { IUserTheme } from "@plane/types"; -import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; // components import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; import { ThemeSwitch } from "@/components/core/theme/theme-switch"; @@ -23,48 +21,22 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: { description: string; }; }) { - // hooks - const { setTheme } = useTheme(); + // store hooks const { data: userProfile, updateUserTheme } = useUserProfile(); - - // states - const [currentTheme, setCurrentTheme] = useState(null); - + // theme + const { setTheme } = useTheme(); + // translation const { t } = useTranslation(); - - // initialize theme - useEffect(() => { - if (!userProfile?.theme?.theme) return; - - const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme); - - if (userThemeOption) { - setCurrentTheme(userThemeOption); - } + // derived values + const currentTheme = useMemo(() => { + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); + return userThemeOption || null; }, [userProfile?.theme?.theme]); - // handlers - const applyThemeChange = useCallback( - (theme: Partial) => { - const themeValue = theme?.theme || "system"; - setTheme(themeValue); - - if (theme?.theme === "custom" && theme?.palette) { - const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5"; - const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette; - applyTheme(palette, false); - } else { - unsetCustomCssVariables(); - } - }, - [setTheme] - ); - const handleThemeChange = useCallback( - async (themeOption: I_THEME_OPTION) => { + (themeOption: I_THEME_OPTION) => { try { - applyThemeChange({ theme: themeOption.value }); - + setTheme(themeOption.value); const updatePromise = updateUserTheme({ theme: themeOption.value }); setPromiseToast(updatePromise, { loading: "Updating theme...", @@ -81,7 +53,7 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: { console.error("Error updating theme:", error); } }, - [applyThemeChange, updateUserTheme] + [updateUserTheme] ); if (!userProfile) return null; @@ -92,12 +64,12 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: { title={t(props.option.title)} description={t(props.option.description)} control={ -
+
} /> - {userProfile.theme?.theme === "custom" && } + {userProfile.theme?.theme === "custom" && } ); }); diff --git a/apps/web/core/components/core/theme/config-handler.tsx b/apps/web/core/components/core/theme/config-handler.tsx new file mode 100644 index 0000000000..a426c75079 --- /dev/null +++ b/apps/web/core/components/core/theme/config-handler.tsx @@ -0,0 +1,135 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +import type { UseFormGetValues, UseFormSetValue } from "react-hook-form"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { setToast, TOAST_TYPE } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; + +type Props = { + getValues: UseFormGetValues; + handleUpdateTheme: (formData: IUserTheme) => Promise; + setValue: UseFormSetValue; +}; + +export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) { + const { getValues, handleUpdateTheme, setValue } = props; + // refs + const fileInputRef = useRef(null); + // translation + const { t } = useTranslation(); + + const handleDownloadConfig = () => { + try { + const currentValues = getValues(); + const config = { + version: "1.0", + themeName: "Custom Theme", + primary: currentValues.primary, + background: currentValues.background, + darkPalette: currentValues.darkPalette, + }; + + const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `plane-theme-${Date.now()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("success"), + message: "Theme configuration downloaded successfully.", + }); + } catch (error) { + console.error("Failed to download config:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Failed to download theme configuration.", + }); + } + }; + + const handleUploadConfig = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const config = JSON.parse(text) as IUserTheme; + + // Validate required fields + if (!config.primary || !config.background) { + throw new Error("Missing required fields: primary and background"); + } + + // Validate hex color format + const hexPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + if (!hexPattern.test(config.primary)) { + throw new Error("Invalid brand color hex format"); + } + if (!hexPattern.test(config.background)) { + throw new Error("Invalid neutral color hex format"); + } + + // Validate theme mode + const themeMode = config.darkPalette ?? false; + if (typeof themeMode !== "boolean") { + throw new Error("Invalid theme mode. Must be a boolean"); + } + + // Apply the configuration to form + const formData: IUserTheme = { + theme: "custom", + primary: config.primary, + background: config.background, + darkPalette: themeMode, + }; + + // Update form values + setValue("primary", formData.primary); + setValue("background", formData.background); + setValue("darkPalette", formData.darkPalette); + setValue("theme", "custom"); + + // Apply the theme + await handleUpdateTheme(formData); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("success"), + message: "Theme configuration imported successfully", + }); + } catch (error) { + console.error("Failed to upload config:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: error instanceof Error ? error.message : "Failed to import theme configuration", + }); + } finally { + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + return ( +
+ + + +
+ ); +}); diff --git a/apps/web/core/components/core/theme/custom-theme-selector.tsx b/apps/web/core/components/core/theme/custom-theme-selector.tsx index e3e5083b68..a249ec20e0 100644 --- a/apps/web/core/components/core/theme/custom-theme-selector.tsx +++ b/apps/web/core/components/core/theme/custom-theme-selector.tsx @@ -1,126 +1,95 @@ -import { useMemo } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -// types -import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +// plane imports import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { setPromiseToast } from "@plane/propel/toast"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUserTheme } from "@plane/types"; -// ui -import { InputColorPicker } from "@plane/ui"; +import { InputColorPicker, ToggleSwitch } from "@plane/ui"; +import { applyCustomTheme } from "@plane/utils"; // hooks -import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; import { useUserProfile } from "@/hooks/store/user"; +// local imports +import { CustomThemeConfigHandler } from "./config-handler"; -type TCustomThemeSelector = { - applyThemeChange: (theme: Partial) => void; -}; - -export const CustomThemeSelector = observer(function CustomThemeSelector(props: TCustomThemeSelector) { - const { applyThemeChange } = props; - // hooks +export const CustomThemeSelector = observer(function CustomThemeSelector() { + // store hooks const { data: userProfile, updateUserTheme } = useUserProfile(); + // translation const { t } = useTranslation(); - const { - control, - formState: { errors, isSubmitting }, - handleSubmit, - watch, - } = useForm({ - defaultValues: { - background: userProfile?.theme?.background !== "" ? userProfile?.theme?.background : "#0d101b", - text: userProfile?.theme?.text !== "" ? userProfile?.theme?.text : "#c5c5c5", - primary: userProfile?.theme?.primary !== "" ? userProfile?.theme?.primary : "#3f76ff", - sidebarBackground: - userProfile?.theme?.sidebarBackground !== "" ? userProfile?.theme?.sidebarBackground : "#0d101b", - sidebarText: userProfile?.theme?.sidebarText !== "" ? userProfile?.theme?.sidebarText : "#c5c5c5", - darkPalette: userProfile?.theme?.darkPalette || false, - palette: userProfile?.theme?.palette !== "" ? userProfile?.theme?.palette : "", - }, - }); - const inputRules = useMemo( - () => ({ - minLength: { - value: 7, - message: t("enter_a_valid_hex_code_of_6_characters"), - }, - maxLength: { - value: 7, - message: t("enter_a_valid_hex_code_of_6_characters"), - }, - pattern: { - value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - message: t("enter_a_valid_hex_code_of_6_characters"), - }, - }), - [t] // Empty dependency array since these rules never change - ); + // Loading state for async palette generation + const [isLoadingPalette, setIsLoadingPalette] = useState(false); - const handleUpdateTheme = async (formData: Partial) => { - const payload: IUserTheme = { - background: formData.background, - text: formData.text, - primary: formData.primary, - sidebarBackground: formData.sidebarBackground, - sidebarText: formData.sidebarText, - darkPalette: false, - palette: `${formData.background},${formData.text},${formData.primary},${formData.sidebarBackground},${formData.sidebarText}`, + // Load saved theme from userProfile (fallback to defaults) + const getSavedTheme = (): IUserTheme => { + if (userProfile?.theme) { + const theme = userProfile.theme; + if (theme.primary && theme.background && theme.darkPalette !== undefined) { + return { + theme: "custom", + primary: theme.primary, + background: theme.background, + darkPalette: theme.darkPalette, + }; + } + } + // Fallback to defaults + return { theme: "custom", + primary: "#3f76ff", + background: "#1a1a1a", + darkPalette: false, }; - applyThemeChange(payload); - - const updateCurrentUserThemePromise = updateUserTheme(payload); - setPromiseToast(updateCurrentUserThemePromise, { - loading: t("updating_theme"), - success: { - title: t("success"), - message: () => t("theme_updated_successfully"), - }, - error: { - title: t("error"), - message: () => t("failed_to_update_the_theme"), - }, - }); - updateCurrentUserThemePromise - .then(() => { - captureElementAndEvent({ - element: { - elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN, - }, - event: { - eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated, - payload: { - theme: payload.theme, - }, - state: "SUCCESS", - }, - }); - }) - .catch(() => { - captureElementAndEvent({ - element: { - elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN, - }, - event: { - eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated, - payload: { - theme: payload.theme, - }, - state: "ERROR", - }, - }); - }); - - return; }; - const handleValueChange = (val: string | undefined, onChange: any) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + getValues, + watch, + setValue, + } = useForm({ + defaultValues: getSavedTheme(), + }); + + const handleUpdateTheme = async (formData: IUserTheme) => { + if (!formData.primary || !formData.background || formData.darkPalette === undefined) return; + + try { + setIsLoadingPalette(true); + applyCustomTheme(formData.primary, formData.background, formData.darkPalette ? "dark" : "light"); + // Save to profile endpoint + await updateUserTheme({ + theme: "custom", + primary: formData.primary, + background: formData.background, + darkPalette: formData.darkPalette, + }); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("success"), + message: t("theme_updated_successfully"), + }); + } catch (error) { + console.error("Failed to apply theme:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("failed_to_update_the_theme"), + }); + } finally { + setIsLoadingPalette(false); + } + }; + + const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => { let hex = val; // prepend a hashtag if it doesn't exist if (val && val[0] !== "#") hex = `#${val}`; - onChange(hex); }; @@ -128,146 +97,98 @@ export const CustomThemeSelector = observer(function CustomThemeSelector(props:

{t("customize_your_theme")}

+
-
+ {/* Color Inputs */} +
+ {/* Brand Color */}
-

{t("background_color")}

-
- ( - handleValueChange(val, onChange)} - placeholder="#0d101b" - className="w-full placeholder:text-placeholder/60" - style={{ - backgroundColor: watch("background"), - color: watch("text"), - }} - hasError={Boolean(errors?.background)} - /> - )} - /> - {errors.background &&

{errors.background.message}

} -
-
- -
-

{t("text_color")}

-
- ( - handleValueChange(val, onChange)} - placeholder="#c5c5c5" - className="w-full placeholder:text-placeholder/60" - style={{ - backgroundColor: watch("text"), - color: watch("background"), - }} - hasError={Boolean(errors?.text)} - /> - )} - /> - {errors.text &&

{errors.text.message}

} -
-
- -
-

{t("primary_color")}

+

Brand color

( handleValueChange(val, onChange)} placeholder="#3f76ff" - className="w-full placeholder:text-placeholder/60" + className="w-full placeholder:text-placeholder" style={{ - backgroundColor: watch("primary"), - color: watch("text"), + backgroundColor: value, + color: "#ffffff", }} - hasError={Boolean(errors?.primary)} + hasError={false} /> )} /> - {errors.primary &&

{errors.primary.message}

}
+ {/* Neutral Color */}
-

{t("sidebar_background_color")}

+

Neutral color

( handleValueChange(val, onChange)} - placeholder="#0d101b" - className="w-full placeholder:text-placeholder/60" + placeholder="#1a1a1a" + className="w-full placeholder:text-placeholder" style={{ - backgroundColor: watch("sidebarBackground"), - color: watch("sidebarText"), + backgroundColor: value, + color: "#ffffff", }} - hasError={Boolean(errors?.sidebarBackground)} + hasError={false} /> )} /> - {errors.sidebarBackground && ( -

{errors.sidebarBackground.message}

- )} -
-
- -
-

{t("sidebar_text_color")}

-
- ( - handleValueChange(val, onChange)} - placeholder="#c5c5c5" - className="w-full placeholder:text-placeholder/60" - style={{ - backgroundColor: watch("sidebarText"), - color: watch("sidebarBackground"), - }} - hasError={Boolean(errors?.sidebarText)} - /> - )} - /> - {errors.sidebarText &&

{errors.sidebarText.message}

}
-
- + +
+ {/* Import/Export Section */} + + +
+ {/* Theme Mode Toggle */} +
+ ( + + )} + /> + {watch("darkPalette") ? "Dark mode" : "Light mode"} +
+ {/* Save Theme Button */} + +
); diff --git a/apps/web/core/components/core/theme/theme-switch.tsx b/apps/web/core/components/core/theme/theme-switch.tsx index a7d9cc8262..a6cd5cb41f 100644 --- a/apps/web/core/components/core/theme/theme-switch.tsx +++ b/apps/web/core/components/core/theme/theme-switch.tsx @@ -14,7 +14,9 @@ type Props = { export function ThemeSwitch(props: Props) { const { value, onChange } = props; + // translation const { t } = useTranslation(); + return ( {THEME_OPTIONS.map((themeOption) => ( diff --git a/apps/web/core/lib/wrappers/store-wrapper.tsx b/apps/web/core/lib/wrappers/store-wrapper.tsx index b05f578bb1..69cc819a5c 100644 --- a/apps/web/core/lib/wrappers/store-wrapper.tsx +++ b/apps/web/core/lib/wrappers/store-wrapper.tsx @@ -6,7 +6,7 @@ import { useTheme } from "next-themes"; import type { TLanguage } from "@plane/i18n"; import { useTranslation } from "@plane/i18n"; // helpers -import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; +import { applyCustomTheme, clearCustomTheme } from "@plane/utils"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useRouterParams } from "@/hooks/store/use-router-params"; @@ -16,7 +16,7 @@ type TStoreWrapper = { children: ReactNode; }; -const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) { +function StoreWrapper(props: TStoreWrapper) { const { children } = props; // theme const { setTheme } = useTheme(); @@ -38,22 +38,25 @@ const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) { }, [sidebarCollapsed, setTheme, toggleSidebar]); /** - * Setting up the theme of the user by fetching it from local storage + * Setting up the theme of the user by fetching it from profile */ useEffect(() => { if (!userProfile?.theme?.theme) return; const currentTheme = userProfile?.theme?.theme || "system"; - const currentThemePalette = userProfile?.theme?.palette; + const theme = userProfile?.theme; + if (currentTheme) { setTheme(currentTheme); - if (currentTheme === "custom" && currentThemePalette) { - applyTheme( - currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", - false - ); - } else unsetCustomCssVariables(); + if (currentTheme === "custom") { + // New 2-color palette system + if (theme.primary && theme.background && theme.darkPalette !== undefined) { + applyCustomTheme(theme.primary, theme.background, theme.darkPalette ? "dark" : "light"); + } + } else { + clearCustomTheme(); + } } - }, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]); + }, [userProfile?.theme, setTheme]); useEffect(() => { if (!userProfile?.language) return; @@ -66,6 +69,6 @@ const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) { }, [params, setQuery]); return <>{children}; -}); +} -export default StoreWrapper; +export default observer(StoreWrapper); diff --git a/apps/web/core/store/user/profile.store.ts b/apps/web/core/store/user/profile.store.ts index ca8ce0cc56..5e9d67de69 100644 --- a/apps/web/core/store/user/profile.store.ts +++ b/apps/web/core/store/user/profile.store.ts @@ -36,13 +36,9 @@ export class ProfileStore implements IUserProfileStore { last_workspace_id: undefined, theme: { theme: undefined, - text: undefined, - palette: undefined, primary: undefined, background: undefined, - darkPalette: undefined, - sidebarText: undefined, - sidebarBackground: undefined, + darkPalette: false, }, onboarding_step: { workspace_join: false, @@ -219,12 +215,14 @@ export class ProfileStore implements IUserProfileStore { const currentProfileTheme = cloneDeep(this.data.theme); try { runInAction(() => { - Object.keys(data).forEach((key: string) => { - const userKey: keyof IUserTheme = key as keyof IUserTheme; - if (this.data.theme) set(this.data.theme, userKey, data[userKey]); + Object.keys(data).forEach((key) => { + const dataKey = key as keyof IUserTheme; + if (this.data.theme) set(this.data.theme, dataKey, data[dataKey]); }); }); - const userProfile = await this.userService.updateCurrentUserProfile({ theme: this.data.theme }); + const userProfile = await this.userService.updateCurrentUserProfile({ + theme: this.data.theme, + }); return userProfile; } catch (error) { runInAction(() => { diff --git a/packages/tailwind-config/index.css b/packages/tailwind-config/index.css index 92958854df..2111e03430 100644 --- a/packages/tailwind-config/index.css +++ b/packages/tailwind-config/index.css @@ -14,7 +14,7 @@ } body { - @apply font-body text-primary; + @apply font-body bg-canvas text-primary; } /* emoji icon picker */ diff --git a/packages/types/src/users.ts b/packages/types/src/users.ts index 9278996a7a..327cbb482d 100644 --- a/packages/types/src/users.ts +++ b/packages/types/src/users.ts @@ -59,14 +59,10 @@ export type TUserProfile = { role: string | undefined; last_workspace_id: string | undefined; theme: { - text: string | undefined; theme: string | undefined; - palette: string | undefined; primary: string | undefined; background: string | undefined; darkPalette: boolean | undefined; - sidebarText: string | undefined; - sidebarBackground: string | undefined; }; onboarding_step: TOnboardingSteps; is_onboarded: boolean; @@ -101,14 +97,10 @@ export interface IUserSettings { } export interface IUserTheme { - text: string | undefined; - theme: string | undefined; - palette: string | undefined; - primary: string | undefined; - background: string | undefined; - darkPalette: boolean | undefined; - sidebarText: string | undefined; - sidebarBackground: string | undefined; + theme: string | undefined; // 'light', 'dark', 'custom', etc. + primary?: string | undefined; + background?: string | undefined; + darkPalette?: boolean | undefined; } export interface IUserMemberLite extends IUserLite { diff --git a/packages/utils/package.json b/packages/utils/package.json index a307ce450b..7b1c656e97 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -25,6 +25,7 @@ "dependencies": { "@plane/constants": "workspace:*", "@plane/types": "workspace:*", + "chroma-js": "^3.2.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "hast": "^1.0.0", @@ -44,6 +45,7 @@ }, "devDependencies": { "@plane/typescript-config": "workspace:*", + "@types/chroma-js": "^3.1.2", "@types/hast": "^3.0.4", "@types/lodash-es": "catalog:", "@types/mdast": "^4.0.4", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7cc4c58e95..4b72ed3c94 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -28,6 +28,7 @@ export * from "./string"; export * from "./subscription"; export * from "./tab-indices"; export * from "./theme"; +export { resolveGeneralTheme } from "./theme-legacy"; export * from "./url"; export * from "./work-item-filters"; export * from "./work-item"; diff --git a/packages/utils/src/theme-legacy.ts b/packages/utils/src/theme-legacy.ts new file mode 100644 index 0000000000..1a8574313a --- /dev/null +++ b/packages/utils/src/theme-legacy.ts @@ -0,0 +1,21 @@ +/** + * Legacy Theme System + * + * This file contains the old 5-color theme system for backward compatibility. + * + * @deprecated Most functions in this file are deprecated + * New code should use the OKLCH-based theme system from ./theme/ instead + * + * Functions: + * - applyTheme: OLD 5-color theme system (background, text, primary, sidebarBg, sidebarText) + * - unsetCustomCssVariables: Clears both old AND new theme variables (updated for OKLCH) + * - resolveGeneralTheme: Utility to resolve theme mode (still useful) + * - migrateLegacyTheme: Converts old 5-color theme to new 2-color system + * + * For new implementations: + * - Use: import { applyCustomTheme, clearCustomTheme } from '@plane/utils/theme' + * - See: packages/utils/src/theme/theme-application.ts + */ + +export const resolveGeneralTheme = (resolvedTheme: string | undefined) => + resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/packages/utils/src/theme.ts b/packages/utils/src/theme.ts deleted file mode 100644 index 54e6d42540..0000000000 --- a/packages/utils/src/theme.ts +++ /dev/null @@ -1,125 +0,0 @@ -// local imports -import type { TRgb } from "./color"; -import { hexToRgb } from "./color"; - -type TShades = { - 10: TRgb; - 20: TRgb; - 30: TRgb; - 40: TRgb; - 50: TRgb; - 60: TRgb; - 70: TRgb; - 80: TRgb; - 90: TRgb; - 100: TRgb; - 200: TRgb; - 300: TRgb; - 400: TRgb; - 500: TRgb; - 600: TRgb; - 700: TRgb; - 800: TRgb; - 900: TRgb; -}; - -const calculateShades = (hexValue: string): TShades => { - const shades: Partial = {}; - const { r, g, b } = hexToRgb(hexValue); - - const convertHexToSpecificShade = (shade: number): TRgb => { - if (shade <= 100) { - const decimalValue = (100 - shade) / 100; - - const newR = Math.floor(r + (255 - r) * decimalValue); - const newG = Math.floor(g + (255 - g) * decimalValue); - const newB = Math.floor(b + (255 - b) * decimalValue); - - return { - r: newR, - g: newG, - b: newB, - }; - } else { - const decimalValue = 1 - Math.ceil((shade - 100) / 100) / 10; - - const newR = Math.ceil(r * decimalValue); - const newG = Math.ceil(g * decimalValue); - const newB = Math.ceil(b * decimalValue); - - return { - r: newR, - g: newG, - b: newB, - }; - } - }; - - for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) - shades[i as keyof TShades] = convertHexToSpecificShade(i); - - return shades as TShades; -}; - -export const applyTheme = (palette: string, isDarkPalette: boolean) => { - if (!palette) return; - const themeElement = document?.querySelector("html"); - // palette: [bg, text, primary, sidebarBg, sidebarText] - const values: string[] = palette.split(","); - values.push(isDarkPalette ? "dark" : "light"); - - const bgShades = calculateShades(values[0]); - const textShades = calculateShades(values[1]); - const primaryShades = calculateShades(values[2]); - const sidebarBackgroundShades = calculateShades(values[3]); - const sidebarTextShades = calculateShades(values[4]); - - for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) { - const shade = i as keyof TShades; - - const bgRgbValues = `${bgShades[shade].r}, ${bgShades[shade].g}, ${bgShades[shade].b}`; - const textRgbValues = `${textShades[shade].r}, ${textShades[shade].g}, ${textShades[shade].b}`; - const primaryRgbValues = `${primaryShades[shade].r}, ${primaryShades[shade].g}, ${primaryShades[shade].b}`; - const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`; - const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`; - - themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues); - themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues); - themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues); - themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues); - themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues); - - if (i >= 100 && i <= 400) { - const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100; - - themeElement?.style.setProperty( - `--color-border-${shade}`, - `${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}` - ); - themeElement?.style.setProperty( - `--color-sidebar-border-${shade}`, - `${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}` - ); - } - } - - themeElement?.style.setProperty("--color-scheme", values[5]); -}; - -export const unsetCustomCssVariables = () => { - for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) { - const dom = document.querySelector("[data-theme='custom']"); - - dom?.style.removeProperty(`--color-background-${i}`); - dom?.style.removeProperty(`--color-text-${i}`); - dom?.style.removeProperty(`--color-border-${i}`); - dom?.style.removeProperty(`--color-primary-${i}`); - dom?.style.removeProperty(`--color-sidebar-background-${i}`); - dom?.style.removeProperty(`--color-sidebar-text-${i}`); - dom?.style.removeProperty(`--color-sidebar-border-${i}`); - dom?.style.removeProperty("--color-scheme"); - } -}; - -export const resolveGeneralTheme = (resolvedTheme: string | undefined) => - resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/packages/utils/src/theme/color-conversion.ts b/packages/utils/src/theme/color-conversion.ts new file mode 100644 index 0000000000..0d0cec1b0b --- /dev/null +++ b/packages/utils/src/theme/color-conversion.ts @@ -0,0 +1,138 @@ +/** + * Color Conversion Utilities + * Provides hex/RGB/HSL/OKLCH conversions using chroma-js + */ + +import chroma from "chroma-js"; +import { validateAndAdjustOKLCH } from "./color-validation"; + +/** + * RGB color interface + */ +export interface RGB { + r: number; + g: number; + b: number; +} + +/** + * OKLCH color interface (modern perceptual color space) + * L = Lightness (0-1) + * C = Chroma/Saturation + * H = Hue (0-360 degrees) + */ +export interface OKLCH { + l: number; + c: number; + h: number; +} + +/** + * Convert hex color to OKLCH color space + * Uses chroma-js for accurate conversion + */ +export function hexToOKLCH(hex: string): OKLCH { + try { + const cleanHex = hex.replace("#", ""); + const color = chroma(`#${cleanHex}`); + const [l, c, h] = color.oklch(); + + // Validate and adjust if needed + return validateAndAdjustOKLCH({ l, c: c || 0, h: h || 0 }); + } catch (error) { + console.error("Error converting hex to OKLCH:", error); + // Return a safe default (mid-gray) + return { l: 0.5, c: 0, h: 0 }; + } +} + +/** + * Convert OKLCH to CSS string format + * Example: oklch(0.5840 0.1200 250.00) + */ +export function oklchToCSS(oklch: OKLCH, alpha?: number): string { + const { l, c, h } = oklch; + return `oklch(${l.toFixed(4)} ${c.toFixed(4)} ${h.toFixed(2)}${alpha ? ` / ${alpha.toFixed(2)}%` : ""})`; +} + +/** + * Convert hex color to OKLCH CSS string + * Combines hexToOKLCH and oklchToCSS + */ +export function hexToOKLCHString(hex: string): string { + const oklch = hexToOKLCH(hex); + return oklchToCSS(oklch); +} + +/** + * Parse OKLCH CSS string to OKLCH object + * Example: "oklch(0.5840 0.1200 250.00)" -> { l: 0.5840, c: 0.1200, h: 250.00 } + */ +export function parseOKLCH(oklchString: string): OKLCH | null { + const match = oklchString.match(/oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)\)/); + if (match) { + return { + l: parseFloat(match[1]), + c: parseFloat(match[2]), + h: parseFloat(match[3]), + }; + } + return null; +} + +/** + * Convert hex color to RGB object + * Legacy function for backward compatibility + */ +export function hexToRgb(hex: string): RGB { + const cleanHex = hex.replace("#", ""); + + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + return { r, g, b }; +} + +/** + * Convert RGB to hex color + * Legacy function for backward compatibility + */ +export function rgbToHex(rgb: RGB): string { + const { r, g, b } = rgb; + const toHex = (n: number) => { + const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** + * Convert hex to chroma-js HSL + * Returns [hue (0-360), saturation (0-1), lightness (0-1)] + */ +export function hexToHSL(hex: string): [number, number, number] { + try { + const cleanHex = hex.replace("#", ""); + const color = chroma(`#${cleanHex}`); + return color.hsl(); + } catch (error) { + console.error("Error converting hex to HSL:", error); + return [0, 0, 0.5]; // Safe default + } +} + +/** + * Check if a color is grayscale (has no saturation) + */ +export function isGrayscale(hex: string): boolean { + try { + const cleanHex = hex.replace("#", ""); + const color = chroma(`#${cleanHex}`); + const [, s] = color.hsl(); + return isNaN(s) || s < 0.01; // NaN hue or very low saturation + } catch { + return false; + } +} diff --git a/packages/utils/src/theme/color-validation.ts b/packages/utils/src/theme/color-validation.ts new file mode 100644 index 0000000000..9668eed83c --- /dev/null +++ b/packages/utils/src/theme/color-validation.ts @@ -0,0 +1,91 @@ +/** + * Color Validation Utilities + * Validates and adjusts color inputs for palette generation + */ + +/** + * Validate hex color format + * Accepts formats: #RGB, #RRGGBB, RGB, RRGGBB + */ +export function validateHexColor(hex: string): boolean { + if (!hex) return false; + + const cleanHex = hex.replace("#", ""); + const hexRegex = /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/; + + return hexRegex.test(cleanHex); +} + +/** + * Normalize hex color to 6-digit format without # + * Converts #RGB to RRGGBB format + */ +export function normalizeHexColor(hex: string): string { + const cleanHex = hex.replace("#", "").toUpperCase(); + + // Expand 3-digit hex to 6-digit + if (cleanHex.length === 3) { + return cleanHex + .split("") + .map((char) => char + char) + .join(""); + } + + return cleanHex; +} + +/** + * Validate and adjust OKLCH color for better visibility + * Ensures the color is not too extreme (too light or too dark) + */ +export function validateAndAdjustOKLCH(oklch: { l: number; c: number; h: number }): { + l: number; + c: number; + h: number; +} { + let { l, c, h } = oklch; + + // Adjust lightness if too extreme + if (l > 0.95) { + l = 0.9; // Too light - darken slightly + } else if (l < 0.1) { + l = 0.15; // Too dark - lighten slightly + } + + // Ensure minimum chroma for color distinction (not pure gray) + if (c < 0.001) { + c = 0.002; + } + + // Clamp chroma to reasonable range + c = Math.max(0.001, Math.min(0.37, c)); + + // Normalize hue to 0-360 range + h = ((h % 360) + 360) % 360; + + return { l, c, h }; +} + +/** + * Adjust lightness for dark mode with improved algorithm + * Applies different scaling based on original lightness + */ +export function adjustLightnessForDarkMode(lightness: number, offset: number): number { + // Apply offset (negative to make darker) + let adjusted = lightness + offset; + + // Enhanced clamping with better distribution + // Keep very light colors from becoming too dark + if (lightness > 0.9) { + // For very light colors, apply less offset + adjusted = lightness + offset * 0.6; + } else if (lightness < 0.25) { + // For already dark colors, apply more offset to ensure they stay very dark + adjusted = lightness + offset * 1.2; + } + + // Clamp to valid range (0.1 to 0.95) + adjusted = Math.max(0.1, Math.min(0.95, adjusted)); + + return adjusted; +} diff --git a/packages/utils/src/theme/constants.ts b/packages/utils/src/theme/constants.ts new file mode 100644 index 0000000000..eca1f65105 --- /dev/null +++ b/packages/utils/src/theme/constants.ts @@ -0,0 +1,114 @@ +/** + * Theme System Constants + * Defines shade stops, default configurations, and color modes + */ + +/** + * Alpha mapping for 14-shade palette system + */ +export const ALPHA_MAPPING = { + 100: 0.05, + 200: 0.1, + 300: 0.15, + 400: 0.2, + 500: 0.3, + 600: 0.4, + 700: 0.5, + 800: 0.6, + 900: 0.7, + 1000: 0.8, + 1100: 0.9, + 1200: 0.95, +}; + +/** + * All shade stops for 14-shade palette system + * 50 = white, 1000 = black + * Extended range: 50-1000 for more granular control + */ +export const SHADE_STOPS = [50, 100, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950, 1000] as const; + +/** + * Default stop where user input color is anchored + * This is now dynamically calculated based on the input color's lightness + * This constant serves as a fallback only + */ +export const DEFAULT_VALUE_STOP = 500; + +/** + * Baseline lightness values for each stop (in OKLCH L scale 0-1) + * Used to determine which stop best matches an input color + * Based on perceptually uniform distribution + */ +export const BASELINE_LIGHTNESS_MAP: Record = { + 50: 0.98, // Near white + 100: 0.95, // Lightest + 200: 0.88, // Very light + 300: 0.78, // Light + 400: 0.68, // Light-medium + 500: 0.58, // Medium (typical input) + 600: 0.48, // Medium-dark + 700: 0.38, // Dark + 750: 0.28, // Very dark + 800: 0.18, // Darkest + 850: 0.12, // Near black + 900: 0.08, // Almost black + 950: 0.04, // Nearly black + 1000: 0.02, // Black +}; + +/** + * Default hue shift for brand colors (in degrees) + * Adds visual interest by shifting hue at extremes + */ +export const DEFAULT_HUE_SHIFT_BRAND = 10; + +/** + * Default hue shift for neutral colors (in degrees) + * No shift to keep neutrals truly neutral + */ +export const DEFAULT_HUE_SHIFT_NEUTRAL = 0; + +/** + * Default minimum lightness for light mode (0-100 scale) + */ +export const DEFAULT_LIGHT_MODE_LIGHTNESS_MIN = 0; + +/** + * Default maximum lightness for light mode (0-100 scale) + */ +export const DEFAULT_LIGHT_MODE_LIGHTNESS_MAX = 100; + +/** + * Default minimum lightness for dark mode (0-100 scale) + */ +export const DEFAULT_DARK_MODE_LIGHTNESS_MIN = 10; + +/** + * Default maximum lightness for dark mode (0-100 scale) + */ +export const DEFAULT_DARK_MODE_LIGHTNESS_MAX = 80; + +/** + * Color generation modes + * - perceived: HSLuv-based perceptually uniform lightness (recommended) + * - linear: Direct HSL manipulation + */ +export type ColorMode = "perceived" | "linear"; + +/** + * Default color generation mode + */ +export const DEFAULT_COLOR_MODE: ColorMode = "perceived"; + +/** + * Saturation curve types + * - ease-in-out: Increase saturation at extremes (recommended for brand colors) + * - linear: Maintain constant saturation (recommended for neutrals) + */ +export type SaturationCurve = "ease-in-out" | "linear"; + +/** + * Default saturation curve + */ +export const DEFAULT_SATURATION_CURVE: SaturationCurve = "ease-in-out"; diff --git a/packages/utils/src/theme/index.ts b/packages/utils/src/theme/index.ts new file mode 100644 index 0000000000..61d7ba9d98 --- /dev/null +++ b/packages/utils/src/theme/index.ts @@ -0,0 +1,53 @@ +/** + * Theme System Public API + * Exports all theme-related utilities for use across Plane apps + */ + +// Palette generation +export { + calculateDynamicValueStop, + generateColorPalette, + generateThemePalettes, + type ColorPalette, + type PaletteOptions, +} from "./palette-generator"; + +// Theme application +export { applyCustomTheme, clearCustomTheme } from "./theme-application"; + +// Color conversion utilities +export { + hexToHSL, + hexToOKLCH, + hexToOKLCHString, + // hexToRgb, + isGrayscale, + oklchToCSS, + parseOKLCH, + // rgbToHex, + type OKLCH, + type RGB, +} from "./color-conversion"; + +// Color validation +export { normalizeHexColor, validateHexColor } from "./color-validation"; + +// Theme inversion (dark mode) +export { getBrandMapping, getNeutralMapping, invertPalette } from "./theme-inversion"; + +// Constants +export { + BASELINE_LIGHTNESS_MAP, + type ColorMode, + DEFAULT_COLOR_MODE, + DEFAULT_HUE_SHIFT_BRAND, + DEFAULT_HUE_SHIFT_NEUTRAL, + DEFAULT_LIGHT_MODE_LIGHTNESS_MAX, + DEFAULT_LIGHT_MODE_LIGHTNESS_MIN, + DEFAULT_DARK_MODE_LIGHTNESS_MAX, + DEFAULT_DARK_MODE_LIGHTNESS_MIN, + DEFAULT_SATURATION_CURVE, + DEFAULT_VALUE_STOP, + type SaturationCurve, + SHADE_STOPS, +} from "./constants"; diff --git a/packages/utils/src/theme/palette-generator.ts b/packages/utils/src/theme/palette-generator.ts new file mode 100644 index 0000000000..f367786fd8 --- /dev/null +++ b/packages/utils/src/theme/palette-generator.ts @@ -0,0 +1,214 @@ +/** + * Palette Generator + * Generates 14-shade color palettes directly in OKLCH color space + * Keeps C (chroma) and H (hue) constant, only varies L (lightness) + * Inspired by tints.dev but optimized for OKLCH + */ + +import type { OKLCH } from "./color-conversion"; +import { hexToOKLCH, oklchToCSS } from "./color-conversion"; +import { normalizeHexColor, validateHexColor } from "./color-validation"; +import { + BASELINE_LIGHTNESS_MAP, + DEFAULT_LIGHT_MODE_LIGHTNESS_MIN, + DEFAULT_LIGHT_MODE_LIGHTNESS_MAX, + DEFAULT_DARK_MODE_LIGHTNESS_MIN, + DEFAULT_DARK_MODE_LIGHTNESS_MAX, + DEFAULT_VALUE_STOP, + SHADE_STOPS, +} from "./constants"; + +/** + * Type representing valid shade stop values + */ +export type ShadeStop = (typeof SHADE_STOPS)[number]; + +/** + * 14-shade color palette + * Keys: 50, 100, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950, 1000 + * Values: OKLCH CSS strings (e.g., "oklch(0.5840 0.1200 250.00)") + */ +export interface ColorPalette { + 50: string; + 100: string; + 200: string; + 300: string; + 400: string; + 500: string; + 600: string; + 700: string; + 750: string; + 800: string; + 850: string; + 900: string; + 950: string; + 1000: string; +} + +/** + * Palette generation options + */ +export interface PaletteOptions { + /** Minimum lightness (0-1) for darkest shade */ + lightnessMin?: number; + /** Maximum lightness (0-1) for lightest shade */ + lightnessMax?: number; + /** Stop where the input color is anchored (default: auto-calculated) */ + valueStop?: number | "auto"; +} + +/** + * Calculate the appropriate stop value based on a color's OKLCH lightness + * Inspired by tints.dev's calculateStopFromColor but simplified for OKLCH + * + * @param oklch - OKLCH color object + * @returns The nearest available stop value (50, 100, 200, etc.) + */ +export function calculateDynamicValueStop(oklch: OKLCH): number { + const { l: lightness } = oklch; + + // Find the stop whose baseline lightness is closest to the input color's lightness + let closestStop = DEFAULT_VALUE_STOP; + let smallestDiff = Infinity; + + for (const stop of SHADE_STOPS) { + const baselineLightness = BASELINE_LIGHTNESS_MAP[stop]; + const diff = Math.abs(baselineLightness - lightness); + + if (diff < smallestDiff) { + smallestDiff = diff; + closestStop = stop; + } + } + + return closestStop; +} + +/** + * Type guard to check if a number is a valid shade stop + * @param value - Number to check + * @returns True if value is a valid shade stop + */ +function isValidShadeStop(value: number): value is ShadeStop { + return (SHADE_STOPS as readonly number[]).includes(value); +} + +/** + * Generate a 14-shade color palette from a base hex color + * Works directly in OKLCH space, keeping C and H constant, only varying L + * + * @param baseColor - Hex color (with or without #) + * @param mode - "light" or "dark" + * @param options - Palette generation options + * @returns ColorPalette with 14 OKLCH CSS strings + */ +export function generateColorPalette( + baseColor: string, + mode: "light" | "dark", + options: PaletteOptions = {} +): ColorPalette { + // Validate and normalize input + if (!validateHexColor(baseColor)) { + throw new Error(`Invalid hex color: ${baseColor}`); + } + + const normalizedHex = normalizeHexColor(baseColor); + + // Convert to OKLCH + const inputOKLCH = hexToOKLCH(normalizedHex); + const { l: inputL, c: inputC, h: inputH } = inputOKLCH; + + const DEFAULT_LIGHTNESS_MIN = mode === "light" ? DEFAULT_LIGHT_MODE_LIGHTNESS_MIN : DEFAULT_DARK_MODE_LIGHTNESS_MIN; + const DEFAULT_LIGHTNESS_MAX = mode === "light" ? DEFAULT_LIGHT_MODE_LIGHTNESS_MAX : DEFAULT_DARK_MODE_LIGHTNESS_MAX; + + // Extract options with defaults + const { + lightnessMin = DEFAULT_LIGHTNESS_MIN / 100, // Convert to 0-1 scale + lightnessMax = DEFAULT_LIGHTNESS_MAX / 100, // Convert to 0-1 scale + valueStop = options.valueStop ?? DEFAULT_VALUE_STOP, + } = options; + + // Calculate or use provided valueStop + const anchorStop = valueStop === "auto" ? calculateDynamicValueStop(inputOKLCH) : valueStop; + + // Validate valueStop if provided manually + if (typeof anchorStop === "number" && !isValidShadeStop(anchorStop)) { + throw new Error(`Invalid valueStop: ${anchorStop}. Must be one of ${SHADE_STOPS.join(", ")}`); + } + + // Create lightness distribution with three anchor points + const distributionAnchors = [ + { stop: SHADE_STOPS[0], lightness: lightnessMax }, // Lightest + { stop: anchorStop, lightness: inputL }, // Input color + { stop: SHADE_STOPS[SHADE_STOPS.length - 1], lightness: lightnessMin }, // Darkest + ]; + + // Generate palette by interpolating lightness for each stop + const palette: Partial = {}; + + SHADE_STOPS.forEach((stop) => { + let targetLightness: number; + + // Check if this is an anchor point + const anchor = distributionAnchors.find((a) => a.stop === stop); + if (anchor) { + targetLightness = anchor.lightness; + } else { + // Interpolate between anchor points + let leftAnchor, rightAnchor; + + if (stop < anchorStop) { + leftAnchor = distributionAnchors[0]; // stop 50 + rightAnchor = distributionAnchors[1]; // anchorStop + } else { + leftAnchor = distributionAnchors[1]; // anchorStop + rightAnchor = distributionAnchors[2]; // stop 1000 + } + + // Linear interpolation + const range = rightAnchor.stop - leftAnchor.stop; + const position = stop - leftAnchor.stop; + const ratio = position / range; + targetLightness = leftAnchor.lightness + (rightAnchor.lightness - leftAnchor.lightness) * ratio; + } + + // Create OKLCH color with constant C and H, only varying L + const shadeOKLCH: OKLCH = { + l: Math.max(0, Math.min(1, targetLightness)), // Clamp to 0-1 + c: inputC, // Keep chroma constant + h: inputH, // Keep hue constant + }; + + // Convert to CSS string + const key = stop as keyof ColorPalette; + palette[key] = oklchToCSS(shadeOKLCH); + }); + + return palette as ColorPalette; +} + +/** + * Generate both brand and neutral palettes for a custom theme + * Optimized for Plane's 2-color theme system + * Uses auto-calculated value stops for better color matching + * + * @param brandColor - Brand accent color (hex) + * @param neutralColor - Neutral/background color (hex) + * @returns Object with brandPalette and neutralPalette + */ +export function generateThemePalettes( + brandColor: string, + neutralColor: string, + mode: "light" | "dark" +): { + brandPalette: ColorPalette; + neutralPalette: ColorPalette; +} { + // Brand palette - auto-calculate value stop based on color lightness + const brandPalette = generateColorPalette(brandColor, mode); + + // Neutral palette - auto-calculate value stop based on color lightness + const neutralPalette = generateColorPalette(neutralColor, mode); + + return { brandPalette, neutralPalette }; +} diff --git a/packages/utils/src/theme/theme-application.ts b/packages/utils/src/theme/theme-application.ts new file mode 100644 index 0000000000..b54d33c592 --- /dev/null +++ b/packages/utils/src/theme/theme-application.ts @@ -0,0 +1,112 @@ +/** + * Theme Application Utilities + * Applies generated palettes to CSS variables for Plane's theme system + */ + +import { hexToOKLCH, oklchToCSS } from "./color-conversion"; +import { ALPHA_MAPPING } from "./constants"; +import { generateThemePalettes } from "./palette-generator"; +import { getBrandMapping, getNeutralMapping, invertPalette } from "./theme-inversion"; + +/** + * Apply custom theme using 2-color palette system + * Generates full palettes from brand and neutral colors + * and maps them to CSS variables + * + * @param brandColor - Brand accent color (hex with or without #) + * @param neutralColor - Neutral/background color (hex with or without #) + * @param mode - 'light' or 'dark' theme mode + */ +export function applyCustomTheme(brandColor: string, neutralColor: string, mode: "light" | "dark"): void { + if (!brandColor || !neutralColor) { + console.warn("applyCustomTheme: brandColor and neutralColor are required"); + return; + } + + const themeElement = document?.querySelector("html"); + if (!themeElement) { + console.warn("applyCustomTheme: html element not found"); + return; + } + + // Generate palettes directly in OKLCH color space + const { brandPalette, neutralPalette } = generateThemePalettes(brandColor, neutralColor, mode); + const neutralOKLCH = hexToOKLCH(neutralColor); + const brandOKLCH = hexToOKLCH(brandColor); + + // For dark mode, invert the palettes + const activeBrandPalette = mode === "dark" ? invertPalette(brandPalette) : brandPalette; + const activeNeutralPalette = mode === "dark" ? invertPalette(neutralPalette) : neutralPalette; + + // Get CSS variable mappings + const neutralMapping = getNeutralMapping(activeNeutralPalette); + const brandMapping = getBrandMapping(activeBrandPalette); + + // Apply base palette colors + // This updates the source palette variables that semantic colors reference + Object.entries(neutralMapping).forEach(([key, value]) => { + themeElement.style.setProperty(`--color-neutral-${key}`, value); + }); + + Object.entries(brandMapping).forEach(([key, value]) => { + themeElement.style.setProperty(`--color-brand-${key}`, value); + }); + + Object.entries(ALPHA_MAPPING).forEach(([key, value]) => { + themeElement.style.setProperty(`--color-alpha-white-${key}`, oklchToCSS(neutralOKLCH, value * 100)); + themeElement.style.setProperty(`--color-alpha-black-${key}`, oklchToCSS(neutralOKLCH, value * 100)); + }); + + const isBrandColorDark = brandOKLCH.l < 0.2; + const whiteInOKLCH = { l: 1, c: 0, h: 0 }; + const blackInOKLCH = { l: 0, c: 0, h: 0 }; + themeElement.style.setProperty(`--text-color-on-color`, oklchToCSS(isBrandColorDark ? whiteInOKLCH : blackInOKLCH)); + themeElement.style.setProperty( + `--text-color-icon-on-color`, + oklchToCSS(isBrandColorDark ? blackInOKLCH : whiteInOKLCH) + ); +} + +/** + * Clear custom theme CSS variables + * Removes base palette color overrides + */ +export function clearCustomTheme(): void { + const themeElement = document?.querySelector("html"); + if (!themeElement) return; + + // Clear neutral base palette colors + const neutralKeys = [ + "white", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "1100", + "1200", + "black", + ]; + neutralKeys.forEach((key) => { + themeElement.style.removeProperty(`--color-neutral-${key}`); + }); + + // Clear brand base palette colors + const brandKeys = ["100", "200", "300", "400", "500", "600", "700", "800", "900", "1000", "1100", "1200", "default"]; + brandKeys.forEach((key) => { + themeElement.style.removeProperty(`--color-brand-${key}`); + }); + + Object.keys(ALPHA_MAPPING).forEach((key) => { + themeElement.style.removeProperty(`--color-alpha-white-${key}`); + themeElement.style.removeProperty(`--color-alpha-black-${key}`); + }); + + themeElement.style.removeProperty(`--text-color-on-color`); + themeElement.style.removeProperty(`--text-color-icon-on-color`); +} diff --git a/packages/utils/src/theme/theme-inversion.ts b/packages/utils/src/theme/theme-inversion.ts new file mode 100644 index 0000000000..440713f75e --- /dev/null +++ b/packages/utils/src/theme/theme-inversion.ts @@ -0,0 +1,93 @@ +/** + * Theme Inversion Utilities + * Handles dark mode palette inversion and mapping + */ + +import { DEFAULT_VALUE_STOP } from "./constants"; +import type { ColorPalette } from "./palette-generator"; + +/** + * Invert a color palette for dark mode + * Maps each shade to its opposite (50↔1250, 100↔1200, 200↔1100, etc.) + * Shades around the middle are preserved for smooth transitions + * + * @param palette - 14-shade color palette to invert + * @returns Inverted palette with swapped shades + */ +export function invertPalette(palette: ColorPalette): ColorPalette { + return { + 50: palette[1000], + 100: palette[950], + 200: palette[900], + 300: palette[850], + 400: palette[800], + 500: palette[750], + 600: palette[700], + 700: palette[600], + 750: palette[500], + 800: palette[400], + 850: palette[300], + 900: palette[200], + 950: palette[100], + 1000: palette[50], + }; +} + +/** + * Get CSS variable mapping for a theme mode + * Maps 14-shade palette to Plane's CSS variable system + * + * For light mode: + * - Uses lighter shades for backgrounds (50-100-200) + * - Uses darker shades for text (900, 950, 1000) + * + * For dark mode: + * - Uses inverted palette + * - Shifts mapping to lighter shades to avoid cave-like darkness + * + * @param palette - 14-shade palette (already inverted for dark mode) + * @returns Mapping object for neutral color CSS variables + */ +export function getNeutralMapping(palette: ColorPalette): Record { + return { + white: palette["50"], + "100": palette["100"], + "200": palette["200"], + "300": palette["300"], + "400": palette["400"], + "500": palette["500"], + "600": palette["600"], + "700": palette["700"], + "800": palette["750"], + "900": palette["800"], + "1000": palette["850"], + "1100": palette["900"], + "1200": palette["950"], + black: palette["1000"], + }; +} + +/** + * Get CSS variable mapping for brand colors + * Brand colors use active palette (already inverted for dark mode) + * + * @param palette - 14-shade brand palette + * @returns Mapping object for brand color CSS variables + */ +export function getBrandMapping(palette: ColorPalette): Record { + return { + "100": palette["100"], + "200": palette["200"], + "300": palette["300"], + "400": palette["400"], + "500": palette["500"], + "600": palette["600"], + "700": palette["700"], + "800": palette["750"], + "900": palette["800"], + "1000": palette["850"], + "1100": palette["900"], + "1200": palette["950"], + default: palette[DEFAULT_VALUE_STOP], // Default brand color (middle-ish) + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08aa285ef9..1e1eeb46fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1457,6 +1457,9 @@ importers: '@plane/types': specifier: workspace:* version: link:../types + chroma-js: + specifier: ^3.2.0 + version: 3.2.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1509,6 +1512,9 @@ importers: '@plane/typescript-config': specifier: workspace:* version: link:../typescript-config + '@types/chroma-js': + specifier: ^3.1.2 + version: 3.1.2 '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -1817,8 +1823,8 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + '@csstools/color-helpers@5.0.2': + resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} engines: {node: '>=18'} '@csstools/css-calc@2.1.4': @@ -1828,8 +1834,8 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + '@csstools/css-color-parser@3.0.10': + resolution: {integrity: sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==} engines: {node: '>=18'} peerDependencies: '@csstools/css-parser-algorithms': ^3.0.5 @@ -3975,6 +3981,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chroma-js@3.1.2': + resolution: {integrity: sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==} + '@types/compression@1.8.1': resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} @@ -4919,6 +4928,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chroma-js@3.2.0: + resolution: {integrity: sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==} + chromatic@11.29.0: resolution: {integrity: sha512-yisBlntp9hHVj19lIQdpTlcYIXuU9H/DbFuu6tyWHmj6hWT2EtukCCcxYXL78XdQt1vm2GfIrtgtKpj/Rzmo4A==} hasBin: true @@ -9478,7 +9490,7 @@ snapshots: '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 @@ -9926,7 +9938,7 @@ snapshots: '@colors/colors@1.6.0': {} - '@csstools/color-helpers@5.1.0': + '@csstools/color-helpers@5.0.2': optional: true '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -9935,9 +9947,9 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 optional: true - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + '@csstools/css-color-parser@3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/color-helpers': 5.1.0 + '@csstools/color-helpers': 5.0.2 '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 @@ -12312,6 +12324,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/chroma-js@3.1.2': {} + '@types/compression@1.8.1': dependencies: '@types/express': 4.17.23 @@ -13395,6 +13409,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chroma-js@3.2.0: {} + chromatic@11.29.0: {} chrome-trace-event@1.0.4: {}