UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: контроль цветов элементов, допил палитры и реворк основного UI

This commit is contained in:
DCCONSTRUCTIONS 2026-05-01 02:29:39 +03:00
parent 7ff7d83b07
commit a7ab8ee123
15 changed files with 728 additions and 271 deletions

View File

@ -54,7 +54,8 @@ const toRelativeLuminance = (rgb: readonly number[]) => {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const getReadableTextRgb = (rgb: readonly number[]) => (toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB);
const getReadableTextRgb = (rgb: readonly number[]) =>
toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB;
const accentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
const activeCardRgb = designConfig.nodedc.active_card_rgb as [number, number, number];
@ -68,10 +69,13 @@ const onPassiveCardRgb = getReadableTextRgb(passiveCardRgb);
const designConfigStyle = {
"--nodedc-accent-rgb": formatRgbTuple(accentRgb),
"--nodedc-card-passive-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-card-passive-surface-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-presence-dot-border-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-card-active-rgb": formatRgbTuple(activeCardRgb),
"--nodedc-on-accent-rgb": formatRgbTuple(onAccentRgb),
"--nodedc-on-card-active-rgb": formatRgbTuple(onActiveCardRgb),
"--nodedc-on-card-passive-rgb": formatRgbTuple(onPassiveCardRgb),
"--nodedc-on-card-passive-surface-rgb": formatRgbTuple(onPassiveCardRgb),
"--brand-default": formatCssRgb(accentRgb),
"--brand-300": formatCssRgb(blendRgb(accentRgb, 255, 0.35)),
"--brand-700": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),

View File

@ -128,11 +128,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const stateOptions = canEditTargetIssue
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
: sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state);
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const foregroundClasses = isActive
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]";
const subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-[rgb(var(--nodedc-on-card-passive-rgb))]";
const cornerActionButtonClasses = cn(
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none",
isActive
@ -141,7 +143,9 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
);
const assigneeButtonClasses = cn(
"flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
isActive
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]"
);
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;

View File

@ -26,10 +26,10 @@ type TNodedcWorkItemCardProps = {
export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
surfaceClassName: isActive
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white",
: "bg-[rgb(var(--nodedc-card-passive-surface-rgb))] text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]",
foregroundClasses: isActive
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "text-[rgb(var(--nodedc-on-card-passive-rgb))]",
: "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]",
subtleTextClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]",
pillBackgroundClasses: isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"

View File

@ -17,7 +17,7 @@ export const PresenceDot = (props: Props) => {
<span
aria-label="Пользователь онлайн"
className={cn(
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-card-passive-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-presence-dot-border-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
className
)}
/>

View File

@ -4,25 +4,12 @@
* See the LICENSE file for details.
*/
import { Fragment, useEffect, useMemo, useState } from "react";
import { Popover, Transition } from "@headlessui/react";
import { observer } from "mobx-react";
import * as ColorPicker from "react-color";
import type { ColorResult } from "react-color";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import {
applyNodedcAccent,
getReadableNodedcTextRgb,
nodedcAccentHexToRgb,
normalizeNodedcAccentHex,
} from "@plane/utils";
import { applyNodedcAccent } from "@plane/utils";
// helpers
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
import { NODEDC_DEFAULT_ACCENT_HEX } from "../../../../../../helpers/nodedc-design";
// local imports
import { ProfileSettingsNodedcColorControl } from "./passive-card-color";
const ACCENT_PRESET_COLORS = [
"#EF4444",
@ -42,242 +29,19 @@ const ACCENT_PRESET_COLORS = [
"#F5F7FB",
];
const CHROME_PICKER_STYLES = {
default: {
picker: {
width: "100%",
background: "transparent",
borderRadius: 0,
boxShadow: "none",
fontFamily: "inherit",
},
saturation: {
borderRadius: "1.35rem",
overflow: "hidden",
},
Saturation: {
borderRadius: "1.35rem",
},
body: {
padding: "0.9rem 0 0",
},
controls: {
alignItems: "center",
gap: "0.75rem",
},
color: {
width: "2rem",
},
swatch: {
borderRadius: "999px",
boxShadow: "0 0 0 1px rgba(255,255,255,0.14)",
},
hue: {
borderRadius: "999px",
overflow: "hidden",
},
Hue: {
borderRadius: "999px",
},
alpha: {
display: "none",
},
Alpha: {
display: "none",
},
},
};
const getReadableColor = (hex: string) => {
const rgb = nodedcAccentHexToRgb(hex);
if (!rgb) return undefined;
return `rgb(${getReadableNodedcTextRgb(rgb).join(" ")})`;
};
export const ProfileSettingsAccentColor = observer(function ProfileSettingsAccentColor() {
const { data: userProfile, updateUserTheme } = useUserProfile();
const [draftAccent, setDraftAccent] = useState(NODEDC_DEFAULT_ACCENT_HEX);
const [isSaving, setIsSaving] = useState(false);
const savedAccent = useMemo(
() => normalizeNodedcAccentHex(userProfile?.theme?.nodedcAccent) || NODEDC_DEFAULT_ACCENT_HEX,
[userProfile?.theme?.nodedcAccent]
);
const normalizedDraftAccent = normalizeNodedcAccentHex(draftAccent);
const isDraftValid = !!normalizedDraftAccent;
const isDirty = normalizedDraftAccent !== savedAccent;
useEffect(() => {
setDraftAccent(savedAccent);
}, [savedAccent]);
const handleAccentChange = (value: string) => {
const nextValue = value.startsWith("#") ? value : `#${value}`;
setDraftAccent(nextValue);
const normalizedValue = normalizeNodedcAccentHex(nextValue);
if (normalizedValue) applyNodedcAccent(normalizedValue);
};
const handleColorPickerChange = (color: ColorResult) => {
handleAccentChange(color.hex);
};
const handleSave = async () => {
if (!normalizedDraftAccent) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Введите корректный HEX-цвет.",
});
return;
}
try {
setIsSaving(true);
applyNodedcAccent(normalizedDraftAccent);
await updateUserTheme({ nodedcAccent: normalizedDraftAccent });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: "Акцентный цвет обновлен.",
});
} catch (_error) {
applyNodedcAccent(savedAccent);
setDraftAccent(savedAccent);
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Не удалось сохранить акцентный цвет.",
});
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
try {
setIsSaving(true);
setDraftAccent(NODEDC_DEFAULT_ACCENT_HEX);
applyNodedcAccent(NODEDC_DEFAULT_ACCENT_HEX);
await updateUserTheme({ nodedcAccent: undefined });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сброшено",
message: "Акцентный цвет возвращен к дизайн-конфигу.",
});
} catch (_error) {
applyNodedcAccent(savedAccent);
setDraftAccent(savedAccent);
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Не удалось сбросить акцентный цвет.",
});
} finally {
setIsSaving(false);
}
};
return (
<SettingsControlItem
title="Акцентный цвет"
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
control={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-40">
<Popover as="div" className="absolute top-1/2 left-3 z-20 -translate-y-1/2">
{() => (
<>
<Popover.Button
type="button"
className="grid size-5 place-items-center rounded-full outline-none transition-transform hover:scale-105 focus-visible:scale-105"
aria-label="Открыть палитру акцентного цвета"
>
<span
className="size-4 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.24)]"
style={{ backgroundColor: normalizedDraftAccent || NODEDC_DEFAULT_ACCENT_HEX }}
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100 translate-y-0 scale-100"
leaveTo="opacity-0 translate-y-1 scale-95"
>
<Popover.Panel className="nodedc-accent-picker-panel absolute top-full left-0 z-[90] mt-4 w-[21rem] origin-top-left rounded-[1.75rem] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.46)]">
<ColorPicker.ChromePicker
className="nodedc-accent-chrome-picker"
color={normalizedDraftAccent || NODEDC_DEFAULT_ACCENT_HEX}
disableAlpha
onChange={handleColorPickerChange}
styles={CHROME_PICKER_STYLES}
/>
<div className="mt-4 grid grid-cols-8 gap-2">
{ACCENT_PRESET_COLORS.map((color) => {
const isSelected = color === normalizedDraftAccent;
return (
<button
key={color}
type="button"
className="grid size-7 place-items-center rounded-full transition-transform hover:scale-105 focus-visible:scale-105"
onClick={() => handleAccentChange(color)}
aria-label={`Выбрать цвет ${color}`}
>
<span
className="size-5 rounded-full"
style={{
backgroundColor: color,
boxShadow: isSelected
? `0 0 0 2px rgba(245,247,251,0.92), 0 0 0 5px ${color}`
: "0 0 0 1px rgba(255,255,255,0.16)",
}}
/>
</button>
);
})}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<input
name="nodedcAccent"
value={draftAccent}
onChange={(event) => handleAccentChange(event.target.value)}
placeholder={NODEDC_DEFAULT_ACCENT_HEX}
className="nodedc-settings-input h-11 min-h-11 w-full pl-10 pr-4 text-13 font-medium uppercase"
style={{
color: normalizedDraftAccent ? getReadableColor(normalizedDraftAccent) : undefined,
}}
aria-invalid={!isDraftValid}
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="nodedc-settings-primary-button min-w-[7rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleSave}
disabled={!isDraftValid || !isDirty || isSaving}
>
Применить
</button>
<button
type="button"
className="nodedc-settings-secondary-button min-w-[6rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleReset}
disabled={savedAccent === NODEDC_DEFAULT_ACCENT_HEX || isSaving}
>
Сбросить
</button>
</div>
</div>
}
/>
);
});
export const ProfileSettingsAccentColor = () => (
<ProfileSettingsNodedcColorControl
themeKey="nodedcAccent"
defaultColor={NODEDC_DEFAULT_ACCENT_HEX}
title="Акцентный цвет"
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
inputName="nodedcAccent"
paletteLabel="Открыть палитру акцентного цвета"
saveSuccessMessage="Акцентный цвет обновлен."
saveErrorMessage="Не удалось сохранить акцентный цвет."
resetSuccessMessage="Акцентный цвет возвращен к дизайн-конфигу."
resetErrorMessage="Не удалось сбросить акцентный цвет."
presetColors={ACCENT_PRESET_COLORS}
applyColor={applyNodedcAccent}
/>
);

View File

@ -9,6 +9,7 @@ import { observer } from "mobx-react";
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
// local imports
import { ProfileSettingsAccentColor } from "./accent-color";
import { ProfileSettingsPassiveCardColor, ProfileSettingsPassiveCardSurfaceColor } from "./passive-card-color";
import { ProfileSettingsToolbarLayout } from "./toolbar-layout";
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
@ -22,6 +23,8 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
}}
/>
<ProfileSettingsAccentColor />
<ProfileSettingsPassiveCardSurfaceColor />
<ProfileSettingsPassiveCardColor />
<ProfileSettingsToolbarLayout />
</div>
);

View File

@ -0,0 +1,573 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BookmarkPlus, Pipette, Trash2 } from "lucide-react";
import { observer } from "mobx-react";
import * as ColorPicker from "react-color";
import type { ColorResult } from "react-color";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import {
applyNodedcPassiveCardColor,
applyNodedcPassiveCardSurfaceColor,
getReadableNodedcTextRgb,
nodedcAccentHexToRgb,
cn,
normalizeNodedcAccentHex,
} from "@plane/utils";
// helpers
import {
NODEDC_DEFAULT_PASSIVE_CARD_HEX,
NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX,
} from "../../../../../../helpers/nodedc-design";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
const PASSIVE_CARD_PRESET_COLORS = [
"#050505",
"#101113",
"#1A1B1E",
"#242529",
"#2A2B2E",
"#34363B",
"#42454B",
"#5A5F67",
"#1F2937",
"#243447",
"#312E81",
"#3B1D4A",
"#3F1D2F",
"#3C2F1D",
"#4B5563",
"#7C7F85",
];
const COLOR_TEMPLATE_STORAGE_KEY = "nodedc:color-template-swatches:v1";
const COLOR_TEMPLATE_LIMIT = 24;
const COLOR_PICKER_PANEL_WIDTH = 336;
const COLOR_PICKER_PANEL_ESTIMATED_HEIGHT = 430;
const COLOR_PICKER_PANEL_GAP = 12;
const COLOR_PICKER_VIEWPORT_MARGIN = 16;
type TEyeDropperResult = {
sRGBHex: string;
};
type TEyeDropperConstructor = new () => {
open: () => Promise<TEyeDropperResult>;
};
declare global {
interface Window {
EyeDropper?: TEyeDropperConstructor;
}
}
const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const normalizeTemplateColorList = (colors: unknown[]): string[] =>
Array.from(
new Set(
colors
.map((color) => (typeof color === "string" ? normalizeNodedcAccentHex(color) : undefined))
.filter((color): color is string => !!color)
)
).slice(0, COLOR_TEMPLATE_LIMIT);
const readTemplateColors = (): string[] => {
if (typeof window === "undefined") return [];
try {
const parsedValue = JSON.parse(window.localStorage.getItem(COLOR_TEMPLATE_STORAGE_KEY) || "[]");
return Array.isArray(parsedValue) ? normalizeTemplateColorList(parsedValue) : [];
} catch (_error) {
return [];
}
};
const writeTemplateColors = (colors: string[]) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(COLOR_TEMPLATE_STORAGE_KEY, JSON.stringify(normalizeTemplateColorList(colors)));
};
const CHROME_PICKER_STYLES = {
default: {
picker: {
width: "100%",
background: "transparent",
borderRadius: 0,
boxShadow: "none",
fontFamily: "inherit",
},
saturation: {
borderRadius: "1.35rem",
overflow: "hidden",
},
Saturation: {
borderRadius: "1.35rem",
},
body: {
padding: "0.9rem 0 0",
},
controls: {
alignItems: "center",
gap: "0.75rem",
},
color: {
width: "2rem",
},
swatch: {
borderRadius: "999px",
boxShadow: "0 0 0 1px rgba(255,255,255,0.14)",
},
hue: {
borderRadius: "999px",
overflow: "hidden",
},
Hue: {
borderRadius: "999px",
},
alpha: {
display: "none",
},
Alpha: {
display: "none",
},
},
};
const getReadableColor = (hex: string) => {
const rgb = nodedcAccentHexToRgb(hex);
if (!rgb) return undefined;
return `rgb(${getReadableNodedcTextRgb(rgb).join(" ")})`;
};
type TNodedcColorThemeKey = "nodedcAccent" | "nodedcPassiveCard" | "nodedcPassiveCardSurface";
type TProfileSettingsNodedcColorControlProps = {
themeKey: TNodedcColorThemeKey;
defaultColor: string;
title: string;
description: string;
inputName: string;
paletteLabel: string;
saveSuccessMessage: string;
saveErrorMessage: string;
resetSuccessMessage: string;
resetErrorMessage: string;
presetColors: string[];
applyColor: (hex: string | null | undefined) => boolean;
};
export const ProfileSettingsNodedcColorControl = observer(function ProfileSettingsNodedcColorControl(
props: TProfileSettingsNodedcColorControlProps
) {
const {
themeKey,
defaultColor,
title,
description,
inputName,
paletteLabel,
saveSuccessMessage,
saveErrorMessage,
resetSuccessMessage,
resetErrorMessage,
presetColors,
applyColor,
} = props;
const { data: userProfile, updateUserTheme } = useUserProfile();
const [draftColor, setDraftColor] = useState(defaultColor);
const [isBrowser, setIsBrowser] = useState(false);
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [panelPosition, setPanelPosition] = useState({ left: 0, top: 0 });
const [templateColors, setTemplateColors] = useState<string[]>([]);
const paletteButtonRef = useRef<HTMLButtonElement | null>(null);
const palettePanelRef = useRef<HTMLDivElement | null>(null);
const savedColor = useMemo(
() => normalizeNodedcAccentHex(userProfile?.theme?.[themeKey]) || defaultColor,
[defaultColor, themeKey, userProfile?.theme]
);
const normalizedDraftColor = normalizeNodedcAccentHex(draftColor);
const isDraftValid = !!normalizedDraftColor;
const isDirty = normalizedDraftColor !== savedColor;
useEffect(() => {
setIsBrowser(true);
setTemplateColors(readTemplateColors());
}, []);
useEffect(() => {
setDraftColor(savedColor);
}, [savedColor]);
const updatePalettePosition = useCallback(() => {
if (typeof window === "undefined" || !paletteButtonRef.current) return;
const buttonRect = paletteButtonRef.current.getBoundingClientRect();
const panelHeight = palettePanelRef.current?.offsetHeight || COLOR_PICKER_PANEL_ESTIMATED_HEIGHT;
const maxLeft = window.innerWidth - COLOR_PICKER_PANEL_WIDTH - COLOR_PICKER_VIEWPORT_MARGIN;
const left = clampNumber(
buttonRect.left - COLOR_PICKER_PANEL_GAP,
COLOR_PICKER_VIEWPORT_MARGIN,
Math.max(COLOR_PICKER_VIEWPORT_MARGIN, maxLeft)
);
const topAbove = buttonRect.top - panelHeight - COLOR_PICKER_PANEL_GAP;
const topBelow = buttonRect.bottom + COLOR_PICKER_PANEL_GAP;
const maxTop = window.innerHeight - panelHeight - COLOR_PICKER_VIEWPORT_MARGIN;
const top =
topAbove >= COLOR_PICKER_VIEWPORT_MARGIN
? topAbove
: clampNumber(topBelow, COLOR_PICKER_VIEWPORT_MARGIN, Math.max(COLOR_PICKER_VIEWPORT_MARGIN, maxTop));
setPanelPosition({ left, top });
}, []);
useEffect(() => {
if (!isPaletteOpen) return;
updatePalettePosition();
const animationFrame = window.requestAnimationFrame(updatePalettePosition);
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (paletteButtonRef.current?.contains(target) || palettePanelRef.current?.contains(target)) return;
setIsPaletteOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsPaletteOpen(false);
};
window.addEventListener("resize", updatePalettePosition);
window.addEventListener("scroll", updatePalettePosition, true);
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.cancelAnimationFrame(animationFrame);
window.removeEventListener("resize", updatePalettePosition);
window.removeEventListener("scroll", updatePalettePosition, true);
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isPaletteOpen, updatePalettePosition]);
const handleColorChange = (value: string) => {
const nextValue = value.startsWith("#") ? value : `#${value}`;
setDraftColor(nextValue);
const normalizedValue = normalizeNodedcAccentHex(nextValue);
if (normalizedValue) applyColor(normalizedValue);
};
const handleColorPickerChange = (color: ColorResult) => {
handleColorChange(color.hex);
};
const handlePickColorFromScreen = async () => {
if (typeof window === "undefined" || !window.EyeDropper) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Недоступно",
message: "Пипетка не поддерживается этим браузером.",
});
return;
}
try {
const result = await new window.EyeDropper().open();
handleColorChange(result.sRGBHex);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Не удалось взять цвет пипеткой.",
});
}
};
const handleSaveTemplateColor = () => {
if (!normalizedDraftColor) return;
const nextTemplateColors = normalizeTemplateColorList([
normalizedDraftColor,
...templateColors.filter((color) => color !== normalizedDraftColor),
]);
setTemplateColors(nextTemplateColors);
writeTemplateColors(nextTemplateColors);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: "Цвет добавлен в шаблоны.",
});
};
const handleRemoveTemplateColor = (color: string) => {
const nextTemplateColors = templateColors.filter((templateColor) => templateColor !== color);
setTemplateColors(nextTemplateColors);
writeTemplateColors(nextTemplateColors);
};
const handleSave = async () => {
if (!normalizedDraftColor) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Введите корректный HEX-цвет.",
});
return;
}
try {
setIsSaving(true);
applyColor(normalizedDraftColor);
await updateUserTheme({ [themeKey]: normalizedDraftColor });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: saveSuccessMessage,
});
} catch (_error) {
applyColor(savedColor);
setDraftColor(savedColor);
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: saveErrorMessage,
});
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
try {
setIsSaving(true);
setDraftColor(defaultColor);
applyColor(defaultColor);
await updateUserTheme({ [themeKey]: undefined });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сброшено",
message: resetSuccessMessage,
});
} catch (_error) {
applyColor(savedColor);
setDraftColor(savedColor);
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: resetErrorMessage,
});
} finally {
setIsSaving(false);
}
};
const renderColorSwatch = (color: string, options?: { isTemplate?: boolean }) => {
const isSelected = color === normalizedDraftColor;
return (
<div key={color} className={cn("relative grid size-7 place-items-center", options?.isTemplate && "group")}>
<button
type="button"
className="grid size-7 place-items-center rounded-full transition-transform hover:scale-105 focus-visible:scale-105"
onClick={() => handleColorChange(color)}
aria-label={`Выбрать цвет ${color}`}
>
<span
className="size-5 rounded-full"
style={{
backgroundColor: color,
boxShadow: isSelected
? `0 0 0 2px rgba(245,247,251,0.92), 0 0 0 5px ${color}`
: "0 0 0 1px rgba(255,255,255,0.16)",
}}
/>
</button>
{options?.isTemplate && (
<button
type="button"
className="absolute -top-1 -right-1 grid size-4 place-items-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black focus-visible:opacity-100"
onClick={() => handleRemoveTemplateColor(color)}
aria-label={`Удалить шаблон ${color}`}
title="Удалить шаблон"
>
<Trash2 className="size-2.5" />
</button>
)}
</div>
);
};
const colorPickerPanel =
isBrowser && isPaletteOpen
? createPortal(
<div
ref={palettePanelRef}
className="nodedc-accent-picker-panel fixed z-[9999] max-h-[calc(100vh-2rem)] overflow-y-auto rounded-[1.75rem] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.46)]"
style={{
left: panelPosition.left,
top: panelPosition.top,
width: COLOR_PICKER_PANEL_WIDTH,
}}
>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-9 place-items-center rounded-full bg-white/7 text-white transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handlePickColorFromScreen}
aria-label="Взять цвет пипеткой"
title="Пипетка"
>
<Pipette className="size-4" />
</button>
<button
type="button"
className="grid size-9 place-items-center rounded-full bg-white/7 text-white transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handleSaveTemplateColor}
disabled={!isDraftValid}
aria-label="Сохранить цвет как шаблон"
title="Сохранить как шаблон"
>
<BookmarkPlus className="size-4" />
</button>
</div>
<div className="flex min-w-0 items-center gap-2 rounded-full bg-white/7 py-1 pr-3 pl-1 text-11 font-medium text-primary">
<span
className="size-5 shrink-0 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.2)]"
style={{ backgroundColor: normalizedDraftColor || defaultColor }}
/>
<span className="truncate uppercase">{normalizedDraftColor || defaultColor}</span>
</div>
</div>
<ColorPicker.ChromePicker
className="nodedc-accent-chrome-picker"
color={normalizedDraftColor || defaultColor}
disableAlpha
onChange={handleColorPickerChange}
styles={CHROME_PICKER_STYLES}
/>
{templateColors.length > 0 && (
<div className="mt-4 grid grid-cols-8 gap-2">
{templateColors.map((color) => renderColorSwatch(color, { isTemplate: true }))}
</div>
)}
<div
className={cn(
"grid grid-cols-8 gap-2",
templateColors.length > 0 ? "mt-3 border-t border-white/8 pt-3" : "mt-4"
)}
>
{presetColors.map((color) => renderColorSwatch(color))}
</div>
</div>,
document.body
)
: null;
return (
<SettingsControlItem
title={title}
description={description}
control={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-40">
<button
ref={paletteButtonRef}
type="button"
className="absolute top-1/2 left-3 z-20 grid size-5 -translate-y-1/2 place-items-center rounded-full transition-transform outline-none hover:scale-105 focus-visible:scale-105"
onClick={() => setIsPaletteOpen((isOpen) => !isOpen)}
aria-label={paletteLabel}
aria-expanded={isPaletteOpen}
>
<span
className="size-4 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.24)]"
style={{ backgroundColor: normalizedDraftColor || defaultColor }}
/>
</button>
{colorPickerPanel}
<input
name={inputName}
value={draftColor}
onChange={(event) => handleColorChange(event.target.value)}
placeholder={defaultColor}
className="nodedc-settings-input h-11 min-h-11 w-full pr-4 pl-10 text-13 font-medium uppercase"
style={{
color: normalizedDraftColor ? getReadableColor(normalizedDraftColor) : undefined,
}}
aria-invalid={!isDraftValid}
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="nodedc-settings-primary-button min-w-[7rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleSave}
disabled={!isDraftValid || !isDirty || isSaving}
>
Применить
</button>
<button
type="button"
className="nodedc-settings-secondary-button min-w-[6rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleReset}
disabled={savedColor === defaultColor || isSaving}
>
Сбросить
</button>
</div>
</div>
}
/>
);
});
export const ProfileSettingsPassiveCardSurfaceColor = () => (
<ProfileSettingsNodedcColorControl
themeKey="nodedcPassiveCardSurface"
defaultColor={NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX}
title="Фон пассивных карточек"
description="Локальная настройка пользователя. Меняет фон невыделенных карточек во внутреннем и внешнем контурах без перезапуска системы."
inputName="nodedcPassiveCardSurface"
paletteLabel="Открыть палитру фона пассивных карточек"
saveSuccessMessage="Фон пассивных карточек обновлен."
saveErrorMessage="Не удалось сохранить фон пассивных карточек."
resetSuccessMessage="Фон пассивных карточек возвращен к дизайн-конфигу."
resetErrorMessage="Не удалось сбросить фон пассивных карточек."
presetColors={PASSIVE_CARD_PRESET_COLORS}
applyColor={applyNodedcPassiveCardSurfaceColor}
/>
);
export const ProfileSettingsPassiveCardColor = () => (
<ProfileSettingsNodedcColorControl
themeKey="nodedcPassiveCard"
defaultColor={NODEDC_DEFAULT_PASSIVE_CARD_HEX}
title="Цвет дат на карточках"
description="Локальная настройка пользователя. Меняет цвет плашек дат и служебных бейджей на карточках."
inputName="nodedcPassiveCard"
paletteLabel="Открыть палитру цвета дат на карточках"
saveSuccessMessage="Цвет дат на карточках обновлен."
saveErrorMessage="Не удалось сохранить цвет дат на карточках."
resetSuccessMessage="Цвет дат на карточках возвращен к дизайн-конфигу."
resetErrorMessage="Не удалось сбросить цвет дат на карточках."
presetColors={PASSIVE_CARD_PRESET_COLORS}
applyColor={applyNodedcPassiveCardColor}
/>
);

View File

@ -8,5 +8,8 @@ import { rgbToNodedcAccentHex } from "@plane/utils";
import designConfig from "../../design.config.json";
const defaultAccentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
const defaultPassiveCardRgb = designConfig.nodedc.passive_card_rgb as [number, number, number];
export const NODEDC_DEFAULT_ACCENT_HEX = rgbToNodedcAccentHex(defaultAccentRgb);
export const NODEDC_DEFAULT_PASSIVE_CARD_HEX = rgbToNodedcAccentHex(defaultPassiveCardRgb);
export const NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX = rgbToNodedcAccentHex(defaultPassiveCardRgb);

View File

@ -12,8 +12,18 @@ import { useTheme } from "next-themes";
import type { TLanguage } from "@plane/i18n";
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
// helpers
import { applyCustomTheme, applyNodedcAccent, clearCustomTheme } from "@plane/utils";
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
import {
applyCustomTheme,
applyNodedcAccent,
applyNodedcPassiveCardColor,
applyNodedcPassiveCardSurfaceColor,
clearCustomTheme,
} from "@plane/utils";
import {
NODEDC_DEFAULT_ACCENT_HEX,
NODEDC_DEFAULT_PASSIVE_CARD_HEX,
NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX,
} from "../../helpers/nodedc-design";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useRouterParams } from "@/hooks/store/use-router-params";
@ -113,6 +123,18 @@ function StoreWrapper(props: TStoreWrapper) {
applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX);
}, [userProfile?.id, userProfile?.theme?.nodedcAccent]);
useEffect(() => {
if (!userProfile?.id) return;
applyNodedcPassiveCardColor(userProfile?.theme?.nodedcPassiveCard || NODEDC_DEFAULT_PASSIVE_CARD_HEX);
}, [userProfile?.id, userProfile?.theme?.nodedcPassiveCard]);
useEffect(() => {
if (!userProfile?.id) return;
applyNodedcPassiveCardSurfaceColor(
userProfile?.theme?.nodedcPassiveCardSurface || NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX
);
}, [userProfile?.id, userProfile?.theme?.nodedcPassiveCardSurface]);
useEffect(() => {
if (!userProfile?.id) return;
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);

View File

@ -46,6 +46,8 @@ export class ProfileStore implements IUserProfileStore {
background: undefined,
darkPalette: false,
nodedcAccent: undefined,
nodedcPassiveCard: undefined,
nodedcPassiveCardSurface: undefined,
nodedcCompactToolbar: undefined,
},
onboarding_step: {

View File

@ -30,7 +30,10 @@
--nodedc-accent-rgb: 51 163 255;
--nodedc-on-accent-rgb: 245 247 251;
--nodedc-card-passive-rgb: 42 43 46;
--nodedc-card-passive-surface-rgb: 42 43 46;
--nodedc-presence-dot-border-rgb: 42 43 46;
--nodedc-on-card-passive-rgb: 245 247 251;
--nodedc-on-card-passive-surface-rgb: 245 247 251;
--nodedc-card-active-rgb: 195 255 102;
--nodedc-on-card-active-rgb: 11 17 23;
--nodedc-priority-none-rgb: 124 128 138;
@ -207,8 +210,8 @@
color: rgb(var(--nodedc-on-card-active-rgb));
}
100% {
background-color: rgb(var(--nodedc-card-passive-rgb));
color: rgb(var(--nodedc-on-card-passive-rgb));
background-color: rgb(var(--nodedc-card-passive-surface-rgb));
color: rgb(var(--nodedc-on-card-passive-surface-rgb));
}
}
@ -234,7 +237,7 @@
outline: none !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.006) 100%),
rgb(var(--nodedc-card-passive-rgb)) !important;
rgb(var(--nodedc-card-passive-surface-rgb)) !important;
box-shadow:
0 14px 34px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
@ -2243,7 +2246,7 @@
border-radius: 2rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.006) 100%),
rgb(var(--nodedc-card-passive-rgb)) !important;
rgb(var(--nodedc-card-passive-surface-rgb)) !important;
color: var(--text-color-primary) !important;
}
@ -2291,6 +2294,46 @@
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
}
.nodedc-work-item-card:not([data-active="true"]),
.nodedc-external-card:not([data-active="true"]) {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%),
rgb(var(--nodedc-card-passive-surface-rgb)) !important;
-webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px);
color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important;
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.02) !important;
}
.nodedc-work-item-card:not([data-active="true"])::before,
.nodedc-external-card:not([data-active="true"])::before {
content: "" !important;
display: block !important;
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(circle at top right, rgba(var(--nodedc-accent-rgb), 0.08), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.01) 0%, transparent 100%);
pointer-events: none;
}
.nodedc-work-item-card:not([data-active="true"]) .text-primary,
.nodedc-external-card:not([data-active="true"]) .text-primary {
color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important;
}
.nodedc-work-item-card:not([data-active="true"]) .text-secondary,
.nodedc-work-item-card:not([data-active="true"]) .text-tertiary,
.nodedc-work-item-card:not([data-active="true"]) .text-placeholder,
.nodedc-external-card:not([data-active="true"]) .text-secondary,
.nodedc-external-card:not([data-active="true"]) .text-tertiary,
.nodedc-external-card:not([data-active="true"]) .text-placeholder {
color: rgb(var(--nodedc-on-card-passive-surface-rgb) / 0.7) !important;
}
.nodedc-external-content-shell {
border: 0 !important;
outline: none !important;
@ -4294,8 +4337,9 @@
.nodedc-home-task-card-surface-passive {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(46, 46, 50, 0.9) !important;
color: rgba(245, 245, 247, 0.58) !important;
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.008) 100%),
rgb(var(--nodedc-card-passive-surface-rgb)) !important;
color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important;
}
.nodedc-home-task-card-surface-active {

View File

@ -14,6 +14,8 @@ export type TUserProfile = {
theme: {
theme: string | undefined;
nodedcAccent?: string | undefined;
nodedcPassiveCard?: string | undefined;
nodedcPassiveCardSurface?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
};

View File

@ -70,6 +70,8 @@ export type TUserProfile = {
background: string | undefined;
darkPalette: boolean | undefined;
nodedcAccent?: string | undefined;
nodedcPassiveCard?: string | undefined;
nodedcPassiveCardSurface?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
};
onboarding_step: TOnboardingSteps;
@ -110,6 +112,8 @@ export interface IUserTheme {
background?: string | undefined;
darkPalette?: boolean | undefined;
nodedcAccent?: string | undefined;
nodedcPassiveCard?: string | undefined;
nodedcPassiveCardSurface?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
}

View File

@ -30,6 +30,8 @@ export {
// NODE.DC runtime accent
export {
applyNodedcAccent,
applyNodedcPassiveCardColor,
applyNodedcPassiveCardSurfaceColor,
getReadableNodedcTextRgb,
nodedcAccentHexToRgb,
normalizeNodedcAccentHex,

View File

@ -100,3 +100,33 @@ export const applyNodedcAccent = (hex: string | null | undefined): boolean => {
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;
};