UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: локальный акцент и аналитика

This commit is contained in:
DCCONSTRUCTIONS
2026-04-29 19:18:55 +03:00
parent aa348d5d64
commit a7e0a63426
17 changed files with 493 additions and 14 deletions
@@ -27,6 +27,15 @@ export {
type DarknessDetectionMethod,
} from "./theme-application";
// NODE.DC runtime accent
export {
applyNodedcAccent,
getReadableNodedcTextRgb,
nodedcAccentHexToRgb,
normalizeNodedcAccentHex,
rgbToNodedcAccentHex,
} from "./nodedc-accent";
// Color conversion utilities
export {
hexToHSL,
@@ -0,0 +1,102 @@
/**
* 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<string, TRgbTuple> = {
"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;
};