АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фундамент редизайна и фиксация UI-техдолга

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 14:30:51 +03:00
parent ecb31a78f9
commit 3f6219fc50
21 changed files with 385 additions and 61 deletions

View File

@ -5,6 +5,7 @@
Связанные документы: Связанные документы:
- архитектурный регламент dropdown-окон: [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md) - архитектурный регламент dropdown-окон: [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md)
- экранный аудит и backlog миграции: [HUI-CANON-AUDIT.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HUI-CANON-AUDIT.md) - экранный аудит и backlog миграции: [HUI-CANON-AUDIT.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HUI-CANON-AUDIT.md)
- активный техдолг по незавершенной миграции dropdown-layer: [plane-src/docs/technical-debts/dropdown-standardization-debt.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/docs/technical-debts/dropdown-standardization-debt.md)
## Источник цветов ## Источник цветов
- Основной runtime-конфиг цветов: [design.config.json](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/design.config.json) - Основной runtime-конфиг цветов: [design.config.json](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/design.config.json)
@ -47,7 +48,9 @@
- Все кнопки без жёсткого outline. - Все кнопки без жёсткого outline.
- Primary button: - Primary button:
- фон: акцентный или `active_card_rgb` - фон: акцентный или `active_card_rgb`
- текст: всегда чёрный или очень тёмный, если фон светлый - текст: определяется автоматически по контрасту заливки
- если заливка светлая, текст тёмный
- если заливка тёмная, текст светлый
- hover: более светлая версия того же цвета - hover: более светлая версия того же цвета
- правило распространяется на все filled CTA: - правило распространяется на все filled CTA:
- `Добавить` - `Добавить`
@ -58,7 +61,8 @@
- любые акцентные toolbar-кнопки - любые акцентные toolbar-кнопки
- это правило обязательно и для `Внешних контуров`: `Добавить запрос` не может иметь светлый текст на светлом фоне - это правило обязательно и для `Внешних контуров`: `Добавить запрос` не может иметь светлый текст на светлом фоне
- Save/update button: - Save/update button:
- если это зафиксированный green CTA, текст должен быть контрастным и читаемым - если это CTA на `accent_rgb` или `active_card_rgb`, текст не задаётся вручную белым или чёрным
- используется системное контрастное значение
- hover осветляет текущий тон, а не уходит в синий - hover осветляет текущий тон, а не уходит в синий
- Secondary button: - Secondary button:
- тёмный glass фон - тёмный glass фон
@ -110,7 +114,7 @@
- Search shell внутри popup должен использовать тот же стиль, что и сам popup. - Search shell внутри popup должен использовать тот же стиль, что и сам popup.
- Filled CTA внутри popup и модалок подчиняются тому же правилу: - Filled CTA внутри popup и модалок подчиняются тому же правилу:
- светлый акцентный фон - светлый акцентный фон
- тёмный/чёрный текст - контрастный текст от реальной яркости фона
- hover только в более светлый тон того же цвета - hover только в более светлый тон того же цвета
### Portal правило ### Portal правило

View File

@ -2,6 +2,9 @@
Документ фиксирует единый канон dropdown-окон NODE.DC. Документ фиксирует единый канон dropdown-окон NODE.DC.
Связанный техдолг:
- [plane-src/docs/technical-debts/dropdown-standardization-debt.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/docs/technical-debts/dropdown-standardization-debt.md)
Цель документа: Цель документа:
- убрать локальные самодельные реализации выпадающих окон - убрать локальные самодельные реализации выпадающих окон
- перевести dropdown на переиспользуемые shared-компоненты - перевести dropdown на переиспользуемые shared-компоненты

View File

@ -5,6 +5,9 @@
и и
[HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md). [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md).
Текущий активный техдолг по незавершенной миграции dropdown-layer:
- [plane-src/docs/technical-debts/dropdown-standardization-debt.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/docs/technical-debts/dropdown-standardization-debt.md)
Цель: Цель:
- увидеть все экраны проекта в одном месте - увидеть все экраны проекта в одном месте
- отделить каноничные shared-dropdown от legacy-механик - отделить каноничные shared-dropdown от legacy-механик
@ -245,21 +248,22 @@
### 1. Внутренний контур ### 1. Внутренний контур
Статус: Статус:
- основной эталон - основной эталон по `board + detail-shell + cards + activity + properties`
Осталось: Осталось:
- дочистить legacy action-menu вне основной карточки - дочистить legacy action-menu вне основной карточки и detail-secondary слоёв
- добить альтернативные view `list / calendar / gantt / spreadsheet`
- добить group headers, spreadsheet header menus, calendar quick add - добить group headers, spreadsheet header menus, calendar quick add
- унифицировать secondary popup в relations, attachments, comments - унифицировать secondary popup в relations, attachments, comments
### 2. Внешние контуры ### 2. Внешние контуры
Статус: Статус:
- частично приведён - cards и detail-shell близки к канону, но модуль ещё не закрыт полностью
Осталось: Осталось:
- перевести [actions-menu.tsx](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/web/ce/components/projects/external-contours/actions-menu.tsx:1) на тот же action-dropdown канон - перевести [actions-menu.tsx](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/web/ce/components/projects/external-contours/actions-menu.tsx:1) на тот же action-dropdown канон, если там ещё остались legacy-path
- проверить все top-toolbar и detail-surface ещё раз по glass/radius/button rules - проверить дополнительное окно информации и related detail-surface ещё раз по glass/radius/button rules
- сравнить все detail-row и popup с эталоном `Внутреннего контура` - сравнить все detail-row и popup с эталоном `Внутреннего контура`
### 3. Предложения / Intake ### 3. Предложения / Intake
@ -345,6 +349,14 @@
Это значит: Это значит:
- эти экраны пока не проходили такой же системный канонический прогон, как `Внутренний контур`, `Внешние контуры` и `Intake` - эти экраны пока не проходили такой же системный канонический прогон, как `Внутренний контур`, `Внешние контуры` и `Intake`
- их надо отдельно сверить по dropdown, button, popup, glass shell, toolbar и spacing - их надо отдельно сверить по dropdown, button, popup, glass shell, toolbar и spacing
- отдельный приоритет внутри этого блока:
- `Analytics overview`
- `Workspace dashboard / Home`
- `Drafts`
- `Profile`
- `Stickies`
- `Browse / All issues / Workspace view`
- `Settings`
## Общий список страниц, которые требуют полноценного UI-pass ## Общий список страниц, которые требуют полноценного UI-pass

View File

@ -116,7 +116,7 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
> >
<span <span
className={`nodedc-toolbar-icon-active-dot ${ className={`nodedc-toolbar-icon-active-dot ${
pathname.includes("/projects/") ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]" : "" pathname.includes("/projects/") ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]" : ""
}`} }`}
> >
<ProjectIcon className="size-4" /> <ProjectIcon className="size-4" />

View File

@ -36,10 +36,50 @@ import "@fontsource/ibm-plex-mono";
const APP_TITLE = "NODE.DC | Self-hosted task management workspace."; const APP_TITLE = "NODE.DC | Self-hosted task management workspace.";
const DARK_TEXT_RGB = [11, 17, 23] as const;
const LIGHT_TEXT_RGB = [245, 247, 251] as const;
const formatRgbTuple = (rgb: readonly number[]) => rgb.join(" ");
const formatCssRgb = (rgb: readonly number[]) => `rgb(${rgb.join(" ")})`;
const blendRgb = (rgb: readonly number[], target: number, ratio: number) =>
rgb.map((channel) => Math.round(channel * (1 - ratio) + target * ratio)) as [number, number, number];
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;
};
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];
const passiveCardRgb = designConfig.nodedc.passive_card_rgb as [number, number, number];
const accentHoverRgb = blendRgb(accentRgb, 255, 0.18);
const accentActiveRgb = blendRgb(accentRgb, 0, 0.1);
const onAccentRgb = getReadableTextRgb(accentRgb);
const onActiveCardRgb = getReadableTextRgb(activeCardRgb);
const onPassiveCardRgb = getReadableTextRgb(passiveCardRgb);
const designConfigStyle = { const designConfigStyle = {
"--nodedc-accent-rgb": designConfig.nodedc.accent_rgb.join(" "), "--nodedc-accent-rgb": formatRgbTuple(accentRgb),
"--nodedc-card-passive-rgb": designConfig.nodedc.passive_card_rgb.join(" "), "--nodedc-card-passive-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-card-active-rgb": designConfig.nodedc.active_card_rgb.join(" "), "--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),
"--brand-default": formatCssRgb(accentRgb),
"--brand-300": formatCssRgb(blendRgb(accentRgb, 255, 0.35)),
"--brand-700": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),
"--bg-accent-primary": formatCssRgb(accentRgb),
"--bg-accent-primary-hover": formatCssRgb(accentHoverRgb),
"--bg-accent-primary-active": formatCssRgb(accentActiveRgb),
"--txt-on-color": formatCssRgb(onAccentRgb),
"--txt-icon-on-color": formatCssRgb(onAccentRgb),
} as CSSProperties; } as CSSProperties;
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [

View File

@ -122,12 +122,14 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]); const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]);
const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""]; const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""];
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const foregroundClasses = isActive ? "text-[#111111]" : "text-white"; const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses = const pillBackgroundClasses =
isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white"; const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)"); const statusIconColor = selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)");
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
if (!issue) return null; if (!issue) return null;

View File

@ -75,7 +75,10 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
/> />
<div className="min-w-0"> <div className="min-w-0">
<div <div
className={cn("truncate text-[15px] leading-5 font-semibold", isActive ? "text-[#0b1117]" : "text-primary")} className={cn(
"truncate text-[15px] leading-5 font-semibold",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-primary"
)}
> >
{requesterName} {requesterName}
</div> </div>
@ -83,7 +86,12 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<div className={cn("text-11 font-medium", isActive ? "text-[#17212b]/70" : "text-tertiary")}> <div
className={cn(
"text-11 font-medium",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-tertiary"
)}
>
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id} {issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -97,7 +105,12 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
</div> </div>
</div> </div>
<div className={cn("truncate pl-10 text-[12px] font-medium leading-4", isActive ? "text-[#17212b]/75" : "text-secondary")}> <div
className={cn(
"truncate pl-10 text-[12px] font-medium leading-4",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/75" : "text-secondary"
)}
>
{contourName} {contourName}
</div> </div>
</div> </div>
@ -106,7 +119,7 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
<h3 <h3
className={cn( className={cn(
"line-clamp-3 w-full max-w-[18.5rem] text-center text-16 leading-7 font-semibold", "line-clamp-3 w-full max-w-[18.5rem] text-center text-16 leading-7 font-semibold",
isActive ? "text-[#0b1117]" : "text-primary" isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-primary"
)} )}
> >
{issue.name} {issue.name}
@ -141,7 +154,7 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
<div <div
className={cn( className={cn(
"rounded-full px-3 py-1.5 text-12", "rounded-full px-3 py-1.5 text-12",
isActive ? "bg-black/10 text-[#17212b]/80" : "bg-white/6 text-secondary" isActive ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]/80" : "bg-white/6 text-secondary"
)} )}
> >
{renderFormattedDate(lastUpdatedAt ?? "")} {renderFormattedDate(lastUpdatedAt ?? "")}

View File

@ -73,7 +73,7 @@ export const FilterDisplayProperties = observer(function FilterDisplayProperties
type="button" type="button"
className={`rounded-full border-0 px-3 py-1.5 text-12 transition-all ${ className={`rounded-full border-0 px-3 py-1.5 text-12 transition-all ${
displayProperties?.[displayProperty.key] displayProperties?.[displayProperty.key]
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]" ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: "bg-white/5 text-secondary hover:bg-white/8" : "bg-white/5 text-secondary hover:bg-white/8"
}`} }`}
onClick={() => onClick={() =>

View File

@ -29,7 +29,7 @@ export function FilterOption(props: Props) {
<div <div
className={`grid h-4 w-4 flex-shrink-0 place-items-center border-0 ${ className={`grid h-4 w-4 flex-shrink-0 place-items-center border-0 ${
isChecked isChecked
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]" ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: "bg-white/6 text-transparent" : "bg-white/6 text-transparent"
} rounded-full`} } rounded-full`}
> >

View File

@ -48,7 +48,9 @@ export function LayoutSelection(props: Props) {
layout={layout.key} layout={layout.key}
size={14} size={14}
strokeWidth={2} strokeWidth={2}
className={`size-3.5 ${selectedLayout == layout.key ? "text-[#0b1117]" : "text-secondary"}`} className={`size-3.5 ${
selectedLayout == layout.key ? "text-[rgb(var(--nodedc-on-accent-rgb))]" : "text-secondary"
}`}
/> />
</span> </span>
</button> </button>

View File

@ -27,7 +27,7 @@ export function MobileLayoutSelection({
<SelectionDropdown <SelectionDropdown
menuButton={ menuButton={
<div className="nodedc-toolbar-pill relative flex items-center gap-2 px-3"> <div className="nodedc-toolbar-pill relative flex items-center gap-2 px-3">
<span className="nodedc-toolbar-icon-active-dot bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]"> <span className="nodedc-toolbar-icon-active-dot bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]">
{activeLayout && ( {activeLayout && (
<IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className="h-3.5 w-3.5" /> <IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className="h-3.5 w-3.5" />
)} )}

View File

@ -75,7 +75,8 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const selectedState = getStateById(issue.state_id); const selectedState = getStateById(issue.state_id);
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const { pillBackgroundClasses, iconBubbleClasses } = getNodedcWorkItemCardAppearance(isActive); const { pillBackgroundClasses, iconBubbleClasses } = getNodedcWorkItemCardAppearance(isActive);
const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)"); const statusIconColor =
selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)");
const creatorName = creatorDetails?.display_name ?? t("common.none"); const creatorName = creatorDetails?.display_name ?? t("common.none");
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");

View File

@ -23,11 +23,13 @@ type TNodedcWorkItemCardProps = {
export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({ export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
surfaceClassName: isActive surfaceClassName: isActive
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[#111111]" ? "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-rgb))] text-white",
foregroundClasses: isActive ? "text-[#111111]" : "text-white", foregroundClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-[rgb(var(--nodedc-on-card-passive-rgb))]",
subtleTextClasses: isActive ? "text-[#2F4721]" : "text-[#B3B3B8]", subtleTextClasses: isActive ? "text-[#2F4721]" : "text-[#B3B3B8]",
pillBackgroundClasses: isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white", pillBackgroundClasses: isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-[rgb(var(--nodedc-on-card-passive-rgb))]",
iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white", iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white",
}); });

View File

@ -435,7 +435,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
type="submit" type="submit"
loading={isLoading} loading={isLoading}
disabled={!isAdmin} disabled={!isAdmin}
className="nodedc-settings-save-button min-w-[11.5rem] !text-[#0b1117] hover:!text-[#0b1117]" className="nodedc-settings-save-button min-w-[11.5rem]"
> >
{isLoading ? t("updating") : t("common.update_project")} {isLoading ? t("updating") : t("common.update_project")}
</Button> </Button>

View File

@ -57,7 +57,7 @@ export const ProjectFeatureUpdate = observer(function ProjectFeatureUpdate(props
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues`} href={`/${workspaceSlug}/projects/${projectId}/issues`}
onClick={onClose} onClick={onClose}
className="nodedc-modal-primary-button inline-flex min-w-[10.5rem] items-center justify-center !text-[#0b1117] hover:!text-[#0b1117]" className="nodedc-modal-primary-button inline-flex min-w-[10.5rem] items-center justify-center"
tabIndex={2} tabIndex={2}
> >
{t("open_project")} {t("open_project")}

View File

@ -28,11 +28,19 @@
--editor-colors-dark-blue-background: #c9dafb; --editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd; --editor-colors-purple-background: #e3d8fd;
--nodedc-accent-rgb: 51 163 255; --nodedc-accent-rgb: 51 163 255;
--nodedc-on-accent-rgb: 245 247 251;
--nodedc-card-passive-rgb: 42 43 46; --nodedc-card-passive-rgb: 42 43 46;
--nodedc-on-card-passive-rgb: 245 247 251;
--nodedc-card-active-rgb: 195 255 102; --nodedc-card-active-rgb: 195 255 102;
--nodedc-on-card-active-rgb: 11 17 23;
--brand-default: rgb(var(--nodedc-accent-rgb)); --brand-default: rgb(var(--nodedc-accent-rgb));
--brand-300: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 35%, white); --brand-300: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 65%, white);
--brand-700: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 75%, black); --brand-700: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 75%, black);
--bg-accent-primary: rgb(var(--nodedc-accent-rgb));
--bg-accent-primary-hover: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white);
--bg-accent-primary-active: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 90%, black);
--txt-on-color: rgb(var(--nodedc-on-accent-rgb));
--txt-icon-on-color: rgb(var(--nodedc-on-accent-rgb));
/* end background colors */ /* end background colors */
} }
/* background colors */ /* background colors */
@ -507,7 +515,7 @@
.nodedc-toolbar-icon-button[data-active="true"] .nodedc-toolbar-icon-active-dot { .nodedc-toolbar-icon-button[data-active="true"] .nodedc-toolbar-icon-active-dot {
background: rgb(var(--nodedc-accent-rgb)); background: rgb(var(--nodedc-accent-rgb));
color: #0b1117; color: rgb(var(--nodedc-on-accent-rgb));
} }
.nodedc-toolbar-pill { .nodedc-toolbar-pill {
@ -553,7 +561,7 @@
min-height: 2.5rem; min-height: 2.5rem;
padding-inline: 1.55rem; padding-inline: 1.55rem;
background: rgb(var(--nodedc-accent-rgb)) !important; background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-accent-rgb)) !important;
} }
.nodedc-toolbar-primary-wide { .nodedc-toolbar-primary-wide {
@ -587,7 +595,7 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-accent-rgb)) !important; background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-accent-rgb)) !important;
padding-inline: 1.25rem !important; padding-inline: 1.25rem !important;
} }
@ -596,14 +604,17 @@
} }
.nodedc-modal-primary-button, .nodedc-modal-primary-button,
.nodedc-modal-primary-button *,
.nodedc-modal-danger-button, .nodedc-modal-danger-button,
.nodedc-modal-danger-button *, .nodedc-modal-primary-button *,
.nodedc-modal-danger-button * {
color: rgb(var(--nodedc-on-accent-rgb)) !important;
}
.nodedc-settings-primary-button, .nodedc-settings-primary-button,
.nodedc-settings-primary-button *, .nodedc-settings-primary-button *,
.nodedc-settings-save-button, .nodedc-settings-save-button,
.nodedc-settings-save-button * { .nodedc-settings-save-button * {
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-modal-danger-button { .nodedc-modal-danger-button {
@ -613,7 +624,7 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-accent-rgb)) !important; background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-accent-rgb)) !important;
padding-inline: 1.25rem !important; padding-inline: 1.25rem !important;
} }
@ -627,7 +638,7 @@
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
background: rgb(var(--nodedc-accent-rgb)) !important; background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-accent-rgb)) !important;
} }
.nodedc-glass-modal button.bg-danger-primary:hover, .nodedc-glass-modal button.bg-danger-primary:hover,
@ -637,7 +648,7 @@
.nodedc-glass-modal button.bg-danger-primary *, .nodedc-glass-modal button.bg-danger-primary *,
.nodedc-glass-modal button.border-danger-strong * { .nodedc-glass-modal button.border-danger-strong * {
color: #0b1117 !important; color: rgb(var(--nodedc-on-accent-rgb)) !important;
} }
.nodedc-modal-chip { .nodedc-modal-chip {
@ -761,13 +772,13 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
padding-inline: 1.35rem !important; padding-inline: 1.35rem !important;
} }
.nodedc-settings-primary-button:hover { .nodedc-settings-primary-button:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important; background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-settings-save-button { .nodedc-settings-save-button {
@ -777,13 +788,13 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
padding-inline: 1.45rem !important; padding-inline: 1.45rem !important;
} }
.nodedc-settings-save-button:hover { .nodedc-settings-save-button:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important; background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-overlay-button { .nodedc-overlay-button {
@ -1014,12 +1025,12 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-auth-primary-button:hover { .nodedc-auth-primary-button:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important; background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-error-shell { .nodedc-error-shell {
@ -1055,13 +1066,13 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.2rem !important; border-radius: 1.2rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
padding-inline: 1.35rem !important; padding-inline: 1.35rem !important;
} }
.nodedc-error-primary:hover { .nodedc-error-primary:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important; background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-empty-state-primary { .nodedc-empty-state-primary {
@ -1071,13 +1082,13 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.2rem !important; border-radius: 1.2rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
padding-inline: 1.35rem !important; padding-inline: 1.35rem !important;
} }
.nodedc-empty-state-primary:hover { .nodedc-empty-state-primary:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important; background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-empty-state-secondary { .nodedc-empty-state-secondary {
@ -1143,14 +1154,14 @@
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%),
rgb(var(--nodedc-card-active-rgb)) !important; rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow: box-shadow:
inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.32), inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.32),
0 12px 32px rgba(0, 0, 0, 0.16) !important; 0 12px 32px rgba(0, 0, 0, 0.16) !important;
} }
.nodedc-external-card[data-active="true"] .text-primary { .nodedc-external-card[data-active="true"] .text-primary {
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-external-card[data-active="true"] .text-secondary, .nodedc-external-card[data-active="true"] .text-secondary,
@ -1278,19 +1289,19 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.35rem !important; border-radius: 1.35rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-card-active-rgb)) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
padding-inline: 1.6rem !important; padding-inline: 1.6rem !important;
font-weight: 600 !important; font-weight: 600 !important;
} }
.nodedc-external-primary-button, .nodedc-external-primary-button,
.nodedc-external-primary-button * { .nodedc-external-primary-button * {
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-external-primary-button:hover { .nodedc-external-primary-button:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important; background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: #0b1117 !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
} }
.nodedc-external-empty-state { .nodedc-external-empty-state {

View File

@ -0,0 +1,233 @@
# Dropdown Standardization Debt
Дата фиксации: `2026-04-22`
Статус: `active`
Связанные документы:
- [HDESIGN-CODE.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDESIGN-CODE.md)
- [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md)
- [HUI-CANON-AUDIT.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HUI-CANON-AUDIT.md)
## Зачем фиксируется этот техдолг
В проекте уже выполнена большая часть канонизации dropdown-окон, но этап еще не завершен.
Это означает:
- общий контракт для dropdown уже определен
- значимая часть экранов уже переведена на shared-компоненты
- legacy-механики еще не вычищены полностью
- визуальный слой новых dropdown и связанных элементов еще не доведен на всех экранах до одного и того же дизайн-эталона
Этот документ нужен, чтобы:
- не потерять текущий контекст миграции
- не откатиться обратно к локальным menu-wrapper решениям
- иметь единый технический ориентир перед следующим большим этапом редизайна
- отделить архитектурную канонизацию dropdown-stack от визуальной доводки экранов
## Что уже считается сделанным
На текущем этапе уже зафиксированы и частично внедрены следующие принципы:
### 1. Типизация dropdown по смыслу
В системе признаются только три вида выпадающих окон:
- `Selection dropdown`
- `Action dropdown`
- `Context menu`
Правило:
- если пользователь выбирает значение, используется `SelectionDropdown`
- если пользователь вызывает список действий, используется `ActionDropdown`
- `ContextMenu` допустим только как secondary/right-click механизм
### 2. Общий канон поведения
Dropdown больше не считается локальной версткой рядом с кнопкой.
Dropdown должен быть:
- отдельным floating-layer
- привязанным к реальному trigger-элементу
- открываемым через общий popper/portal стек
- одинаковым по контракту открытия, закрытия и позиционирования
### 3. Уже мигрированные слои
На общий канон уже переведены крупные блоки:
- project/module breadcrumbs
- quick-actions карточек и detail action-menu
- desktop header action-menu
- desktop sorting dropdown
- mobile selection/menu слой для части экранов
- searchable select-пикеры
- form/settings select-пикеры
### 4. Уже определен визуальный канон
Для dropdown зафиксирован matte black glass подход:
- темный glass surface
- blur
- мягкая граница
- единый popup-shell
- единый ритм option-row
- запрет на случайные outline, clip и локальные подложки
## В чем состоит незакрытый долг
Долг не в том, что dropdown “вообще не стандартизированы”.
Долг в том, что стандартизация завершена не до конца на уровне всей системы.
Проблема сейчас состоит из четырех частей.
### 1. Не все legacy dropdown переведены на shared-канон
В проекте еще остаются места, где используются старые механики:
- `CustomMenu`
- `CustomSelect`
- `CustomSearchSelect`
- локальные menu-wrapper решения вокруг существующих control-компонентов
Это критично, потому что:
- соседние controls могут вести себя по-разному на одном экране
- фиксы начинают делаться точечно, а не системно
- новая UI-логика начинает разъезжаться по разным слоям
### 2. Не все dropdown доведены до одной и той же схемы переиспользования
Даже там, где поведение уже близко к канону, еще встречаются расхождения:
- разные trigger-shell
- разные popup-wrapper
- разная схема option-render
- разные локальные классы для похожих dropdown
Это опасно тем, что код выглядит “уже почти каноничным”, но реально еще не является одним и тем же reusable-механизмом.
### 3. Визуальный канон еще не протянут через все экраны
Архитектурный слой во многих местах уже выровнен, но визуально часть экранов еще живет на смешанном состоянии:
- popup уже новый, а surrounding layout еще старый
- dropdown уже каноничный, а рядом карточка, detail-pane или modal сверстаны по старому ритму
- glass shell и spacing формально есть, но не совпадают с эталоном `Внутреннего контура`
### 4. Нет финального закрытия этапа по критерию Definition of Done
Этап можно считать закрытым только тогда, когда:
- legacy dropdown-механики перестают быть обязательным рабочим слоем
- новые экраны не изобретают свои popup-решения
- существующие экраны используют только зафиксированные shared-компоненты
- UI доведен до одного и того же канона не только функционально, но и визуально
Сейчас этот критерий еще не выполнен.
## Что запрещено до полного закрытия долга
До закрытия этого техдолга запрещено:
- добавлять новый dropdown через локальный `isOpen` и `absolute` popup
- делать новый `...` через отдельный компонент, если уже есть `ActionDropdown`
- использовать `CustomMenu` как “быструю временную затычку” для нового action-menu
- использовать `CustomSelect` или `CustomSearchSelect` для новых экранов, если их можно закрыть каноничными shared-компонентами
- лечить проблемы positioning случайными `left/right translate` без исправления placement-контракта
- исправлять визуальный разнобой только внешней оберткой, если проблема находится внутри базового reusable-компонента
## Что обязательно делать при продолжении этапа
Любая новая работа по dropdown и связанным UI-элементам должна идти в таком порядке:
1. Определить тип dropdown:
`selection`, `action` или `context`.
2. Проверить, есть ли shared-компонент для этого сценария.
3. Если shared-компонент есть:
дорабатывать его, а не создавать новый локальный wrapper.
4. Если shared-компонент не покрывает кейс полностью:
расширять shared-контракт, чтобы изменение переиспользовалось и на других экранах.
5. После архитектурного изменения дотягивать экран до дизайн-канона целиком, а не только dropdown.
## Граница между архитектурой и редизайном
Важно разделять два разных этапа.
### Текущий этап
Это этап архитектурной канонизации dropdown-layer.
Его задача:
- вычистить legacy popup-механики
- выровнять trigger/popup/portal/placement слой
- свести похожие выпадающие окна к одним и тем же shared-компонентам
### Следующий большой этап
Это этап редизайна UI-элементов под новые каноны.
Его задача:
- пройтись по живым экранам проекта
- довести карточки, detail-pane, filters, modal, toolbar и list layouts до одного визуального эталона
- использовать уже зафиксированный dropdown-канон как инфраструктурную основу, а не спорить заново о popup-поведении
Именно этот второй этап теперь считается более приоритетным.
## Какие области еще требуют возврата
На момент фиксации документа к этапу нужно вернуться минимум по следующим направлениям:
### 1. Legacy select/search layer
Нужно дочистить остатки старых select-механик:
- `automation`
- `export`
- `pages modal`
- `api-token`
- `publish-project`
- `rich-filters`
Цель:
- убрать остаточные прямые зависимости от legacy select/search dropdown-слоя
- завершить переход на `SelectionDropdown` и `SearchSelectionDropdown`
### 2. Legacy action/context layer
Нужно проверить, не остались ли вторичные места, где visible action-menu еще живет на старом menu-engine.
Цель:
- не допустить, чтобы `ActionDropdown` существовал как “еще один способ”
- закрепить его как основной стандарт для action popup
### 3. Экранный визуальный проход
Нужно отдельно пройтись по:
- `Внутреннему контуру`
- `Внешним контурам`
- `Предложениям`
- `Модулям`
- `Циклам`
- `Видам`
- `Страницам`
- workspace/sidebar/settings flows
Цель:
- довести не только сами dropdown, но и surrounding UI до общего дизайн-ритма
## Признак завершения техдолга
Этот документ можно считать закрытым только если одновременно выполнены все условия:
- на новых экранах больше не появляются локальные dropdown-механики
- legacy `CustomMenu` не используется как основной visible action dropdown
- legacy `CustomSelect` и `CustomSearchSelect` перестают быть рабочим стандартом для новых задач
- общие shared dropdown покрывают реальные product-сценарии без точечных обходов
- визуально dropdown, filters, action menu и related cards/modals подчиняются одному и тому же канону
- команда может вернуться к экрану через несколько недель и продолжить работу без повторного архитектурного переизобретения
## Что делать перед стартом большого этапа редизайна
Перед стартом следующего большого этапа нужно считать зафиксированными следующие вводные:
- dropdown-канон уже существует и описан
- техдолг по миграции еще активен
- редизайн не должен плодить новые popup-механики
- если при редизайне находится dropdown-кейс, которого нет в shared-компонентах, сначала расширяется shared-компонент, а только потом меняется экран
Итоговое правило:
- редизайн делается поверх канона
- канон не ломается ради скорости редизайна

View File

@ -13,7 +13,7 @@ export const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
primary: primary:
"bg-accent-primary text-on-color hover:bg-accent-primary-hover active:bg-accent-primary-active disabled:bg-layer-disabled disabled:text-on-color-disabled", "bg-accent-primary text-[rgb(var(--nodedc-on-accent-rgb))] hover:bg-accent-primary-hover active:bg-accent-primary-active disabled:bg-layer-disabled disabled:text-on-color-disabled",
"error-fill": "error-fill":
"bg-danger-primary text-on-color hover:bg-danger-primary-hover active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled", "bg-danger-primary text-on-color hover:bg-danger-primary-hover active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled",
"error-outline": "error-outline":

View File

@ -14,7 +14,7 @@ export const iconButtonVariants = cva(
variants: { variants: {
variant: { variant: {
primary: primary:
"bg-accent-primary text-on-color hover:bg-accent-primary-hover focus:bg-accent-primary-active active:bg-accent-primary-active disabled:bg-layer-disabled disabled:text-on-color-disabled", "bg-accent-primary text-[rgb(var(--nodedc-on-accent-rgb))] hover:bg-accent-primary-hover focus:bg-accent-primary-active active:bg-accent-primary-active disabled:bg-layer-disabled disabled:text-on-color-disabled",
"error-fill": "error-fill":
"bg-danger-primary text-on-color hover:bg-danger-primary-hover focus:bg-danger-primary-active active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled", "bg-danger-primary text-on-color hover:bg-danger-primary-hover focus:bg-danger-primary-active active:bg-danger-primary-active disabled:bg-layer-disabled disabled:text-disabled",
"error-outline": "error-outline":

View File

@ -130,7 +130,8 @@ const ToolbarSeparator = React.forwardRef(function ToolbarSeparator(
}); });
const buttonVariants = { const buttonVariants = {
primary: "bg-accent-primary text-on-color hover:bg-accent-primary/80 focus:bg-accent-primary/80", primary:
"bg-accent-primary text-[rgb(var(--nodedc-on-accent-rgb))] hover:bg-accent-primary-hover focus:bg-accent-primary-active",
secondary: "bg-surface-1 text-secondary border border-subtle hover:bg-surface-2 focus:bg-surface-2", secondary: "bg-surface-1 text-secondary border border-subtle hover:bg-surface-2 focus:bg-surface-2",
outline: outline:
"border border-accent-strong text-accent-primary bg-transparent hover:bg-accent-primary/10 focus:bg-accent-primary/20", "border border-accent-strong text-accent-primary bg-transparent hover:bg-accent-primary/10 focus:bg-accent-primary/20",

View File

@ -44,9 +44,9 @@ enum buttonIconStyling {
export const buttonStyling: IButtonStyling = { export const buttonStyling: IButtonStyling = {
primary: { primary: {
default: `text-on-color bg-accent-primary`, default: `text-[rgb(var(--nodedc-on-accent-rgb))] bg-accent-primary`,
hover: `hover:bg-accent-primary/80`, hover: `hover:bg-accent-primary-hover`,
pressed: `focus:text-custom-brand-40 focus:bg-accent-primary/80`, pressed: `focus:bg-accent-primary-active`,
disabled: `cursor-not-allowed !bg-layer-1 !text-on-color-disabled`, disabled: `cursor-not-allowed !bg-layer-1 !text-on-color-disabled`,
}, },
"accent-primary": { "accent-primary": {