/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ import { normalizeHexColor, validateHexColor } from "./color-validation"; type TRgbTuple = [number, number, number]; const DARK_TEXT_RGB: TRgbTuple = [11, 17, 23]; const LIGHT_TEXT_RGB: TRgbTuple = [245, 247, 251]; const clampRgbChannel = (value: number) => Math.min(Math.max(Math.round(value), 0), 255); const formatRgbTuple = (rgb: readonly number[]) => rgb.map(clampRgbChannel).join(" "); const formatCssRgb = (rgb: readonly number[]) => `rgb(${formatRgbTuple(rgb)})`; const blendRgb = (rgb: readonly number[], target: number, ratio: number): TRgbTuple => rgb.map((channel) => clampRgbChannel(channel * (1 - ratio) + target * ratio)) as TRgbTuple; const toRelativeLuminance = (rgb: readonly number[]) => { const [r, g, b] = rgb.map((channel) => { const normalized = channel / 255; return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; export const normalizeNodedcAccentHex = (hex: string | null | undefined): string | undefined => { if (!hex || !validateHexColor(hex)) return undefined; return `#${normalizeHexColor(hex)}`; }; export const nodedcAccentHexToRgb = (hex: string | null | undefined): TRgbTuple | undefined => { const normalizedHex = normalizeNodedcAccentHex(hex); if (!normalizedHex) return undefined; const cleanHex = normalizedHex.replace("#", ""); return [0, 2, 4].map((start) => parseInt(cleanHex.slice(start, start + 2), 16)) as TRgbTuple; }; export const rgbToNodedcAccentHex = (rgb: readonly number[]): string => `#${rgb .map((channel) => clampRgbChannel(channel).toString(16).padStart(2, "0")) .join("") .toUpperCase()}`; export const getReadableNodedcTextRgb = (rgb: readonly number[]): TRgbTuple => toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB; export const applyNodedcAccent = (hex: string | null | undefined): boolean => { if (typeof document === "undefined") return false; const accentRgb = nodedcAccentHexToRgb(hex); if (!accentRgb) return false; const root = document.documentElement; const onAccentRgb = getReadableNodedcTextRgb(accentRgb); const brandScale: Record = { "100": blendRgb(accentRgb, 255, 0.9), "200": blendRgb(accentRgb, 255, 0.8), "300": blendRgb(accentRgb, 255, 0.65), "400": blendRgb(accentRgb, 255, 0.45), "500": blendRgb(accentRgb, 255, 0.25), "600": blendRgb(accentRgb, 255, 0.1), "700": blendRgb(accentRgb, 0, 0.25), "800": blendRgb(accentRgb, 0, 0.35), "900": blendRgb(accentRgb, 0, 0.45), "1000": blendRgb(accentRgb, 0, 0.6), "1100": blendRgb(accentRgb, 0, 0.72), "1200": blendRgb(accentRgb, 0, 0.82), }; root.style.setProperty("--nodedc-accent-rgb", formatRgbTuple(accentRgb)); root.style.setProperty("--nodedc-card-active-rgb", formatRgbTuple(accentRgb)); root.style.setProperty("--nodedc-on-accent-rgb", formatRgbTuple(onAccentRgb)); root.style.setProperty("--nodedc-on-card-active-rgb", formatRgbTuple(onAccentRgb)); Object.entries(brandScale).forEach(([key, value]) => { root.style.setProperty(`--brand-${key}`, formatCssRgb(value)); }); root.style.setProperty("--brand-default", formatCssRgb(accentRgb)); root.style.setProperty("--bg-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--bg-accent-primary-hover", formatCssRgb(blendRgb(accentRgb, 255, 0.18))); root.style.setProperty("--bg-accent-primary-active", formatCssRgb(blendRgb(accentRgb, 0, 0.1))); root.style.setProperty("--txt-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--txt-accent-secondary", formatCssRgb(blendRgb(accentRgb, 0, 0.25))); root.style.setProperty("--txt-icon-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--text-color-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--text-color-accent-secondary", formatCssRgb(blendRgb(accentRgb, 0, 0.25))); root.style.setProperty("--text-color-icon-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--stroke-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--fill-accent-primary", formatCssRgb(accentRgb)); root.style.setProperty("--txt-on-color", formatCssRgb(onAccentRgb)); root.style.setProperty("--txt-icon-on-color", formatCssRgb(onAccentRgb)); return true; }; export const applyNodedcPassiveCardColor = (hex: string | null | undefined): boolean => { if (typeof document === "undefined") return false; const passiveCardRgb = nodedcAccentHexToRgb(hex); if (!passiveCardRgb) return false; const root = document.documentElement; const onPassiveCardRgb = getReadableNodedcTextRgb(passiveCardRgb); root.style.setProperty("--nodedc-card-passive-rgb", formatRgbTuple(passiveCardRgb)); root.style.setProperty("--nodedc-on-card-passive-rgb", formatRgbTuple(onPassiveCardRgb)); return true; }; export const applyNodedcPassiveCardSurfaceColor = (hex: string | null | undefined): boolean => { if (typeof document === "undefined") return false; const passiveCardSurfaceRgb = nodedcAccentHexToRgb(hex); if (!passiveCardSurfaceRgb) return false; const root = document.documentElement; const onPassiveCardSurfaceRgb = getReadableNodedcTextRgb(passiveCardSurfaceRgb); root.style.setProperty("--nodedc-card-passive-surface-rgb", formatRgbTuple(passiveCardSurfaceRgb)); root.style.setProperty("--nodedc-on-card-passive-surface-rgb", formatRgbTuple(onPassiveCardSurfaceRgb)); return true; };