Establish NODE.DC design system baseline

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 02:34:36 +03:00
commit 73629d68c3
63 changed files with 6701 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
.DS_Store
*.log
.vite/
coverage/
*.tsbuildinfo

22
AGENTS.md Normal file
View File

@ -0,0 +1,22 @@
# NODE.DC Design System rules
This repository is the canonical home of reusable NODE.DC interface foundations and components.
Before adding UI to a NODE.DC application:
1. Read `registry/registry.json` and `docs/COMPONENTS.md`.
2. Reuse an existing package export if the required primitive or pattern already exists.
3. If a reusable capability is missing, add it here first and consume a released version from the application.
4. Do not copy component source into an application. Temporary adapters must stay thin and be documented in `docs/ADOPTION.md`.
5. Application-specific domain UI remains in the application, but its controls and surfaces must be composed from this system.
Canonical source limits:
- Launcher/Hub, CMS and SEO are the reference product family for application shell, headers, windows and common controls.
- From Engine, only the redesigned environment settings, shared glass controls and NDC agent inspector are approved reference sources.
- Legacy Engine inspectors and legacy Engine plates are explicitly non-canonical.
- Task Manager contributes only already-standardized shared behavior and components. Its legacy local menus are non-canonical.
- BIM Viewer contributes validated non-React integration requirements.
Every component change must update its registry entry and the living catalog. Preserve keyboard behavior, portal layering and theme independence.

12
CHANGELOG.md Normal file
View File

@ -0,0 +1,12 @@
# Changelog
## 0.1.0 — baseline
- Established the standalone NODE.DC design-system repository.
- Added canonical tokens and dark/light glass theme contracts.
- Added framework-neutral positioning and theme utilities.
- Added DOM controllers for dropdowns and modal windows.
- Added React primitives, overlays, navigation and inspector/settings patterns.
- Added a living catalog and machine-readable component/source registry.
- Recorded the initial audit boundary without modifying source applications.

61
README.md Normal file
View File

@ -0,0 +1,61 @@
# NODE.DC Design Guideline
Единый источник компонентов и правил интерфейса NODE.DC.
Это не архив скриншотов и не описание того, как разные приложения когда-то стилизовали одинаковый контрол. Репозиторий содержит реальные переиспользуемые пакеты, машинный реестр и живой каталог. Новые приложения должны подключать эти пакеты, выбирать тему/акцент и собирать предметный интерфейс из готовых элементов.
## Что здесь является каноном
- общая геометрия приложения и верхней панели;
- glass-материал, поверхности и слои;
- кнопки, поля, чекеры, range, select и dropdown;
- модальные окна, подтверждения и боковые окна;
- segmented navigation и статусы;
- инспектор/панель настроек нового поколения;
- поведение portal-слоёв, Escape, outside click и фокуса;
- контракт темы: приложения меняют цветовую схему, а не геометрию компонентов.
## Что не является каноном
- старые инспекторы и старые плашки Engine;
- legacy dropdown/menu реализации Task Manager;
- доменная логика нод, BIM-моделей, SEO pipeline или задач;
- локальные копии компонентов внутри приложений.
## Пакеты
- `@nodedc/tokens` — JSON и CSS custom properties.
- `@nodedc/ui-core` — общие стили, тема, контраст и расчёт floating-layer.
- `@nodedc/ui-react` — React-компоненты.
- `@nodedc/ui-dom` — JS/DOM-контроллеры для BIM Viewer, CMS и других приложений без React.
- `@nodedc/ui-catalog` — живой стенд всех канонических компонентов.
## Быстрый запуск каталога
```bash
npm install
npm run dev
```
Полная проверка:
```bash
npm run check
npm run build
```
## Статус
Версия `0.1.x` — зафиксированный baseline. Исходные приложения на первом этапе не изменяются. Компоненты здесь построены по подтверждённым реализациям Launcher/Hub, CMS/SEO и двум новым участкам Engine: настройкам окружения и инспектору агентной ноды.
Подробности:
- [Архитектура](docs/ARCHITECTURE.md)
- [Компоненты](docs/COMPONENTS.md)
- [Темы](docs/THEMING.md)
- [Окна и слои](docs/WINDOWS_AND_LAYERS.md)
- [Границы исходного аудита](docs/SOURCE_BASELINE.md)
- [Правила развития](docs/GOVERNANCE.md)
- [План подключения приложений](docs/ADOPTION.md)
- [Инвентарь следующих компонентов](docs/CANDIDATES.md)

14
apps/catalog/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru" data-nodedc-ui data-nodedc-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#050506" />
<title>NODE.DC UI Catalog</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/catalog/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@nodedc/ui-catalog",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@nodedc/ui-core": "0.1.0",
"@nodedc/ui-react": "0.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
}
}

View File

@ -0,0 +1 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,331 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import {
AppHeader,
Button,
Checker,
ColorField,
ConfirmationModal,
ControlRow,
Dropdown,
FieldFrame,
GlassSurface,
HeaderProfile,
HeaderWorkspace,
IconButton,
Inspector,
RangeControl,
SegmentedControl,
Select,
StatusBadge,
TextAreaField,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "windows" | "inspector";
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "Hub", value: [195, 255, 102], hex: "#c3ff66" },
{ label: "Engine", value: [148, 123, 219], hex: "#947bdb" },
{ label: "BIM", value: [78, 164, 255], hex: "#4ea4ff" },
{ label: "SEO", value: [32, 32, 34], hex: "#202022" },
];
const selectOptions = [
{ value: "active", label: "Активен", description: "Сервис доступен пользователям" },
{ value: "maintenance", label: "Техработы", description: "Временно недоступен" },
{ value: "disabled", label: "Отключён", description: "Запуск запрещён" },
] as const;
function rgbFromHex(hex: string): RgbTuple | null {
const normalized = hex.trim().replace(/^#/, "");
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null;
return [
Number.parseInt(normalized.slice(0, 2), 16),
Number.parseInt(normalized.slice(2, 4), 16),
Number.parseInt(normalized.slice(4, 6), 16),
];
}
function Section({ id, eyebrow, title, description, children }: {
id: string;
eyebrow: string;
title: string;
description: string;
children: ReactNode;
}) {
return (
<section id={id} className="catalog-section">
<header className="catalog-section__head">
<span>{eyebrow}</span>
<h2>{title}</h2>
<p>{description}</p>
</header>
{children}
</section>
);
}
function Preview({ title, note, children, className }: {
title: string;
note?: string;
children: ReactNode;
className?: string;
}) {
return (
<GlassSurface className={`catalog-preview ${className ?? ""}`} padding="lg">
<div className="catalog-preview__head">
<strong>{title}</strong>
{note ? <span>{note}</span> : null}
</div>
<div className="catalog-preview__body">{children}</div>
</GlassSurface>
);
}
export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66");
const [section, setSection] = useState<CatalogSection>("foundation");
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
const [checker, setChecker] = useState(true);
const [range, setRange] = useState(64);
const [modalOpen, setModalOpen] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [projectName, setProjectName] = useState("NODE.DC Platform");
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
useEffect(() => {
applyNodedcTheme(document.documentElement, { theme, accent });
}, [accent, theme]);
const openSection = (next: CatalogSection) => {
setSection(next);
document.getElementById(next)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const inspectorSections = [
{
id: "definition",
label: "Definition Control",
description: "Параметры агентной ноды",
content: (
<>
<ControlRow label="Активность"><Checker checked={checker} label="Агент активен" onChange={setChecker} /></ControlRow>
<ControlRow label="Модель">
<Select
label="Модель"
value="codex"
options={[
{ value: "codex", label: "Codex" },
{ value: "local", label: "Local executor" },
]}
onChange={() => undefined}
/>
</ControlRow>
<RangeControl label="Температура" value={range} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setRange} />
</>
),
},
{
id: "appearance",
label: "Внешний вид",
description: "Тема и акцент",
content: (
<ControlRow label="Акцент"><ColorField value={accentHex} onChange={setAccentHex} /></ControlRow>
),
},
{
id: "runtime",
label: "Выполнение",
description: "Параметры запуска",
content: <Checker checked={false} label="Запускать автоматически" onChange={() => undefined} />,
},
];
return (
<div className="catalog-app nodedc-ui-root" style={{ "--catalog-accent": accentHex } as CSSProperties}>
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
center={
<>
<HeaderWorkspace label="UI" />
<SegmentedControl
label="Разделы каталога"
value={section}
items={[
{ value: "foundation", label: "Основа" },
{ value: "controls", label: "Контролы" },
{ value: "windows", label: "Окна" },
{ value: "inspector", label: "Инспектор" },
]}
onChange={openSection}
/>
</>
}
right={
<HeaderProfile>
<IconButton label="Сменить тему" onClick={() => setTheme((current) => current === "dark" ? "light" : "dark")}>
{theme === "dark" ? "☼" : "◐"}
</IconButton>
<span className="catalog-avatar">DC</span>
</HeaderProfile>
}
/>
<main className="catalog-main">
<div className="catalog-hero">
<StatusBadge tone="accent">baseline 0.1.0</StatusBadge>
<h1>Один UI-контракт.<br />Разные приложения.</h1>
<p>Геометрия, окна и поведение остаются общими. Приложение выбирает тему и акцент, затем использует готовые компоненты.</p>
</div>
<Section id="foundation" eyebrow="01 / Foundation" title="Материал и тема" description="Одинаковые поверхности проверяются в тёмной и светлой схеме без fork компонентов.">
<div className="catalog-grid catalog-grid--foundation">
<Preview title="Theme contract" note={theme === "dark" ? "dark glass" : "light glass"}>
<div className="catalog-theme-row">
<Button variant={theme === "dark" ? "accent" : "secondary"} onClick={() => setTheme("dark")}>Dark</Button>
<Button variant={theme === "light" ? "accent" : "secondary"} onClick={() => setTheme("light")}>Light</Button>
</div>
<div className="catalog-swatches">
{accents.map((item) => (
<button
key={item.label}
type="button"
className="catalog-swatch"
data-active={item.hex.toLowerCase() === accentHex.toLowerCase() ? "true" : undefined}
style={{ "--swatch": item.hex } as CSSProperties}
onClick={() => setAccentHex(item.hex)}
>
<span />{item.label}
</button>
))}
</div>
<ColorField label="Свой accent" value={accentHex} onChange={setAccentHex} />
</Preview>
<Preview title="Glass hierarchy" note="default / strong / soft">
<div className="catalog-surface-stack">
<GlassSurface padding="md">Default surface</GlassSurface>
<GlassSurface tone="strong" padding="md">Strong floating surface</GlassSurface>
<GlassSurface tone="soft" padding="md">Soft nested surface</GlassSurface>
</div>
</Preview>
</div>
</Section>
<Section id="controls" eyebrow="02 / Primitives" title="Контролы" description="Общие actions, fields и selection primitives для React и DOM consumers.">
<div className="catalog-grid">
<Preview title="Actions" note="shared geometry">
<div className="catalog-inline catalog-inline--wrap">
<Button variant="accent">Сохранить</Button>
<Button>Отмена</Button>
<Button variant="ghost">Подробнее</Button>
<Button variant="danger">Удалить</Button>
<IconButton label="Настройки"></IconButton>
</div>
</Preview>
<Preview title="Fields" note="label / hint / control">
<div className="catalog-form">
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div>
</Preview>
<Preview title="Selection" note="portal dropdown">
<div className="catalog-form">
<FieldFrame label="Статус">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
</FieldFrame>
<Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} onClick={toggle}>Действия</Button>
)}
>
{({ close }) => (
<>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><span /><span className="nodedc-dropdown-option__label">Переименовать</span></button>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><span /><span className="nodedc-dropdown-option__label">Дублировать</span></button>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><span /><span className="nodedc-dropdown-option__label">Архивировать</span></button>
</>
)}
</Dropdown>
</div>
</Preview>
<Preview title="Settings controls" note="new Engine generation">
<div className="catalog-form">
<Checker checked={checker} label="Автоматическое обновление" description="Применять изменения без перезапуска" onChange={setChecker} />
<RangeControl label="Интенсивность стекла" value={range} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setRange} />
</div>
</Preview>
<Preview title="Semantic status" note="not tied to accent" className="catalog-preview--wide">
<div className="catalog-inline catalog-inline--wrap">
<StatusBadge>Черновик</StatusBadge>
<StatusBadge tone="success">Готово</StatusBadge>
<StatusBadge tone="warning">Нужна проверка</StatusBadge>
<StatusBadge tone="danger">Ошибка</StatusBadge>
<StatusBadge tone="accent">В работе</StatusBadge>
</div>
</Preview>
</div>
</Section>
<Section id="windows" eyebrow="03 / Layers" title="Окна и подтверждения" description="Modal и side panel используют одну механику portal, Escape, focus trap и focus restore.">
<Preview title="Controlled windows" note="center / end / confirm">
<div className="catalog-inline catalog-inline--wrap">
<Button variant="accent" onClick={() => setModalOpen(true)}>Открыть окно</Button>
<Button onClick={() => setInspectorOpen(true)}>Открыть инспектор</Button>
<Button variant="danger" onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
</div>
</Preview>
</Section>
<Section id="inspector" eyebrow="04 / Pattern" title="Inspector / Settings" description="Канон основан только на новом Environment Settings и NDC Agent Inspector, без legacy-инспекторов Engine.">
<div className="catalog-inspector-stage">
<GlassSurface tone="strong" radius="panel" padding="md" className="catalog-inspector-card">
<div className="catalog-inspector-title"><strong>Инспектор</strong><span>Agent Definition</span></div>
<Inspector sections={inspectorSections} defaultOpen={["definition"]} activeId="definition" />
</GlassSurface>
</div>
</Section>
</main>
<Window
open={modalOpen}
title="Настройки сервиса"
subtitle="Единое окно Launcher / CMS / SEO"
onClose={() => setModalOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}>Отмена</Button>
<WindowFooterActions><Button variant="accent" onClick={() => setModalOpen(false)}>Сохранить</Button></WindowFooterActions>
</>
}
>
<div className="catalog-form">
<TextField label="Название" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
<Checker checked={checker} label="Сервис активен" onChange={setChecker} />
</div>
</Window>
<Window open={inspectorOpen} title="Инспектор" subtitle="Agent Definition" placement="end" onClose={() => setInspectorOpen(false)}>
<Inspector sections={inspectorSections} defaultOpen={["definition"]} activeId="definition" />
</Window>
<ConfirmationModal
open={confirmOpen}
title="Удалить конфигурацию?"
description={<>Конфигурация будет удалена. Это действие нельзя отменить.</>}
confirmLabel="Удалить"
danger
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
/>
</div>
);
}

12
apps/catalog/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@nodedc/ui-core/styles.css";
import "./styles.css";
import { CatalogApp } from "./CatalogApp";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<CatalogApp />
</StrictMode>,
);

261
apps/catalog/src/styles.css Normal file
View File

@ -0,0 +1,261 @@
:root {
background: var(--nodedc-canvas);
}
html {
scroll-behavior: smooth;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 72% 10%, rgba(var(--nodedc-accent-rgb), 0.09), transparent 28rem),
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px),
var(--nodedc-canvas);
background-size: auto, 44px 44px, 44px 44px, auto;
color: var(--nodedc-text-primary);
transition: background-color 220ms ease, color 220ms ease;
}
button,
input,
textarea {
font: inherit;
}
.catalog-app {
min-height: 100vh;
}
[data-nodedc-theme="light"] .catalog-app .nodedc-header__brand img {
filter: brightness(0.12);
}
.catalog-avatar {
display: inline-grid;
width: 3rem;
height: 3rem;
place-items: center;
border-radius: 999px;
background: rgb(var(--nodedc-accent-rgb));
color: rgb(var(--nodedc-on-accent-rgb));
font-size: 0.72rem;
font-weight: 800;
}
.catalog-main {
display: grid;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
gap: 8rem;
padding: 5rem 0 10rem;
}
.catalog-hero {
display: grid;
min-height: 32rem;
align-content: center;
justify-items: start;
gap: 1.4rem;
}
.catalog-hero h1 {
max-width: 820px;
margin: 0;
color: var(--nodedc-text-primary);
font-size: clamp(3.1rem, 7vw, 6.4rem);
font-weight: 670;
letter-spacing: -0.065em;
line-height: 0.92;
}
.catalog-hero p {
max-width: 650px;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: clamp(1rem, 1.6vw, 1.3rem);
line-height: 1.5;
}
.catalog-section {
display: grid;
scroll-margin-top: 2rem;
gap: 2rem;
}
.catalog-section__head {
display: grid;
max-width: 760px;
gap: 0.75rem;
}
.catalog-section__head span {
color: rgb(var(--nodedc-accent-rgb));
font-size: 0.72rem;
font-weight: 780;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.catalog-section__head h2 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.6rem);
font-weight: 650;
letter-spacing: -0.05em;
line-height: 1;
}
.catalog-section__head p {
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 1rem;
line-height: 1.5;
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.catalog-grid--foundation {
grid-template-columns: 1.1fr 0.9fr;
}
.catalog-preview {
min-height: 18rem;
}
.catalog-preview--wide {
grid-column: 1 / -1;
}
.catalog-preview__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
}
.catalog-preview__head strong {
font-size: 0.92rem;
}
.catalog-preview__head span {
color: var(--nodedc-text-muted);
font-size: 0.68rem;
text-transform: uppercase;
}
.catalog-preview__body {
display: grid;
min-height: 10rem;
align-content: center;
gap: 1rem;
}
.catalog-theme-row,
.catalog-inline {
display: flex;
align-items: center;
gap: 0.6rem;
}
.catalog-inline--wrap {
flex-wrap: wrap;
}
.catalog-swatches {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
}
.catalog-swatch {
display: grid;
min-width: 0;
gap: 0.4rem;
border: 0;
border-radius: 0.9rem;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-muted);
padding: 0.55rem;
font-size: 0.68rem;
cursor: pointer;
}
.catalog-swatch span {
height: 2.4rem;
border-radius: 0.65rem;
background: var(--swatch);
}
.catalog-swatch[data-active="true"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.catalog-surface-stack,
.catalog-form {
display: grid;
gap: 0.75rem;
}
.catalog-surface-stack .nodedc-glass {
min-height: 4rem;
display: flex;
align-items: center;
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
}
.catalog-inspector-stage {
display: flex;
min-height: 40rem;
justify-content: flex-end;
align-items: flex-start;
padding: 4rem;
border-radius: 2rem;
background:
radial-gradient(circle at 18% 24%, rgba(var(--nodedc-accent-rgb), 0.13), transparent 22rem),
color-mix(in srgb, var(--nodedc-canvas-soft) 86%, transparent);
}
.catalog-inspector-card {
width: min(24.375rem, 100%);
}
.catalog-inspector-title {
display: grid;
gap: 0.3rem;
padding: 0.75rem 0.75rem 1rem;
}
.catalog-inspector-title span {
color: var(--nodedc-text-muted);
font-size: 0.7rem;
}
@media (max-width: 820px) {
.catalog-main {
gap: 6rem;
padding-top: 2rem;
}
.catalog-grid,
.catalog-grid--foundation {
grid-template-columns: 1fr;
}
.catalog-preview--wide {
grid-column: auto;
}
.catalog-inspector-stage {
padding: 1rem;
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
sourcemap: true,
},
});

69
docs/ADOPTION.md Normal file
View File

@ -0,0 +1,69 @@
# Подключение приложений
## Текущее состояние
Первый этап не меняет существующие приложения. Репозиторий фиксирует baseline и создаёт пакетный контракт. Это позволяет продолжать текущую разработку без одномоментной переделки Engine, Ops, BIM, CMS, SEO и Launcher.
## Рекомендуемый порядок
### 1. Catalog validation
Сначала компоненты проверяются как самостоятельная система в dark/light и нескольких accent. До этого они не объявляются заменой production-кода.
### 2. Launcher/Hub pilot
Первый consumer — Launcher/Hub как основной visual reference. Подключение выполняется по одному вертикальному набору:
- tokens/theme;
- Button/IconButton;
- Dropdown/Select;
- Window/Confirmation;
- AppHeader.
Локальные реализации удаляются только после функционального и визуального сравнения.
### 3. CMS и SEO
Используют те же компоненты и механику окон. CMS подключает DOM-layer, SEO — React-layer. Цветовые различия оформляются theme overrides.
### 4. Engine
Сначала библиотека заменяет компоненты только в уже новом Environment Settings и NDC Agent Inspector. Затем остальные инспекторы переводятся на Inspector/ControlRow постепенно. Legacy UI не переносится в библиотеку и не используется как reference.
### 5. Task Manager / Ops
Подключение начинается с floating-layer и modal primitives, где уже сформулирован канон. Массовая замена Plane UI не выполняется одним изменением.
### 6. BIM Viewer
Подключает tokens/styles и `@nodedc/ui-dom`. React в BIM ради дизайн-системы не добавляется.
## Новые приложения
Шаблон приложения пока не входит в scope. До его появления новый проект должен:
- установить опубликованные NODE.DC UI packages;
- выбрать theme и accent;
- использовать AppHeader и Window/Dropdown primitives;
- проверять registry перед созданием локального control;
- хранить доменные компоненты у себя.
## Adoption matrix
| Приложение | Текущий источник | Целевой adapter | Первый набор |
| --- | --- | --- | --- |
| Launcher/Hub | React/local shared | React | header, button, dropdown, window |
| SEO | React/local overrides | React | theme, window, field, select |
| CMS | Vanilla DOM | DOM | theme, header, modal, select |
| Engine | React/mixed generations | React | new inspector and environment settings only |
| Ops | React/Plane packages | React | floating behavior and modal primitives |
| BIM Viewer | Vanilla JS | DOM | tokens, glass select, settings surfaces |
## Не делать
- не переписывать все приложения одновременно;
- не копировать `packages/ui-react/src` в consumer;
- не поддерживать отдельную тему путём fork компонента;
- не использовать legacy Engine inspector как промежуточный канон;
- не удалять production local component до проверки package replacement.

79
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,79 @@
# Архитектура дизайн-системы
## Цель
NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику.
Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Он не является backend-сервисом и не владеет данными приложений.
## Слои
### Tokens
`@nodedc/tokens` хранит геометрию, слои, motion и переменные темы. Геометрия не зависит от выбранного цвета приложения.
### Core
`@nodedc/ui-core` хранит:
- общий CSS-контракт;
- расчёт контрастного текста на акцентной заливке;
- расчёт позиции dropdown/popover относительно viewport;
- применение темы к DOM-контейнеру.
Core не зависит от React.
### React
`@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
### DOM
`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`.
### Catalog
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
## Граница shared/domain
В дизайн-системе живут элементы, значение которых не зависит от предметной области:
- поверхности;
- поля и действия;
- floating layers;
- окна;
- навигационная геометрия;
- inspector/settings layout;
- состояния загрузки, пустоты и ошибки.
В приложениях остаются:
- определение агентной ноды;
- схема SEO pipeline;
- BIM tree/model tools;
- модели задач и карточки с уникальной бизнес-логикой;
- запросы к API, права, маршруты и сохранение данных.
Если элемент встречается только в одном приложении, это ещё не причина переносить его сюда. Он переносится, когда имеет независимый UI-контракт или нужен новому приложению как готовый строительный блок.
## Зависимости
Направление зависимостей одностороннее:
1. tokens;
2. core;
3. React/DOM adapters;
4. applications.
Дизайн-система никогда не импортирует исходники приложения. Исторические реализации используются только как audit/reference и фиксируются в `registry/sources.json`.
## Версионирование
- `0.x` — baseline и первоначальная проверка API на реальных приложениях;
- minor — новый компонент или обратно совместимое расширение;
- patch — исправление поведения/стиля без изменения публичного контракта;
- major — удаление/переименование экспорта или несовместимая геометрия/семантика.
Приложение должно зависеть от опубликованной версии пакета, а не от копии файлов или произвольного commit URL.

35
docs/CANDIDATES.md Normal file
View File

@ -0,0 +1,35 @@
# Следующие компоненты
Baseline намеренно отделяет готовые exports от известного инвентаря. Элемент из этого списка нельзя считать отсутствующим и начинать с нуля в новом приложении: сначала проверяется его запись в `registry/candidates.json` и существующие reference sources.
## P1 — следующий reusable слой
- CalendarPopover — согласовать Launcher и Task Manager, включая keyboard/date range.
- ProfileMenu — отделить визуальную карточку от identity/role API приложения.
- ActionDropdown — добавить типизированные command rows поверх готового Dropdown.
- DataTable — вынести toolbar, cells, loading/empty и row actions без entity schemas Launcher/Plane.
- SideNavigation — route-aware навигация отдельно от уже готового Inspector accordion.
- AvatarStack — согласовать overflow, presence и picker mode.
## P2 — продуктовые patterns
- ServiceRailCard — зрелый Launcher pattern, пока связанный с service catalog data.
- WorkItemCard — зрелый Ops pattern, который нужно разделить на общий card shell и task-domain content.
- MediaSourceField — общий preview/picker, но storage adapter остаётся в приложении.
- Toast.
- Tooltip.
- Empty/Loading/Error states.
## Как использовать список
Если новый проект требует candidate:
1. не копировать reference implementation целиком;
2. поднять candidate до package component в этом репозитории;
3. определить независимые props/DOM contract;
4. добавить catalog examples и состояния;
5. обновить `components.json`, удалив запись из candidates;
6. выпустить minor version.
Так список сохраняет уже проделанную работу, но не превращает незавершённый локальный код в ложный канон.

108
docs/COMPONENTS.md Normal file
View File

@ -0,0 +1,108 @@
# Канонические компоненты
Машинный список находится в `registry/components.json`. Ниже зафиксирован смысл компонентов и границы их использования.
## GlassSurface
Базовая поверхность для карточки, панели и большого окна. Варианты меняют плотность материала, но не создают отдельный дизайн.
- `default` — обычная карточка/панель;
- `strong` — dropdown, modal и поверхность над сложным фоном;
- `soft` — вложенная или вторичная область.
Material rim допустим только как часть стекла. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
## Button и IconButton
Button используется для всех текстовых действий. IconButton — для действия без текста.
- primary/accent — главное действие текущего контекста;
- secondary — обычное действие на glass surface;
- ghost — малозаметное действие без собственной подложки;
- danger — разрушительное действие, обычно в окне подтверждения.
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
## Field
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
Приложение не должно вручную собирать label и input, если подходит Field. Ошибка и validation state будут расширением этого контракта, а не локальным классом приложения.
## Checker
Круглый бинарный контрол из нового Engine settings/agent inspector. Он использует checkbox semantics, но не копирует квадратный системный checkbox.
## RangeControl
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа.
## ColorField
Связка круглого color picker и текстового значения. Валидация конкретного формата остаётся у формы приложения.
## Dropdown
Dropdown владеет floating-layer поведением:
- рендерится в `document.body`;
- позиционируется относительно viewport;
- переворачивается вверх при недостатке места;
- ограничивает размеры viewport;
- обновляется при resize и scroll;
- закрывается по outside pointer и Escape;
- закрывает другой открытый dropdown.
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
## Select
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. Native select не используется как видимый runtime UI, но может использоваться как fallback только в специально оговорённом legacy-контуре.
## Window
Window — единая механика открытия modal и правой panel:
- приложение контролирует `open`;
- библиотека управляет portal, focus entry/trap/restore, Escape, backdrop и scroll lock;
- `center` — modal;
- `end` — inspector/settings/detail panel.
Содержание, сохранение и запросы принадлежат приложению.
## ConfirmationModal
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
## SegmentedControl
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
## AppHeader
Трёхосевая верхняя панель:
- слева фиксированная область логотипа и optional context;
- по центру workspace/navigation;
- справа actions/profile.
Компонент фиксирует положение логотипа, высоту и выравнивание, но не знает маршруты конкретного приложения.
## StatusBadge
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
## Inspector и ControlRow
Единая accordion-панель для settings и definition controls. Источник — только новый Environment Settings и NDC Agent Inspector.
Inspector владеет:
- заголовками разделов;
- открытием/закрытием секций;
- active state;
- вертикальным ритмом;
- раскладкой label/control.
Engine продолжает владеть определениями полей, типами нод и сохранением значений.

56
docs/GOVERNANCE.md Normal file
View File

@ -0,0 +1,56 @@
# Правила развития
## Единственный источник истины
Публичный компонент, его CSS, документация, registry entry и catalog example изменяются одним pull/merge request в этом репозитории.
Приложения не должны исправлять общий компонент локальным копированием. Если срочный adapter неизбежен, он должен:
- оборачивать публичный компонент;
- не копировать его внутреннюю реализацию;
- иметь ссылку на issue/design-system change;
- быть внесён в `docs/ADOPTION.md` как временный долг.
## Добавление компонента
Компонент принимается в систему, если:
1. он независим от доменной модели;
2. его API можно описать без упоминания конкретной таблицы/ноды/проекта;
3. он использует существующую тему и layer scale;
4. определены keyboard и disabled/pending states;
5. добавлен React или DOM adapter в зависимости от потребителей;
6. есть catalog example;
7. обновлён registry.
## Изменение геометрии
Геометрия меняется централизованно. Нельзя исправлять высоту/радиус в одном приложении, если изменение относится к общему контролу.
Допустимы density variants, если они имеют устойчивое назначение (`default`, `compact`) и тестируются как часть API.
## Deprecated
Перед удалением export:
- он получает статус deprecated в registry;
- документация указывает replacement;
- минимум один minor release сохраняет совместимость;
- после миграции известных consumers export удаляется в major release.
## Проверки
Минимум для merge:
- typecheck всех пакетов;
- registry validation;
- production build каталога;
- ручная визуальная проверка dark/light и нескольких accent;
- keyboard smoke test для Dropdown, Select, Window и Inspector.
Следующий уровень зрелости — автоматические screenshot regression и interaction tests. Они добавляются до массовой миграции приложений.
## Владение
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.

59
docs/SOURCE_BASELINE.md Normal file
View File

@ -0,0 +1,59 @@
# Baseline источников
Дата аудита: 10 июля 2026 года. Точные repository revisions находятся в `registry/sources.json`.
## Launcher / Hub
Основной визуальный источник:
- общая верхняя панель;
- позиция логотипа;
- segmented navigation;
- dark glass family;
- button/modal/dropdown primitives;
- общая логика admin windows.
Существовавший `dc-ui-guideline` использован как исходный инвентарь. Он больше не должен развиваться как независимая копия после подключения центрального репозитория.
## SEO и CMS
Рассматриваются как одна продуктовая семья с Launcher/Hub. Их различия должны выражаться темой и содержанием, а не отдельной реализацией окон и controls.
SEO содержит большой объём накопленных локальных overrides. Они являются материалом аудита, но не переносятся автоматически. В библиотеку попадает очищенный общий контракт.
CMS подтверждает необходимость DOM-пакета без React.
## Engine
Разрешён только строгий subset:
- Environment Settings;
- NdcGlassChecker;
- NdcGlassSelect;
- NdcGlassModal;
- новый NDC Agent Inspector;
- styles, непосредственно обслуживающие эти элементы.
Все остальные legacy inspectors и старые plates не участвуют в принятии решений. Их будущее приведение к новому дизайну должно идти через эту библиотеку.
## Task Manager / Ops
Используются только уже стандартизированные shared patterns и документы по dropdown/modal behavior. Известные legacy CustomMenu и screen-local wrappers не являются источником компонентов.
## BIM Viewer
Используется как ограничение архитектуры: общий UI должен работать без React. В baseline включены общие требования к toolbar, settings menu и glass select, но не BIM-domain controls.
## Почему код не копируется целиком
Исторические реализации содержат:
- зависимости от доменных типов;
- локальные классы;
- дублированные токены;
- наслоившиеся overrides;
- разные z-index;
- различную степень accessibility.
Поэтому библиотека фиксирует очищенный API и поведение, сохраняя происхождение решения в реестре.

56
docs/THEMING.md Normal file
View File

@ -0,0 +1,56 @@
# Темы и цвет приложения
## Основное правило
NODE.DC-приложения отличаются цветовой схемой, а не набором заново нарисованных компонентов. Тема задаёт значения CSS custom properties. Компоненты сохраняют геометрию, состояния и поведение.
## Что меняет тема
- canvas/background;
- основной и вторичный текст;
- accent и автоматически вычисляемый on-accent;
- плотность/цвет glass surfaces;
- field/control surfaces;
- overlay;
- shadows и material rim;
- semantic success/warning/danger tones при необходимости.
## Что тема не меняет
- высоту контролов;
- радиусы по классам компонентов;
- положение логотипа;
- механику окна и dropdown;
- DOM anatomy;
- keyboard behavior;
- расположение footer actions;
- API React/DOM компонентов.
## Presets
В baseline включены `dark` и `light`. Это не два отдельных продукта, а примеры одного контракта:
- dark соответствует текущей основной семье Launcher/Hub/CMS;
- light фиксирует возможность светлой схемы, наблюдаемой в SEO;
- конкретный продукт может поверх preset задать свой accent и surface variables.
## Accent
Accent задаётся RGB tuple. On-accent вычисляется по luminance и не должен вручную прописываться белым или чёрным в отдельном компоненте.
Semantic status не обязан совпадать с accent. Например, ошибка остаётся danger, даже если приложение использует красный брендовый accent.
## Область применения
Тема может быть установлена:
- на корневом `html/body` для всего приложения;
- на контейнере embedded-модуля;
- на catalog preview для сравнения схем.
Portal-компоненты рендерятся в `document.body`, поэтому приложение должно либо задавать тему на `document.documentElement`, либо передавать согласованные переменные на body. Локальная тема глубоко внутри React subtree не сможет автоматически охватить portal без отдельного theme portal root.
## Совместимость
Существующие `--nodedc-*` имена сохранены намеренно. Это уменьшает стоимость будущего подключения Launcher, Engine, Task Manager и BIM Viewer.

View File

@ -0,0 +1,61 @@
# Окна, dropdown и слои
## Зачем отдельный контракт
В NODE.DC повторяется одна механика: пользователь нажимает trigger, открывается окно или floating surface, взаимодействует и закрывает его. Если каждое приложение реализует outside click, Escape, portal и z-index отдельно, одинаковые окна начинают вести себя по-разному.
## Layer scale
- base: `0`;
- panel: `100`;
- header: `400`;
- overlay/window: `800`;
- dropdown/popover: `900`;
- toast: `950`.
Произвольные значения вроде `30030` допускаются только во временном legacy adapter. Новый код использует scale.
## Dropdown/popover
Обязательное поведение:
1. trigger сообщает `aria-expanded`;
2. surface переносится в body;
3. координаты рассчитываются как fixed;
4. горизонтальная позиция ограничивается viewport;
5. при недостатке места surface меняет bottom/top placement;
6. scroll/resize пересчитывают позицию;
7. outside pointer и Escape закрывают surface;
8. после Escape фокус возвращается на trigger;
9. открытие другого dropdown закрывает предыдущий.
Inline absolute dropdown внутри card/sidebar/sticky container является дефектом.
## Modal window
Обязательное поведение:
1. window рендерится в body;
2. при открытии запоминается текущий focus;
3. focus переносится на первый интерактивный элемент или dialog;
4. Tab не выходит за пределы dialog;
5. Escape закрывает окно, если операция не блокирует закрытие;
6. backdrop закрывает окно только при клике именно на backdrop;
7. body scroll блокируется;
8. после закрытия focus возвращается на предыдущий trigger.
## Side window / inspector
Side window использует ту же механику, но placement `end` и ограниченную ширину. Это канон для:
- настроек окружения;
- инспектора агентной ноды;
- будущих detail/settings panels;
- CMS/SEO side editors, если они не требуют отдельного полноэкранного workflow.
## Управление состоянием
Библиотека не создаёт глобальный store окон. Приложение владеет тем, какое окно открыто и какие данные в нём загружены. Библиотека владеет одинаковым поведением самого слоя.
Если приложению позже потребуется stack нескольких modeless-окон, это будет отдельный пакетный контракт, а не расширение локального z-index.

1916
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "nodedc-design-guideline",
"version": "0.1.0",
"private": true,
"description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.",
"type": "module",
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"validate:registry": "node scripts/validate-registry.mjs"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
}
}

View File

@ -0,0 +1,11 @@
{
"name": "@nodedc/tokens",
"version": "0.1.0",
"type": "module",
"files": ["tokens.json", "tokens.css", "themes.css"],
"exports": {
"./tokens.json": "./tokens.json",
"./tokens.css": "./tokens.css",
"./themes.css": "./themes.css"
}
}

View File

@ -0,0 +1,67 @@
:root,
[data-nodedc-theme="dark"] {
color-scheme: dark;
--nodedc-accent-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-danger-rgb: 255 116 116;
--nodedc-warning-rgb: 255 209 102;
--nodedc-success-rgb: 181 255 90;
--nodedc-canvas: #050506;
--nodedc-canvas-soft: #0b0b0d;
--nodedc-text-primary: rgba(247, 248, 244, 0.96);
--nodedc-text-secondary: rgba(247, 248, 244, 0.72);
--nodedc-text-muted: rgba(247, 248, 244, 0.48);
--nodedc-text-disabled: rgba(247, 248, 244, 0.34);
--nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72);
--nodedc-glass-panel-bg-strong: rgba(18, 18, 21, 0.88);
--nodedc-glass-panel-bg-soft: rgba(42, 42, 46, 0.5);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.014)), var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)), var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-control-bg: rgba(110, 110, 110, 0.07);
--nodedc-glass-control-hover: rgba(129, 129, 129, 0.13);
--nodedc-glass-control-active: rgba(255, 255, 255, 0.92);
--nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96);
--nodedc-field-bg: rgba(20, 20, 27, 0.96);
--nodedc-overlay-bg: rgba(0, 0, 0, 0.46);
--nodedc-glass-rim: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.026));
--nodedc-glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
--nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.075), 0 22px 72px rgba(0, 0, 0, 0.42);
--nodedc-glass-dropdown-shadow: 0 22px 60px rgba(0, 0, 0, 0.42), 0 6px 18px rgba(0, 0, 0, 0.2);
--nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.14);
}
[data-nodedc-theme="light"] {
color-scheme: light;
--nodedc-accent-rgb: 32 32 34;
--nodedc-on-accent-rgb: 247 248 244;
--nodedc-danger-rgb: 205 47 67;
--nodedc-warning-rgb: 173 111 0;
--nodedc-success-rgb: 46 125 76;
--nodedc-canvas: #ececec;
--nodedc-canvas-soft: #f4f4f4;
--nodedc-text-primary: rgba(18, 20, 22, 0.96);
--nodedc-text-secondary: rgba(18, 20, 22, 0.7);
--nodedc-text-muted: rgba(18, 20, 22, 0.48);
--nodedc-text-disabled: rgba(18, 20, 22, 0.32);
--nodedc-glass-panel-bg: rgba(255, 255, 255, 0.48);
--nodedc-glass-panel-bg-strong: rgba(255, 255, 255, 0.72);
--nodedc-glass-panel-bg-soft: rgba(255, 255, 255, 0.34);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.22)), var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.34)), var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-control-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.22)), rgba(255, 255, 255, 0.38);
--nodedc-glass-control-hover: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.34)), rgba(255, 255, 255, 0.54);
--nodedc-glass-control-active: rgba(32, 32, 34, 0.96);
--nodedc-glass-control-active-text: rgba(247, 248, 244, 0.96);
--nodedc-field-bg: rgba(255, 255, 255, 0.58);
--nodedc-overlay-bg: rgba(24, 32, 29, 0.22);
--nodedc-glass-rim: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(24, 32, 29, 0.1));
--nodedc-glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68);
--nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 22px 76px rgba(24, 32, 29, 0.16);
--nodedc-glass-dropdown-shadow: 0 22px 60px rgba(24, 32, 29, 0.2), 0 6px 18px rgba(24, 32, 29, 0.1);
--nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.1);
}

View File

@ -0,0 +1,56 @@
:root,
[data-nodedc-ui] {
--nodedc-radius-modal: 1.75rem;
--nodedc-radius-panel: 1.375rem;
--nodedc-radius-card: 1.35rem;
--nodedc-radius-control: 1.25rem;
--nodedc-radius-control-compact: 0.875rem;
--nodedc-radius-option: 0.9rem;
--nodedc-radius-circle: 999px;
--nodedc-control-height: 2.875rem;
--nodedc-control-height-compact: 2.375rem;
--nodedc-icon-button-size: 2.875rem;
--nodedc-header-row-height: 3rem;
--nodedc-header-pill-height: 3.45rem;
--nodedc-header-logo-width: 7.25rem;
--nodedc-header-logo-height: 1.79rem;
--nodedc-inspector-width: 24.375rem;
--nodedc-space-1: 0.25rem;
--nodedc-space-2: 0.5rem;
--nodedc-space-3: 0.75rem;
--nodedc-space-4: 1rem;
--nodedc-space-5: 1.25rem;
--nodedc-space-6: 1.5rem;
--nodedc-space-8: 2rem;
--nodedc-page-padding: 1.25rem;
--nodedc-blur-control: 24px;
--nodedc-blur-panel: 44px;
--nodedc-blur-modal: 64px;
--nodedc-blur-dropdown: 44px;
--nodedc-duration-fast: 120ms;
--nodedc-duration-normal: 180ms;
--nodedc-duration-slow: 260ms;
--nodedc-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
--nodedc-layer-base: 0;
--nodedc-layer-panel: 100;
--nodedc-layer-header: 400;
--nodedc-layer-overlay: 800;
--nodedc-layer-popover: 900;
--nodedc-layer-toast: 950;
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nodedc-font-size-xs: 0.6875rem;
--nodedc-font-size-sm: 0.75rem;
--nodedc-font-size-md: 0.8125rem;
--nodedc-font-size-lg: 1rem;
--nodedc-font-size-title: 1.125rem;
--nodedc-font-weight-regular: 450;
--nodedc-font-weight-medium: 620;
--nodedc-font-weight-strong: 760;
}

View File

@ -0,0 +1,61 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"schemaVersion": "1.0.0",
"name": "NODE.DC Design Tokens",
"color": {
"accentRgb": { "$type": "color", "$value": "rgb(195 255 102)" },
"onAccentRgb": { "$type": "color", "$value": "rgb(11 17 23)" },
"dangerRgb": { "$type": "color", "$value": "rgb(255 116 116)" },
"warningRgb": { "$type": "color", "$value": "rgb(255 209 102)" },
"successRgb": { "$type": "color", "$value": "rgb(181 255 90)" }
},
"radius": {
"modal": { "$type": "dimension", "$value": "28px" },
"panel": { "$type": "dimension", "$value": "22px" },
"card": { "$type": "dimension", "$value": "21.6px" },
"control": { "$type": "dimension", "$value": "20px" },
"controlCompact": { "$type": "dimension", "$value": "14px" },
"option": { "$type": "dimension", "$value": "14.4px" },
"circle": { "$type": "dimension", "$value": "999px" }
},
"size": {
"control": { "$type": "dimension", "$value": "46px" },
"controlCompact": { "$type": "dimension", "$value": "38px" },
"iconButton": { "$type": "dimension", "$value": "46px" },
"headerRow": { "$type": "dimension", "$value": "48px" },
"headerPill": { "$type": "dimension", "$value": "55.2px" },
"logoWidth": { "$type": "dimension", "$value": "116px" },
"logoHeight": { "$type": "dimension", "$value": "28.64px" },
"inspectorWidth": { "$type": "dimension", "$value": "390px" }
},
"space": {
"1": { "$type": "dimension", "$value": "4px" },
"2": { "$type": "dimension", "$value": "8px" },
"3": { "$type": "dimension", "$value": "12px" },
"4": { "$type": "dimension", "$value": "16px" },
"5": { "$type": "dimension", "$value": "20px" },
"6": { "$type": "dimension", "$value": "24px" },
"8": { "$type": "dimension", "$value": "32px" },
"page": { "$type": "dimension", "$value": "20px" }
},
"blur": {
"control": { "$type": "dimension", "$value": "24px" },
"panel": { "$type": "dimension", "$value": "44px" },
"modal": { "$type": "dimension", "$value": "64px" },
"dropdown": { "$type": "dimension", "$value": "44px" }
},
"duration": {
"fast": { "$type": "duration", "$value": "120ms" },
"normal": { "$type": "duration", "$value": "180ms" },
"slow": { "$type": "duration", "$value": "260ms" }
},
"layer": {
"base": { "$type": "number", "$value": 0 },
"panel": { "$type": "number", "$value": 100 },
"header": { "$type": "number", "$value": 400 },
"overlay": { "$type": "number", "$value": 800 },
"popover": { "$type": "number", "$value": 900 },
"toast": { "$type": "number", "$value": 950 }
}
}

View File

@ -0,0 +1,22 @@
{
"name": "@nodedc/ui-core",
"version": "0.1.0",
"type": "module",
"files": ["dist", "styles.css"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles.css": "./styles.css"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/tokens": "0.1.0"
}
}

View File

@ -0,0 +1,38 @@
export type RgbTuple = readonly [number, number, number];
export interface AccentVariables {
"--nodedc-accent-rgb": string;
"--nodedc-on-accent-rgb": string;
}
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function linearChannel(channel: number): number {
const normalized = clampChannel(channel) / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
}
export function relativeLuminance(rgb: RgbTuple): number {
const [red, green, blue] = rgb.map(linearChannel) as [number, number, number];
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
}
export function readableTextRgb(rgb: RgbTuple): RgbTuple {
return relativeLuminance(rgb) > 0.46 ? [11, 17, 23] : [247, 248, 244];
}
export function rgbTuple(value: RgbTuple): string {
return value.map(clampChannel).join(" ");
}
export function createAccentVariables(accent: RgbTuple): AccentVariables {
return {
"--nodedc-accent-rgb": rgbTuple(accent),
"--nodedc-on-accent-rgb": rgbTuple(readableTextRgb(accent)),
};
}

View File

@ -0,0 +1,67 @@
export type FloatingPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
export interface RectLike {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
export interface FloatingPositionOptions {
anchor: RectLike;
surfaceWidth: number;
surfaceHeight: number;
placement?: FloatingPlacement;
offset?: number;
viewportWidth?: number;
viewportHeight?: number;
viewportPadding?: number;
}
export interface FloatingPosition {
top: number;
left: number;
maxHeight: number;
resolvedPlacement: FloatingPlacement;
}
export function computeFloatingPosition({
anchor,
surfaceWidth,
surfaceHeight,
placement = "bottom-start",
offset = 8,
viewportWidth = typeof window === "undefined" ? 1920 : window.innerWidth,
viewportHeight = typeof window === "undefined" ? 1080 : window.innerHeight,
viewportPadding = 8,
}: FloatingPositionOptions): FloatingPosition {
const roomBelow = viewportHeight - anchor.bottom - offset - viewportPadding;
const roomAbove = anchor.top - offset - viewportPadding;
const requestedTop = placement.startsWith("top");
const shouldFlip = requestedTop
? surfaceHeight > roomAbove && roomBelow > roomAbove
: surfaceHeight > roomBelow && roomAbove > roomBelow;
const resolvedPlacement = shouldFlip
? (`${requestedTop ? "bottom" : "top"}-${placement.endsWith("end") ? "end" : "start"}` as FloatingPlacement)
: placement;
const alignedLeft = resolvedPlacement.endsWith("end")
? anchor.right - surfaceWidth
: anchor.left;
const left = Math.min(
Math.max(viewportPadding, alignedLeft),
Math.max(viewportPadding, viewportWidth - surfaceWidth - viewportPadding),
);
const availableHeight = resolvedPlacement.startsWith("top") ? roomAbove : roomBelow;
const maxHeight = Math.max(96, availableHeight);
const visibleHeight = Math.min(surfaceHeight, maxHeight);
const top = resolvedPlacement.startsWith("top")
? Math.max(viewportPadding, anchor.top - offset - visibleHeight)
: Math.min(anchor.bottom + offset, viewportHeight - visibleHeight - viewportPadding);
return { top, left, maxHeight, resolvedPlacement };
}

View File

@ -0,0 +1,4 @@
export * from "./accent";
export * from "./floating";
export * from "./theme";

View File

@ -0,0 +1,28 @@
import { createAccentVariables, type RgbTuple } from "./accent";
export type NodedcTheme = "dark" | "light";
export interface ApplyThemeOptions {
theme?: NodedcTheme;
accent?: RgbTuple;
}
export function applyNodedcTheme(
target: HTMLElement,
{ theme = "dark", accent }: ApplyThemeOptions = {},
): void {
target.dataset.nodedcTheme = theme;
target.dataset.nodedcUi = "true";
if (!accent) return;
const variables = createAccentVariables(accent);
Object.entries(variables).forEach(([property, value]) => {
target.style.setProperty(property, value);
});
}
export function clearNodedcAccent(target: HTMLElement): void {
target.style.removeProperty("--nodedc-accent-rgb");
target.style.removeProperty("--nodedc-on-accent-rgb");
}

1154
packages/ui-core/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,21 @@
{
"name": "@nodedc/ui-dom",
"version": "0.1.0",
"type": "module",
"files": ["dist"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/ui-core": "0.1.0"
}
}

View File

@ -0,0 +1,131 @@
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
export interface FloatingLayerOptions {
trigger: HTMLElement;
surface: HTMLElement;
placement?: FloatingPlacement;
offset?: number;
minWidth?: number;
matchTriggerWidth?: boolean;
closeOnSelect?: boolean;
onOpenChange?: (open: boolean) => void;
}
export interface FloatingLayerController {
open: () => void;
close: () => void;
toggle: () => void;
reposition: () => void;
isOpen: () => boolean;
destroy: () => void;
}
export function createFloatingLayer({
trigger,
surface,
placement = "bottom-start",
offset = 8,
minWidth = 180,
matchTriggerWidth = true,
closeOnSelect = false,
onOpenChange,
}: FloatingLayerOptions): FloatingLayerController {
let openState = false;
const originalParent = surface.parentNode;
const originalNextSibling = surface.nextSibling;
surface.hidden = true;
surface.classList.add("nodedc-dropdown-surface");
document.body.append(surface);
const reposition = () => {
if (!openState) return;
const anchor = trigger.getBoundingClientRect();
const measured = surface.getBoundingClientRect();
const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width);
const surfaceHeight = Math.max(1, surface.scrollHeight);
const position = computeFloatingPosition({ anchor, surfaceWidth, surfaceHeight, placement, offset });
Object.assign(surface.style, {
position: "fixed",
top: `${position.top}px`,
left: `${position.left}px`,
width: `${surfaceWidth}px`,
maxHeight: `${position.maxHeight}px`,
visibility: "visible",
});
};
const open = () => {
if (openState || trigger.hasAttribute("disabled")) return;
openState = true;
surface.hidden = false;
trigger.setAttribute("aria-expanded", "true");
window.dispatchEvent(new CustomEvent("nodedc-floating-open", { detail: { surface } }));
requestAnimationFrame(reposition);
onOpenChange?.(true);
};
const close = () => {
if (!openState) return;
openState = false;
surface.hidden = true;
trigger.setAttribute("aria-expanded", "false");
onOpenChange?.(false);
};
const toggle = () => {
if (openState) close();
else open();
};
const handleTriggerClick = (event: MouseEvent) => {
event.preventDefault();
toggle();
};
const handleDocumentPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node)) return;
if (!trigger.contains(event.target) && !surface.contains(event.target)) close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && openState) {
close();
trigger.focus();
}
};
const handleOtherOpen = (event: Event) => {
const otherSurface = (event as CustomEvent<{ surface?: HTMLElement }>).detail.surface;
if (otherSurface && otherSurface !== surface) close();
};
const handleSurfaceClick = (event: MouseEvent) => {
if (closeOnSelect && event.target instanceof HTMLElement && event.target.closest("[data-nodedc-option]")) close();
};
trigger.addEventListener("click", handleTriggerClick);
surface.addEventListener("click", handleSurfaceClick);
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
return {
open,
close,
toggle,
reposition,
isOpen: () => openState,
destroy: () => {
close();
trigger.removeEventListener("click", handleTriggerClick);
surface.removeEventListener("click", handleSurfaceClick);
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
if (originalParent) originalParent.insertBefore(surface, originalNextSibling);
surface.removeAttribute("style");
},
};
}

View File

@ -0,0 +1,5 @@
export { applyNodedcTheme, clearNodedcAccent, createAccentVariables, readableTextRgb } from "@nodedc/ui-core";
export * from "./floating";
export * from "./modal";
export * from "./select";

View File

@ -0,0 +1,117 @@
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export interface ModalControllerOptions {
overlay: HTMLElement;
dialog: HTMLElement;
closeControls?: Iterable<HTMLElement>;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
lockBodyScroll?: boolean;
onOpenChange?: (open: boolean) => void;
}
export interface ModalController {
open: () => void;
close: () => void;
isOpen: () => boolean;
destroy: () => void;
}
export function createModalController({
overlay,
dialog,
closeControls = [],
closeOnBackdrop = true,
closeOnEscape = true,
lockBodyScroll = true,
onOpenChange,
}: ModalControllerOptions): ModalController {
let openState = false;
let previousFocus: HTMLElement | null = null;
let previousOverflow = "";
const controls = Array.from(closeControls);
overlay.classList.add("nodedc-overlay", "nodedc-ui-root");
dialog.classList.add("nodedc-window");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.tabIndex = -1;
overlay.hidden = true;
const focusFirst = () => {
const first = dialog.querySelector<HTMLElement>(focusableSelector);
(first ?? dialog).focus();
};
const open = () => {
if (openState) return;
openState = true;
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
previousOverflow = document.body.style.overflow;
if (lockBodyScroll) document.body.style.overflow = "hidden";
overlay.hidden = false;
requestAnimationFrame(focusFirst);
onOpenChange?.(true);
};
const close = () => {
if (!openState) return;
openState = false;
overlay.hidden = true;
if (lockBodyScroll) document.body.style.overflow = previousOverflow;
previousFocus?.focus();
onOpenChange?.(false);
};
const handleOverlayPointerDown = (event: PointerEvent) => {
if (closeOnBackdrop && event.target === overlay) close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!openState) return;
if (event.key === "Escape" && closeOnEscape) {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector));
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
overlay.addEventListener("pointerdown", handleOverlayPointerDown);
document.addEventListener("keydown", handleKeyDown);
controls.forEach((control) => control.addEventListener("click", close));
return {
open,
close,
isOpen: () => openState,
destroy: () => {
close();
overlay.removeEventListener("pointerdown", handleOverlayPointerDown);
document.removeEventListener("keydown", handleKeyDown);
controls.forEach((control) => control.removeEventListener("click", close));
},
};
}

View File

@ -0,0 +1,67 @@
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating";
export interface SelectControllerOptions<T extends string> extends Omit<FloatingLayerOptions, "closeOnSelect"> {
value: T;
onChange: (value: T) => void;
valueTarget?: HTMLElement;
}
export interface SelectController<T extends string> extends FloatingLayerController {
getValue: () => T;
setValue: (value: T, emit?: boolean) => void;
}
export function createSelectController<T extends string>({
value,
onChange,
valueTarget,
surface,
trigger,
...floatingOptions
}: SelectControllerOptions<T>): SelectController<T> {
let currentValue = value;
const floating = createFloatingLayer({
trigger,
surface,
closeOnSelect: true,
...floatingOptions,
});
const options = () => Array.from(surface.querySelectorAll<HTMLElement>("[data-nodedc-option][data-nodedc-value]"));
const sync = () => {
options().forEach((option) => {
const selected = option.dataset.nodedcValue === currentValue;
option.dataset.selected = selected ? "true" : "false";
option.setAttribute("aria-selected", String(selected));
if (selected && valueTarget) valueTarget.textContent = option.dataset.nodedcLabel ?? option.textContent ?? currentValue;
});
};
const setValue = (nextValue: T, emit = true) => {
currentValue = nextValue;
sync();
if (emit) onChange(nextValue);
};
const handleOptionClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) return;
const option = event.target.closest<HTMLElement>("[data-nodedc-option][data-nodedc-value]");
if (!option || option.hasAttribute("disabled")) return;
setValue(option.dataset.nodedcValue as T);
};
surface.addEventListener("click", handleOptionClick);
sync();
return {
...floating,
getValue: () => currentValue,
setValue,
destroy: () => {
surface.removeEventListener("click", handleOptionClick);
floating.destroy();
},
};
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,31 @@
{
"name": "@nodedc/ui-react",
"version": "0.1.0",
"type": "module",
"files": ["dist"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/ui-core": "0.1.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}

View File

@ -0,0 +1,56 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
brand: ReactNode;
brandHref?: string;
brandLabel?: string;
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
}
export function AppHeader({
brand,
brandHref,
brandLabel = "NODE.DC",
left,
center,
right,
className,
...props
}: AppHeaderProps) {
const brandNode = brandHref ? (
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
) : (
<span className="nodedc-header__brand" aria-label={brandLabel}>{brand}</span>
);
return (
<header className={cn("nodedc-header-shell", className)} {...props}>
<div className="nodedc-header">
<div className="nodedc-header__left">{brandNode}{left}</div>
<div className="nodedc-header__center">{center}</div>
<div className="nodedc-header__right">{right}</div>
</div>
</header>
);
}
export function HeaderProfile({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-header__profile", className)} {...props}>{children}</div>;
}
export interface HeaderWorkspaceProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
imageUrl?: string;
}
export function HeaderWorkspace({ label, imageUrl, className, ...props }: HeaderWorkspaceProps) {
return (
<span className={cn("nodedc-header__workspace", className)} title={label} {...props}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);
}

View File

@ -0,0 +1,71 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
width?: "auto" | "full";
accent?: RgbTuple;
icon?: ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
variant = "secondary",
size = "default",
width = "auto",
accent,
icon,
className,
children,
style,
type = "button",
...props
}, ref) {
const accentStyle = accent ? createAccentVariables(accent) : undefined;
return (
<button
ref={ref}
type={type}
className={cn("nodedc-button", className)}
data-variant={variant}
data-size={size === "default" ? undefined : size}
data-width={width === "auto" ? undefined : width}
style={accentStyle ? { ...accentStyle, ...style } : style}
{...props}
>
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
{children}
</button>
);
});
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
shape?: "circle" | "rounded";
}
export function IconButton({
label,
shape = "circle",
className,
children,
type = "button",
...props
}: IconButtonProps) {
return (
<button
type={type}
className={cn("nodedc-icon-button", className)}
data-shape={shape === "circle" ? undefined : shape}
aria-label={label}
title={label}
{...props}
>
{children}
</button>
);
}

View File

@ -0,0 +1,41 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./cn";
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;
label: string;
description?: string;
onChange: (checked: boolean) => void;
}
export function Checker({
checked,
label,
description,
onChange,
disabled,
className,
type = "button",
...props
}: CheckerProps) {
return (
<button
type={type}
className={cn("nodedc-checker", className)}
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => {
if (!disabled) onChange(!checked);
}}
{...props}
>
<span className="nodedc-checker__copy">
<span className="nodedc-checker__label">{label}</span>
{description ? <span className="nodedc-checker__description">{description}</span> : null}
</span>
<span className="nodedc-checker__indicator" aria-hidden="true" />
</button>
);
}

View File

@ -0,0 +1,76 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "./Button";
import { Window, WindowFooterActions } from "./Window";
export interface ConfirmationModalProps {
open: boolean;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
danger?: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}
export function ConfirmationModal({
open,
title,
description,
confirmLabel = "Подтвердить",
cancelLabel = "Отмена",
pendingLabel = "Выполняется",
danger = false,
onClose,
onConfirm,
}: ConfirmationModalProps) {
const [pending, setPending] = useState(false);
const mounted = useRef(true);
useEffect(() => () => {
mounted.current = false;
}, []);
useEffect(() => {
if (!open) setPending(false);
}, [open]);
const handleConfirm = async () => {
if (pending) return;
setPending(true);
try {
await onConfirm();
} finally {
if (mounted.current) setPending(false);
}
};
return (
<Window
open={open}
title={title}
size="sm"
closeOnBackdrop={!pending}
closeOnEscape={!pending}
onClose={onClose}
footer={
<>
<span />
<WindowFooterActions>
<Button disabled={pending} onClick={onClose}>{cancelLabel}</Button>
<Button variant={danger ? "danger" : "accent"} disabled={pending} onClick={handleConfirm}>
{pending ? pendingLabel : confirmLabel}
</Button>
</WindowFooterActions>
</>
}
>
<div className="nodedc-confirmation">
<span className="nodedc-confirmation__icon" aria-hidden="true">!</span>
<div className="nodedc-confirmation__copy">{description}</div>
</div>
</Window>
);
}

View File

@ -0,0 +1,148 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
import { cn } from "./cn";
export interface DropdownTriggerApi {
open: boolean;
show: () => void;
close: () => void;
toggle: () => void;
setTriggerRef: (node: HTMLElement | null) => void;
surfaceId: string;
}
export interface DropdownProps {
trigger: (api: DropdownTriggerApi) => ReactNode;
children: ReactNode | ((api: { close: () => void }) => ReactNode);
placement?: FloatingPlacement;
minWidth?: number;
width?: number | "anchor";
offset?: number;
disabled?: boolean;
className?: string;
surfaceClassName?: string;
surfaceRole?: "menu" | "listbox" | "dialog";
}
export function Dropdown({
trigger,
children,
placement = "bottom-start",
minWidth = 180,
width = "anchor",
offset = 8,
disabled = false,
className,
surfaceClassName,
surfaceRole = "menu",
}: DropdownProps) {
const instanceId = useId();
const surfaceId = `${instanceId.replaceAll(":", "")}-surface`;
const [isOpen, setIsOpen] = useState(false);
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
const surfaceRef = useRef<HTMLDivElement>(null);
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ visibility: "hidden" });
const close = useCallback(() => setIsOpen(false), []);
const show = useCallback(() => {
if (!disabled) setIsOpen(true);
}, [disabled]);
const toggle = useCallback(() => {
if (!disabled) setIsOpen((current) => !current);
}, [disabled]);
const updatePosition = useCallback(() => {
if (!triggerElement || !surfaceRef.current) return;
const anchor = triggerElement.getBoundingClientRect();
const measured = surfaceRef.current.getBoundingClientRect();
const surfaceWidth = typeof width === "number"
? width
: Math.max(minWidth, anchor.width, measured.width);
const surfaceHeight = Math.max(1, surfaceRef.current.scrollHeight);
const position = computeFloatingPosition({
anchor,
surfaceWidth,
surfaceHeight,
placement,
offset,
});
setSurfaceStyle({
top: position.top,
left: position.left,
width: surfaceWidth,
maxHeight: position.maxHeight,
visibility: "visible",
});
}, [minWidth, offset, placement, triggerElement, width]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePosition();
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (triggerElement?.contains(target) || surfaceRef.current?.contains(target)) return;
close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close();
triggerElement?.focus();
}
};
const handleOtherOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail.id !== instanceId) close();
};
const handleViewportChange = () => updatePosition();
window.dispatchEvent(new CustomEvent("nodedc-dropdown-open", { detail: { id: instanceId } }));
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [close, instanceId, isOpen, triggerElement, updatePosition]);
return (
<span className={cn("nodedc-dropdown-anchor", className)}>
{trigger({ open: isOpen, show, close, toggle, setTriggerRef: setTriggerElement, surfaceId })}
{isOpen && typeof document !== "undefined"
? createPortal(
<div
ref={surfaceRef}
id={surfaceId}
className={cn("nodedc-dropdown-surface", surfaceClassName)}
role={surfaceRole}
style={surfaceStyle}
>
{typeof children === "function" ? children({ close }) : children}
</div>,
document.body,
)
: null}
</span>
);
}

View File

@ -0,0 +1,75 @@
import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from "react";
import { cn } from "./cn";
export interface FieldFrameProps {
label: string;
hint?: string;
description?: string;
htmlFor?: string;
children: ReactNode;
className?: string;
}
export function FieldFrame({ label, hint, description, htmlFor, children, className }: FieldFrameProps) {
return (
<label className={cn("nodedc-field", className)} htmlFor={htmlFor}>
<span className="nodedc-field__label-row">
<span className="nodedc-field__label">{label}</span>
{hint ? <span className="nodedc-field__hint">{hint}</span> : null}
</span>
{children}
{description ? <span className="nodedc-field__description">{description}</span> : null}
</label>
);
}
export interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
hint?: string;
description?: string;
fieldClassName?: string;
}
export function TextField({
label,
hint,
description,
fieldClassName,
className,
id,
...props
}: TextFieldProps) {
const generatedId = useId();
const controlId = id ?? generatedId;
return (
<FieldFrame label={label} hint={hint} description={description} htmlFor={controlId} className={fieldClassName}>
<input id={controlId} className={cn("nodedc-field__control", className)} {...props} />
</FieldFrame>
);
}
export interface TextAreaFieldProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string;
hint?: string;
description?: string;
fieldClassName?: string;
}
export function TextAreaField({
label,
hint,
description,
fieldClassName,
className,
id,
...props
}: TextAreaFieldProps) {
const generatedId = useId();
const controlId = id ?? generatedId;
return (
<FieldFrame label={label} hint={hint} description={description} htmlFor={controlId} className={fieldClassName}>
<textarea id={controlId} className={cn("nodedc-field__control", className)} {...props} />
</FieldFrame>
);
}

View File

@ -0,0 +1,37 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal";
export type GlassPadding = "none" | "sm" | "md" | "lg";
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
tone?: GlassTone;
radius?: GlassRadius;
padding?: GlassPadding;
materialRim?: boolean;
}
export function GlassSurface({
children,
className,
tone = "default",
radius = "card",
padding = "none",
materialRim = true,
...props
}: GlassSurfaceProps) {
return (
<div
className={cn("nodedc-glass", materialRim && "nodedc-material-rim", className)}
data-tone={tone}
data-radius={radius}
data-padding={padding === "none" ? undefined : padding}
{...props}
>
{children}
</div>
);
}

View File

@ -0,0 +1,101 @@
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "./cn";
export interface InspectorSectionSpec {
id: string;
label: string;
description?: string;
group?: string;
content: ReactNode;
disabled?: boolean;
}
export interface InspectorProps {
sections: InspectorSectionSpec[];
defaultOpen?: string[];
activeId?: string;
singleOpen?: boolean;
className?: string;
onActiveChange?: (id: string) => void;
}
export function Inspector({
sections,
defaultOpen = [],
activeId,
singleOpen = false,
className,
onActiveChange,
}: InspectorProps) {
const [openIds, setOpenIds] = useState(() => new Set(defaultOpen));
const groups = useMemo(() => {
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
sections.forEach((section) => {
const previous = result[result.length - 1];
if (previous && previous.label === section.group) {
previous.sections.push(section);
} else {
result.push({ label: section.group, sections: [section] });
}
});
return result;
}, [sections]);
const toggle = (id: string) => {
setOpenIds((current) => {
const next = singleOpen ? new Set<string>() : new Set(current);
if (current.has(id)) next.delete(id);
else next.add(id);
return next;
});
onActiveChange?.(id);
};
return (
<div className={cn("nodedc-inspector", className)}>
{groups.map((group, groupIndex) => (
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
{group.sections.map((section) => {
const isOpen = openIds.has(section.id);
return (
<section className="nodedc-inspector__section" key={section.id}>
<button
type="button"
className="nodedc-inspector__section-trigger"
data-open={isOpen ? "true" : undefined}
data-active={activeId === section.id ? "true" : undefined}
aria-expanded={isOpen}
disabled={section.disabled}
onClick={() => toggle(section.id)}
>
<span className="nodedc-inspector__section-label">{section.label}</span>
{section.description ? (
<span className="nodedc-inspector__section-description">{section.description}</span>
) : null}
</button>
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
</section>
);
})}
</div>
))}
</div>
);
}
export interface ControlRowProps {
label: ReactNode;
children: ReactNode;
layout?: "inline" | "stack";
className?: string;
}
export function ControlRow({ label, children, layout = "inline", className }: ControlRowProps) {
return (
<div className={cn("nodedc-control-row", className)} data-layout={layout === "inline" ? undefined : layout}>
<div className="nodedc-control-row__label">{label}</div>
<div className="nodedc-control-row__control">{children}</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import type { CSSProperties, InputHTMLAttributes } from "react";
import { cn } from "./cn";
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string;
value: number;
min: number;
max: number;
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
export function RangeControl({
label,
value,
min,
max,
step,
formatValue = String,
onChange,
className,
...props
}: RangeControlProps) {
const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
return (
<label className={cn("nodedc-range", className)} style={style}>
<input
type="range"
value={value}
min={min}
max={safeMax}
step={step}
aria-label={label}
onChange={(event) => onChange(Number(event.target.value))}
{...props}
/>
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
<span className="nodedc-range__fill-text" aria-hidden="true">
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
</span>
</label>
);
}
export interface ColorFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
value: string;
onChange: (value: string) => void;
label?: string;
}
export function ColorField({ value, onChange, label = "Цвет", className, ...props }: ColorFieldProps) {
const style = { "--nodedc-color-value": value } as CSSProperties;
return (
<div className={cn("nodedc-color-field", className)} style={style}>
<label className="nodedc-color-field__picker" aria-label={label}>
<input type="color" value={value} onChange={(event) => onChange(event.target.value)} {...props} />
</label>
<input
className="nodedc-color-field__text"
value={value}
aria-label={`${label}: HEX`}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
}

View File

@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
export interface SegmentedItem<T extends string> {
value: T;
label: string;
icon?: ReactNode;
disabled?: boolean;
}
export interface SegmentedControlProps<T extends string> {
value: T;
items: Array<SegmentedItem<T>>;
label: string;
className?: string;
onChange: (value: T) => void;
}
export function SegmentedControl<T extends string>({
value,
items,
label,
className,
onChange,
}: SegmentedControlProps<T>) {
return (
<div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
{items.map((item) => (
<button
key={item.value}
type="button"
className="nodedc-segmented__item"
role="tab"
aria-selected={item.value === value}
data-active={item.value === value ? "true" : undefined}
disabled={item.disabled}
onClick={() => onChange(item.value)}
>
{item.icon}
{item.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import type { FloatingPlacement } from "@nodedc/ui-core";
import { Dropdown } from "./Dropdown";
import { cn } from "./cn";
export interface SelectOption<T extends string> {
value: T;
label: string;
description?: string;
icon?: ReactNode;
disabled?: boolean;
}
export interface SelectProps<T extends string> {
value: T;
options: Array<SelectOption<T>>;
label: string;
onChange: (value: T, option: SelectOption<T>) => void;
searchable?: boolean;
searchPlaceholder?: string;
emptyLabel?: string;
placement?: FloatingPlacement;
minMenuWidth?: number;
menuWidth?: number | "anchor";
disabled?: boolean;
className?: string;
triggerClassName?: string;
menuClassName?: string;
}
export function Select<T extends string>({
value,
options,
label,
onChange,
searchable = false,
searchPlaceholder = "Поиск",
emptyLabel = "Ничего не найдено",
placement = "bottom-start",
minMenuWidth = 180,
menuWidth = "anchor",
disabled = false,
className,
triggerClassName,
menuClassName,
}: SelectProps<T>) {
const [query, setQuery] = useState("");
const selected = options.find((option) => option.value === value) ?? options[0];
const visibleOptions = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return options;
return options.filter((option) => `${option.label} ${option.description ?? ""}`.toLocaleLowerCase().includes(normalized));
}, [options, query]);
return (
<Dropdown
className={className}
placement={placement}
minWidth={minMenuWidth}
width={menuWidth}
disabled={disabled}
surfaceRole="listbox"
surfaceClassName={menuClassName}
trigger={({ open, toggle, setTriggerRef, surfaceId }) => {
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!open) toggle();
}
};
return (
<button
ref={setTriggerRef}
type="button"
className={cn("nodedc-select-trigger", triggerClassName)}
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ?? null}
<span className="nodedc-select-trigger__label">{selected?.label ?? "—"}</span>
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
</button>
);
}}
>
{({ close }) => (
<>
{searchable ? (
<label className="nodedc-dropdown-search">
<span aria-hidden="true"></span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} />
</label>
) : null}
{visibleOptions.map((option) => (
<button
key={option.value}
type="button"
className="nodedc-dropdown-option"
role="option"
aria-selected={option.value === value}
data-selected={option.value === value ? "true" : undefined}
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
onChange(option.value, option);
setQuery("");
close();
}}
>
{option.icon ? <span className="nodedc-dropdown-option__icon">{option.icon}</span> : <span />}
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{option.label}</span>
{option.description ? <span className="nodedc-dropdown-option__description">{option.description}</span> : null}
</span>
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true"></span> : null}
</button>
))}
{visibleOptions.length === 0 ? <div className="nodedc-empty-state">{emptyLabel}</div> : null}
</>
)}
</Dropdown>
);
}

View File

@ -0,0 +1,17 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent";
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: StatusTone;
}
export function StatusBadge({ tone = "neutral", className, children, ...props }: StatusBadgeProps) {
return (
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
{children}
</span>
);
}

View File

@ -0,0 +1,143 @@
import {
useEffect,
useId,
useRef,
type HTMLAttributes,
type PointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn";
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export type WindowSize = "sm" | "md" | "lg";
export type WindowPlacement = "center" | "end";
export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
open: boolean;
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
footer?: ReactNode;
size?: WindowSize;
placement?: WindowPlacement;
closeLabel?: string;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
lockBodyScroll?: boolean;
onClose: () => void;
}
export function Window({
open,
title,
subtitle,
children,
footer,
size = "md",
placement = "center",
closeLabel = "Закрыть",
closeOnBackdrop = true,
closeOnEscape = true,
lockBodyScroll = true,
onClose,
className,
...props
}: WindowProps) {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open || typeof document === "undefined") return;
const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const previousOverflow = document.body.style.overflow;
if (lockBodyScroll) document.body.style.overflow = "hidden";
const frame = window.requestAnimationFrame(() => {
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(focusableSelector);
(firstFocusable ?? dialogRef.current)?.focus();
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && closeOnEscape) {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(focusableSelector));
if (focusable.length === 0) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
window.cancelAnimationFrame(frame);
document.removeEventListener("keydown", handleKeyDown);
if (lockBodyScroll) document.body.style.overflow = previousOverflow;
previousActiveElement?.focus();
};
}, [closeOnEscape, lockBodyScroll, onClose, open]);
if (!open || typeof document === "undefined") return null;
const handleBackdropPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (closeOnBackdrop && event.target === event.currentTarget) onClose();
};
return createPortal(
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
<div
ref={dialogRef}
className={cn("nodedc-window nodedc-material-rim", className)}
data-size={size === "md" ? undefined : size}
data-placement={placement}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? descriptionId : undefined}
tabIndex={-1}
{...props}
>
<header className="nodedc-window__head">
<div className="nodedc-window__titles">
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
</div>
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
<span className="nodedc-close-mark" aria-hidden="true" />
</button>
</header>
<div className="nodedc-window__body">{children}</div>
{footer ? <footer className="nodedc-window__footer">{footer}</footer> : null}
</div>
</div>,
document.body,
);
}
export function WindowFooterActions({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-window__footer-actions", className)} {...props}>{children}</div>;
}

View File

@ -0,0 +1,4 @@
export function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(" ");
}

View File

@ -0,0 +1,14 @@
export * from "./AppHeader";
export * from "./Button";
export * from "./Checker";
export * from "./ConfirmationModal";
export * from "./Dropdown";
export * from "./Field";
export * from "./Glass";
export * from "./Inspector";
export * from "./RangeControl";
export * from "./SegmentedControl";
export * from "./Select";
export * from "./StatusBadge";
export * from "./Window";

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

127
registry/candidates.json Normal file
View File

@ -0,0 +1,127 @@
{
"schemaVersion": "1.0.0",
"purpose": "Known reusable UI inventory that is recorded but not yet promised as a stable package export.",
"candidates": [
{
"id": "calendar-popover",
"classification": "planned-primitive",
"priority": "P1",
"referenceSources": ["launcher", "task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["dropdown", "button", "field"],
"requiredBehavior": ["portal", "locale-aware labels", "single date", "date range", "keyboard navigation"],
"reasonNotBaseline": "Current Launcher and Task Manager implementations must be reconciled before one API is declared canonical."
},
{
"id": "profile-menu",
"classification": "planned-pattern",
"priority": "P1",
"referenceSources": ["launcher", "task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["dropdown", "button", "status-badge"],
"requiredBehavior": ["avatar trigger", "identity summary", "role/workspace switch", "logout action"],
"reasonNotBaseline": "Identity and role data contracts differ between Hub and Ops and must remain application-owned."
},
{
"id": "action-dropdown",
"classification": "planned-pattern",
"priority": "P1",
"referenceSources": ["task-manager", "launcher"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["dropdown"],
"requiredBehavior": ["commands", "disabled rows", "danger row", "optional groups", "keyboard navigation"],
"reasonNotBaseline": "Floating behavior is stable; command-row API still needs validation against Ops consumers."
},
{
"id": "data-table",
"classification": "planned-pattern",
"priority": "P1",
"referenceSources": ["launcher", "task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["button", "select", "status-badge", "field"],
"requiredBehavior": ["toolbar", "responsive overflow", "row actions", "editable cells", "loading", "empty state"],
"reasonNotBaseline": "The library should own presentation and interaction slots, not adopt Launcher entity schemas or Plane table models."
},
{
"id": "side-navigation",
"classification": "planned-pattern",
"priority": "P1",
"referenceSources": ["launcher", "engine"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["glass-surface", "button", "status-badge"],
"requiredBehavior": ["sections", "active item", "optional nested groups", "compact viewport"],
"reasonNotBaseline": "Inspector accordion is implemented; application-level navigation requires a separate route-aware contract."
},
{
"id": "avatar-stack",
"classification": "planned-primitive",
"priority": "P1",
"referenceSources": ["engine", "task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["dropdown"],
"requiredBehavior": ["overflow count", "presence", "picker mode", "fallback initials"],
"reasonNotBaseline": "Engine picker behavior and Task Manager presence semantics must not be coupled accidentally."
},
{
"id": "service-rail-card",
"classification": "product-pattern",
"priority": "P2",
"referenceSources": ["launcher"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["glass-surface", "status-badge", "button"],
"requiredBehavior": ["ambient media", "access status", "selected state", "open/request action"],
"reasonNotBaseline": "Likely reusable for product catalogs, but current contract is tightly connected to Launcher service data."
},
{
"id": "work-item-card",
"classification": "product-pattern",
"priority": "P2",
"referenceSources": ["task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["glass-surface", "avatar-stack", "status-badge", "action-dropdown"],
"requiredBehavior": ["selected state", "drag state", "assignees", "dates", "quick actions"],
"reasonNotBaseline": "The visual pattern is mature, but task-domain fields must be separated from the reusable card shell."
},
{
"id": "media-source-field",
"classification": "planned-pattern",
"priority": "P2",
"referenceSources": ["launcher", "seo"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["field", "button", "glass-surface"],
"requiredBehavior": ["preview", "source picker", "validation", "clear/replace"],
"reasonNotBaseline": "File and storage integration remains application-specific and needs adapter slots."
},
{
"id": "toast",
"classification": "missing-primitive",
"priority": "P2",
"referenceSources": ["task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["status-badge", "button"],
"requiredBehavior": ["stack", "timeout", "persistent error", "action", "screen-reader announcement"],
"reasonNotBaseline": "No cross-application contract has been audited yet."
},
{
"id": "tooltip",
"classification": "missing-primitive",
"priority": "P2",
"referenceSources": ["launcher", "task-manager", "engine"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["dropdown"],
"requiredBehavior": ["hover", "focus", "delay", "viewport placement"],
"reasonNotBaseline": "Needs a small dedicated accessibility contract rather than reuse of an application tooltip."
},
{
"id": "empty-loading-error",
"classification": "planned-pattern",
"priority": "P2",
"referenceSources": ["launcher", "seo", "task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["button", "status-badge"],
"requiredBehavior": ["empty message", "retry", "loading progress", "fatal error"],
"reasonNotBaseline": "Copy and domain actions vary; the layout contract still needs examples from all product families."
}
]
}

168
registry/components.json Normal file
View File

@ -0,0 +1,168 @@
{
"schemaVersion": "1.0.0",
"components": [
{
"id": "glass-surface",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["GlassSurface"],
"domContract": ["nodedc-glass", "nodedc-material-rim"],
"summary": "Canonical matte/glossy glass surface for cards, panels and window shells.",
"anatomy": ["theme-provided surface", "optional material rim", "backdrop blur", "content"],
"variants": ["default", "strong", "soft"],
"rules": [
"Geometry is invariant across application themes.",
"A material rim is a glass highlight, not a hard product-colored border.",
"Nested surfaces must use a softer tone to avoid card-inside-card noise."
]
},
{
"id": "button",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Button", "IconButton"],
"domContract": ["nodedc-button", "nodedc-icon-button"],
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
"rules": [
"Icon-only actions are circular by default.",
"Filled accent actions derive foreground contrast from the actual accent.",
"Destructive actions remain neutral until confirmation unless danger is the principal message."
]
},
{
"id": "field",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["FieldFrame", "TextField", "TextAreaField"],
"domContract": ["nodedc-field", "nodedc-field__control"],
"summary": "Textual form controls with consistent labels, hints, geometry and focus surface.",
"rules": [
"Labels and validation copy are part of the field contract.",
"Keyboard focus changes the surface and remains visible without a browser-blue outline."
]
},
{
"id": "checker",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Checker"],
"domContract": ["nodedc-checker"],
"summary": "Binary setting control derived from the redesigned Engine environment settings.",
"rules": [
"The visual anchor is circular, not a square native checkbox.",
"The control exposes checkbox semantics through role and aria-checked."
]
},
{
"id": "range-control",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["RangeControl"],
"domContract": ["nodedc-range"],
"summary": "Filled pill range control with embedded label and value from redesigned Engine settings.",
"rules": [
"The accent fill follows the active application theme.",
"The native range remains the accessible input while its chrome is visually replaced."
]
},
{
"id": "color-field",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ColorField"],
"domContract": ["nodedc-color-field"],
"summary": "Combined color picker and textual value for settings/inspectors."
},
{
"id": "dropdown",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Dropdown"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createFloatingLayer"],
"domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"],
"summary": "Shared portal/fixed floating layer for action and selection menus.",
"behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close", "scroll and resize reposition", "one open dropdown per window"],
"rules": [
"Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.",
"Action menus and selection menus share floating behavior even when their row content differs."
]
},
{
"id": "select",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Select"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createSelectController"],
"summary": "Theme-independent selection control using the canonical dropdown layer.",
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"]
},
{
"id": "window",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Window", "WindowFooterActions"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createModalController"],
"domContract": ["nodedc-overlay", "nodedc-window"],
"summary": "Canonical controlled window for modal and right-side panel presentations.",
"behavior": ["portal to body", "focus entry", "focus trap", "focus restore", "Escape close", "backdrop close policy", "body scroll lock"],
"rules": [
"Open state belongs to the application; focus/layer behavior belongs to the component.",
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences."
]
},
{
"id": "confirmation-modal",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ConfirmationModal"],
"summary": "Async-safe confirmation window for destructive and consequential operations.",
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
},
{
"id": "segmented-control",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["SegmentedControl"],
"domContract": ["nodedc-segmented"],
"summary": "Pill navigation used in the shared top header and compact mode switches."
},
{
"id": "app-header",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["AppHeader", "HeaderProfile", "HeaderWorkspace"],
"domContract": ["nodedc-header-shell", "nodedc-header"],
"summary": "Three-axis NODE.DC application header with fixed brand geometry and composable center/right slots.",
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
"rules": [
"The logo occupies the same visual position and geometry across applications.",
"Product routes and profile content are application data passed into stable slots."
]
},
{
"id": "status-badge",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["StatusBadge"],
"domContract": ["nodedc-status"],
"summary": "Semantic status pill whose tone is independent from the application accent."
},
{
"id": "inspector",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Inspector", "ControlRow"],
"domContract": ["nodedc-inspector", "nodedc-control-row"],
"summary": "Accordion settings/definition inspector derived only from the redesigned Engine environment and NDC agent inspector.",
"rules": [
"Legacy Engine inspectors are not reference implementations.",
"Domain-specific field definitions remain in Engine; reusable layout and controls live here."
]
}
]
}

42
registry/registry.json Normal file
View File

@ -0,0 +1,42 @@
{
"schemaVersion": "1.0.0",
"libraryVersion": "0.1.0",
"name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [
{
"name": "@nodedc/tokens",
"path": "packages/tokens",
"role": "portable tokens and theme variables"
},
{
"name": "@nodedc/ui-core",
"path": "packages/ui-core",
"role": "framework-neutral styles, contrast and floating geometry"
},
{
"name": "@nodedc/ui-react",
"path": "packages/ui-react",
"role": "React component implementation"
},
{
"name": "@nodedc/ui-dom",
"path": "packages/ui-dom",
"role": "DOM controllers for non-React applications"
}
],
"sourceRegistry": "sources.json",
"componentRegistry": "components.json",
"candidateRegistry": "candidates.json",
"documentation": {
"architecture": "../docs/ARCHITECTURE.md",
"components": "../docs/COMPONENTS.md",
"theming": "../docs/THEMING.md",
"windows": "../docs/WINDOWS_AND_LAYERS.md",
"baseline": "../docs/SOURCE_BASELINE.md",
"governance": "../docs/GOVERNANCE.md",
"adoption": "../docs/ADOPTION.md",
"candidates": "../docs/CANDIDATES.md"
},
"operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy."
}

107
registry/sources.json Normal file
View File

@ -0,0 +1,107 @@
{
"schemaVersion": "1.0.0",
"auditedAt": "2026-07-10",
"sources": [
{
"id": "launcher",
"repository": "https://git.dcserve.ru/SILVER/NODEDC_LAUNCHER.git",
"auditedRevision": "332e097",
"role": "primary reference for Hub shell, header, glass surfaces, modal and shared controls",
"approvedPaths": [
"dc-ui-guideline",
"src/shared/nodedc-ui",
"src/shared/ui",
"src/widgets/top-bar",
"src/widgets/admin-overlay",
"src/styles/globals.css"
],
"policy": "reference"
},
{
"id": "seo",
"repository": "https://git.dcserve.ru/SILVER/NODEDC_SEO.git",
"auditedRevision": "bcc3d9e",
"role": "same product family; confirms theme variation and common window/control mechanics",
"approvedPaths": [
"seo_mode/seo_mode/app/src/NdcGlassSelect.tsx",
"seo_mode/seo_mode/app/src/styles.css",
"seo_mode/seo_mode/SEO-farm/DESIGN_SYSTEM.md"
],
"policy": "reference-with-cleanup"
},
{
"id": "cms",
"repository": "https://git.dcserve.ru/SILVER/DC_CMS.git",
"auditedRevision": "4c6f133",
"role": "same product family; non-React consumer of header, window and control system",
"approvedPaths": [
"admin/admin.css",
"admin/admin.js",
"admin/nodedc-logo.svg"
],
"policy": "reference-with-cleanup"
},
{
"id": "engine",
"repository": "https://git.dcserve.ru/dctouch/NODEDC_ENGINE_INFRA.git",
"auditedRevision": "d427272c7e",
"role": "reference only for redesigned environment settings, glass controls and NDC agent inspector",
"approvedPaths": [
"nodedc-source/src/components/environment",
"nodedc-source/src/components/ui/NdcGlassChecker.tsx",
"nodedc-source/src/components/ui/NdcGlassModal.tsx",
"nodedc-source/src/components/ui/NdcGlassSelect.tsx",
"nodedc-source/src/driveinspector/NdcAgentInspector.tsx",
"nodedc-source/src/style/menu.css",
"nodedc-source/src/style/inspector.css"
],
"excludedPaths": [
"all other legacy node inspectors",
"legacy node plates and legacy controls not used by the environment settings or NDC agent inspector"
],
"policy": "strict-subset-reference"
},
{
"id": "task-manager",
"repository": "https://git.dcserve.ru/SILVER/NODEDC_TASKMANAGER.git",
"auditedRevision": "43e5f57",
"role": "behavior reference for already-standardized dropdowns, modal layers, headers and work-item patterns",
"approvedPaths": [
"HDESIGN-CODE.md",
"HDROPDOWN-CANON.md",
"design.config.json",
"plane-src/packages/ui/src/dropdowns/action-dropdown.tsx",
"plane-src/packages/ui/src/modals"
],
"excludedPaths": [
"legacy CustomMenu implementations",
"screen-local dropdown wrappers listed as technical debt"
],
"policy": "behavior-reference"
},
{
"id": "bim-viewer",
"repository": "https://git.dcserve.ru/SILVER/NODEDC_BIM_VIEWER.git",
"auditedRevision": "1a78dd0",
"role": "non-React integration reference and validation target",
"approvedPaths": [
"frontend/src/ui/glassSelect.js",
"frontend/src/ui/settingsMenu.js",
"frontend/src/ui/toolbar.js"
],
"policy": "integration-reference"
},
{
"id": "platform",
"repository": "https://git.dcserve.ru/SILVER/NODEDC_PLATFORM.git",
"auditedRevision": "d196d4b",
"role": "platform boundary reference; applications remain independent repositories",
"approvedPaths": [
"README.md",
"docs/ARCHITECTURE.md"
],
"policy": "architecture-context"
}
]
}

View File

@ -0,0 +1,56 @@
import { access, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const parse = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8"));
const registry = await parse("registry/registry.json");
const sources = await parse("registry/sources.json");
const components = await parse("registry/components.json");
const candidates = await parse("registry/candidates.json");
const failures = [];
const requireValue = (condition, message) => {
if (!condition) failures.push(message);
};
requireValue(registry.schemaVersion === "1.0.0", "registry schemaVersion must be 1.0.0");
requireValue(registry.libraryVersion, "registry libraryVersion is required");
requireValue(Array.isArray(registry.packages) && registry.packages.length >= 4, "registry must list all packages");
requireValue(Array.isArray(sources.sources) && sources.sources.length >= 6, "source audit is incomplete");
requireValue(Array.isArray(components.components) && components.components.length >= 10, "component registry is incomplete");
requireValue(Array.isArray(candidates.candidates) && candidates.candidates.length >= 10, "candidate registry is incomplete");
const ids = components.components.map((component) => component.id);
requireValue(new Set(ids).size === ids.length, "component ids must be unique");
const candidateIds = candidates.candidates.map((component) => component.id);
requireValue(new Set(candidateIds).size === candidateIds.length, "candidate ids must be unique");
requireValue(candidateIds.every((id) => !ids.includes(id)), "a component cannot be both baseline and candidate");
for (const component of components.components) {
requireValue(component.id && component.status && component.summary, `component entry is incomplete: ${component.id ?? "unknown"}`);
}
for (const pkg of registry.packages) {
try {
await access(resolve(root, pkg.path, "package.json"));
} catch {
failures.push(`missing package manifest: ${pkg.path}/package.json`);
}
}
for (const path of Object.values(registry.documentation)) {
try {
await access(resolve(root, "registry", path));
} catch {
failures.push(`missing documentation target: ${path}`);
}
}
if (failures.length > 0) {
console.error(failures.map((failure) => `- ${failure}`).join("\n"));
process.exit(1);
}
console.log(`Registry valid: ${components.components.length} components, ${candidates.candidates.length} candidates, ${sources.sources.length} audited sources.`);

19
tsconfig.base.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"jsx": "react-jsx"
}
}