import type { CustomThemeColorOverrides, CustomThemePreset, } from "@/core/system-settings/api"; export const CUSTOM_THEME_PREFIX = "custom-theme-"; type Rgb = { r: number; g: number; b: number }; const HEX_COLOR = /^#[0-9a-f]{6}$/i; const WHITE: Rgb = { r: 255, g: 255, b: 255 }; const BLACK: Rgb = { r: 0, g: 0, b: 0 }; export const CUSTOM_THEME_ADVANCED_KEYS = [ "button_hover", "selected_background", "border", "muted", "muted_text", "destructive", "brand_title", "primary_foreground", ] as const; export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number]; export type CustomThemeTokenInput = Pick< CustomThemePreset, "primary" | "mode" > & Partial>; export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` { return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX); } export function normalizeHexColor(value: string, fallback = "#0062D9"): string { const normalized = value.trim().toUpperCase(); return HEX_COLOR.test(normalized) ? normalized : fallback; } function optionalHexColor(value: string | null | undefined): string | undefined { if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined; return value.trim().toUpperCase(); } function toRgb(value: string): Rgb { const hex = normalizeHexColor(value).slice(1); return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16), }; } function toHex({ r, g, b }: Rgb): string { const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0"); return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase(); } function mix(from: Rgb, to: Rgb, amount: number): Rgb { const ratio = Math.min(1, Math.max(0, amount)); return { r: from.r + (to.r - from.r) * ratio, g: from.g + (to.g - from.g) * ratio, b: from.b + (to.b - from.b) * ratio, }; } function relativeLuminance({ r, g, b }: Rgb): number { const linear = [r, g, b].map((channel) => { const value = channel / 255; return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!; } function contrastRatio(a: Rgb, b: Rgb): number { const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); return (lighter! + 0.05) / (darker! + 0.05); } function readableForeground(background: Rgb): string { return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF"; } function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb { let candidate = color; for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) { candidate = mix(candidate, toward, 0.18); } return candidate; } function rgbChannels(color: Rgb): string { return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`; } /** * Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign. * Inputs are restricted to a safe six-digit primary colour, optional primary * button text colour, a small set of interaction overrides and a light/dark surface * family, so callers never interpolate arbitrary CSS values. */ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record { const primary = toRgb(theme.primary); const isDark = theme.mode === "dark"; const overrides: CustomThemeColorOverrides = theme.overrides ?? {}; const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035); const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE; // Normal page copy stays automatic so selecting white for a teal/blue // action does not accidentally make a light-page's body text white. const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A"); const foregroundRgb = ensureContrast( preferredForeground, background, toRgb(readableForeground(background)), ); const foreground = toHex(foregroundRgb); const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085"); const mutedForegroundRgb = optionalHexColor(overrides.muted_text) ? ensureContrast( toRgb(overrides.muted_text!), background, toRgb(readableForeground(background)), 3, ) : defaultMutedForeground; const mutedForeground = toHex(mutedForegroundRgb); const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14); const accent = optionalHexColor(overrides.selected_background) ? toRgb(overrides.selected_background!) : defaultAccent; const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14); const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28); const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder; const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38); const action = ensureContrast( mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3), isDark ? background : WHITE, isDark ? WHITE : BLACK, ); const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12); const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22); const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1); const primaryHover = optionalHexColor(overrides.button_hover) ? toRgb(overrides.button_hover!) : defaultPrimaryHover; const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2); const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary); const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground; const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground; const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075); const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary; const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018); const ring = rgbChannels(primary); const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A"); const primaryHex = toHex(primary); const actionHex = toHex(action); const actionHoverHex = toHex(actionHover); const actionActiveHex = toHex(actionActive); const accentHex = toHex(accent); const accentHoverHex = toHex(accentHover); const backgroundHex = toHex(background); const cardHex = toHex(card); const borderHex = toHex(border); const inputHex = toHex(input); const secondaryHex = toHex(secondary); const primaryHoverHex = toHex(primaryHover); const primaryActiveHex = toHex(primaryActive); const sidebarHex = toHex(sidebar); return { "--workspace-brand": primaryHex, "--workspace-brand-foreground": primaryForeground, "--brand-200": accentHex, "--brand-900": actionHex, "--brand-950": actionActiveHex, "--theme-button-hover": primaryHoverHex, "--theme-selected-background": accentHex, "--background": backgroundHex, "--foreground": foreground, "--card": cardHex, "--card-foreground": foreground, "--popover": cardHex, "--popover-foreground": foreground, "--primary": primaryHex, "--primary-foreground": primaryForeground, "--button-foreground": buttonForeground, "--brand-title-foreground": brandTitle, "--secondary": secondaryHex, "--secondary-foreground": actionHex, "--muted": secondaryHex, "--muted-foreground": mutedForeground, "--accent": accentHex, "--accent-foreground": actionHex, "--destructive": destructive, "--border": borderHex, "--input": inputHex, "--ring": `rgb(${ring} / 0.48)`, "--chart-1": actionHex, "--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)), "--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)), "--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)), "--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)), "--sidebar": sidebarHex, "--sidebar-foreground": foreground, "--sidebar-primary": primaryHex, "--sidebar-primary-foreground": primaryForeground, "--sidebar-accent": accentHex, "--sidebar-accent-foreground": actionHex, "--sidebar-border": borderHex, "--sidebar-ring": `rgb(${ring} / 0.48)`, "--legacy-accent-rgb": rgbChannels(accent), "--legacy-background-rgb": rgbChannels(background), "--legacy-border-rgb": rgbChannels(border), "--legacy-brand-200-rgb": rgbChannels(accent), "--legacy-card-rgb": rgbChannels(card), "--legacy-destructive-rgb": rgbChannels(toRgb(destructive)), "--legacy-foreground-rgb": rgbChannels(foregroundRgb), "--legacy-input-rgb": rgbChannels(input), "--legacy-muted-rgb": rgbChannels(secondary), "--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb), "--legacy-primary-rgb": rgbChannels(primary), "--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)), "--legacy-ring-rgb": ring, "--legacy-secondary-rgb": rgbChannels(secondary), "--legacy-sidebar-accent-rgb": rgbChannels(accent), "--legacy-sidebar-border-rgb": rgbChannels(border), "--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb), "--legacy-workspace-brand-rgb": rgbChannels(primary), "--td-brand-color-1": backgroundHex, "--td-brand-color-2": secondaryHex, "--td-brand-color-3": accentHex, "--td-brand-color-4": accentHoverHex, "--td-brand-color-5": primaryHex, "--td-brand-color-6": primaryHex, "--td-brand-color-7": actionHex, "--td-brand-color-8": actionHoverHex, "--td-brand-color-9": actionActiveHex, "--td-brand-color-10": actionActiveHex, "--td-brand-color": primaryHex, "--td-brand-color-hover": primaryHoverHex, "--td-brand-color-focus": accentHex, "--td-brand-color-active": primaryActiveHex, "--td-brand-color-disabled": accentHoverHex, "--td-brand-color-light": accentHex, "--td-brand-color-light-hover": accentHoverHex, "--td-text-color-primary": foreground, "--td-text-color-secondary": mutedForeground, "--td-text-color-brand": actionHex, "--td-text-color-link": actionHex, "--td-text-color-anti": buttonForeground, "--td-bg-color-page": backgroundHex, "--td-bg-color-container": cardHex, "--td-bg-color-container-hover": secondaryHex, "--td-bg-color-container-active": accentHex, "--td-bg-color-secondarycontainer": secondaryHex, "--td-bg-color-secondarycontainer-hover": accentHex, "--td-bg-color-secondarycontainer-active": accentHoverHex, "--td-border-level-1-color": borderHex, "--td-component-stroke": borderHex, "--td-border-level-2-color": inputHex, "--td-component-border": inputHex, }; } /** Tokens produced from the automatic palette only, used by the editor to * show which advanced values are currently inheriting from the system. */ export function getCustomThemeAutomaticTokens( theme: Pick, ): Record { return getCustomThemeTokens(theme); } export function customThemeContrast(primary: string): number { const color = toRgb(primary); return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE)); } export function customThemeTextContrast(theme: CustomThemeTokenInput): number { const tokens = getCustomThemeTokens(theme); return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!)); }