feat: custom theming enhancements (#8342)
This commit is contained in:
parent
be1113b170
commit
fa63964566
24 changed files with 1203 additions and 465 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
21
packages/utils/src/theme-legacy.ts
Normal file
21
packages/utils/src/theme-legacy.ts
Normal 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";
|
||||
|
|
@ -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";
|
||||
138
packages/utils/src/theme/color-conversion.ts
Normal file
138
packages/utils/src/theme/color-conversion.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
91
packages/utils/src/theme/color-validation.ts
Normal file
91
packages/utils/src/theme/color-validation.ts
Normal 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;
|
||||
}
|
||||
114
packages/utils/src/theme/constants.ts
Normal file
114
packages/utils/src/theme/constants.ts
Normal 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";
|
||||
53
packages/utils/src/theme/index.ts
Normal file
53
packages/utils/src/theme/index.ts
Normal 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";
|
||||
214
packages/utils/src/theme/palette-generator.ts
Normal file
214
packages/utils/src/theme/palette-generator.ts
Normal 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 };
|
||||
}
|
||||
112
packages/utils/src/theme/theme-application.ts
Normal file
112
packages/utils/src/theme/theme-application.ts
Normal 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`);
|
||||
}
|
||||
93
packages/utils/src/theme/theme-inversion.ts
Normal file
93
packages/utils/src/theme/theme-inversion.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue