feat: custom theming enhancements (#8342)

This commit is contained in:
Anmol Singh Bhatia
2025-12-16 18:17:59 +05:30
committed by GitHub
parent be1113b170
commit fa63964566
24 changed files with 1203 additions and 465 deletions

View File

@@ -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,

View File

@@ -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() {
</div>
</>
);
}
});
export default observer(ProfileAppearancePage);
export default ProfileAppearancePage;

View File

@@ -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<I_THEME_OPTION | null>(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<IUserTheme>) => {
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() {
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">

View File

@@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<div id="context-menu-portal" />
<div id="editor-portal" />
<AppProvider>
<div className={cn("h-screen w-full overflow-hidden bg-canvas relative flex flex-col", "app-container")}>
<div className={cn("h-screen w-full overflow-hidden relative flex flex-col", "app-container")}>
<main className="w-full h-full overflow-hidden relative">{children}</main>
</div>
</AppProvider>

View File

@@ -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<I_THEME_OPTION | null>(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<IUserTheme>) => {
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={
<div className="">
<div>
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
</>
);
});

View File

@@ -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<IUserTheme>;
handleUpdateTheme: (formData: IUserTheme) => Promise<void>;
setValue: UseFormSetValue<IUserTheme>;
};
export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) {
const { getValues, handleUpdateTheme, setValue } = props;
// refs
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="flex gap-2">
<input ref={fileInputRef} type="file" accept=".json" onChange={handleUploadConfig} className="hidden" />
<Button variant="secondary" type="button" onClick={() => fileInputRef.current?.click()}>
Import config
</Button>
<Button variant="secondary" type="button" onClick={handleDownloadConfig}>
Download config
</Button>
</div>
);
});

View File

@@ -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<IUserTheme>) => 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<IUserTheme>({
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<IUserTheme>) => {
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<IUserTheme>({
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:
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-16 font-semibold text-primary">{t("customize_your_theme")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
{/* Color Inputs */}
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
{/* Brand Color */}
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("background_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="background"
rules={{ ...inputRules, required: t("background_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
value={value}
onChange={(val) => 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 && <p className="mt-1 text-11 text-red-500">{errors.background.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("text_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="text"
rules={{ ...inputRules, required: t("text_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
value={value}
onChange={(val) => 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 && <p className="mt-1 text-11 text-red-500">{errors.text.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("primary_color")}</h3>
<h3 className="text-left text-13 font-medium text-secondary">Brand color</h3>
<div className="w-full">
<Controller
control={control}
name="primary"
rules={{ ...inputRules, required: t("primary_color_is_required") }}
rules={{
required: "Brand color is required",
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code",
},
}}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
value={value}
onChange={(val) => 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 && <p className="mt-1 text-11 text-red-500">{errors.primary.message}</p>}
</div>
</div>
{/* Neutral Color */}
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("sidebar_background_color")}</h3>
<h3 className="text-left text-13 font-medium text-secondary">Neutral color</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarBackground"
rules={{ ...inputRules, required: t("sidebar_background_color_is_required") }}
name="background"
rules={{
required: "Neutral color is required",
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code",
},
}}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
name="background"
value={value}
onChange={(val) => 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 && (
<p className="mt-1 text-11 text-red-500">{errors.sidebarBackground.message}</p>
)}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("sidebar_text_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarText"
rules={{ ...inputRules, required: t("sidebar_text_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
value={value}
onChange={(val) => 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 && <p className="mt-1 text-11 text-red-500">{errors.sidebarText.message}</p>}
</div>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? t("creating_theme") : t("set_theme")}
</Button>
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
{/* Import/Export Section */}
<CustomThemeConfigHandler getValues={getValues} handleUpdateTheme={handleUpdateTheme} setValue={setValue} />
<div className="flex items-center gap-4">
{/* Theme Mode Toggle */}
<div className="flex items-center gap-2">
<Controller
control={control}
name="darkPalette"
render={({ field: { value, onChange } }) => (
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
)}
/>
<span className="text-12 text-tertiary">{watch("darkPalette") ? "Dark mode" : "Light mode"}</span>
</div>
{/* Save Theme Button */}
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating..." : t("set_theme")}
</Button>
</div>
</div>
</form>
);

View File

@@ -14,7 +14,9 @@ type Props = {
export function ThemeSwitch(props: Props) {
const { value, onChange } = props;
// translation
const { t } = useTranslation();
return (
<CustomSelect
value={value}
@@ -48,6 +50,7 @@ export function ThemeSwitch(props: Props) {
)
}
onChange={onChange}
placement="bottom-end"
input
>
{THEME_OPTIONS.map((themeOption) => (

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -14,7 +14,7 @@
}
body {
@apply font-body text-primary;
@apply font-body bg-canvas text-primary;
}
/* emoji icon picker */

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<TShades> = {};
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<HTMLElement>("[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";

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<number, number> = {
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";

View File

@@ -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";

View File

@@ -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<ColorPalette> = {};
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 };
}

View File

@@ -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`);
}

View File

@@ -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<string, string> {
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<string, string> {
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)
};
}

32
pnpm-lock.yaml generated
View File

@@ -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: {}