Add Launcher application template and icon system

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 11:56:27 +03:00
parent 4a9ad01ea6
commit 9261d3af36
30 changed files with 1469 additions and 366 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## 0.3.0 — launcher application template
- Rebuilt the living catalog as a Launcher-style windowed application instead of a long documentation page.
- Added the fixed three-axis AppHeader, central media stage and reusable ApplicationShell/ApplicationPanel exports.
- Added Guideline navigation with separate expandable content windows and removed the product-specific bottom service rail.
- Added a responsive mobile panel/modal flow and verified it at a 390 × 844 viewport.
- Added the audited Icon component, 42 semantic glyphs, a living icon table and machine-readable icon registry.
- Reused the Launcher stage media as an optimized MP4 with a static mobile/poster fallback.
## 0.2.0 — source-faithful geometry
- Corrected the Hub header to the production three-axis shell with workspace, navigation and profile groups.

View File

@ -12,6 +12,8 @@
- модальные окна, подтверждения и боковые окна;
- segmented navigation и статусы;
- Hub header и левая admin navigation panel;
- launcher-style ApplicationShell без обязательной нижней витрины;
- аудированный общий набор иконок;
- инспектор/панель настроек нового поколения;
- поведение portal-слоёв, Escape, outside click и фокуса;
- контракт темы: приложения меняют цветовую схему, а не геометрию компонентов.
@ -47,12 +49,14 @@ npm run build
## Статус
Версия `0.2.x` — уточнённый baseline с точной геометрией Hub header/admin panel и новых Engine settings/agent inspector. Исходные приложения на первом этапе не изменяются.
Версия `0.3.x` — готовый launcher-style шаблон приложения, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются.
Подробности:
- [Архитектура](docs/ARCHITECTURE.md)
- [Компоненты](docs/COMPONENTS.md)
- [Шаблон приложения](docs/APPLICATION_TEMPLATE.md)
- [Иконки](docs/ICONS.md)
- [Темы](docs/THEMING.md)
- [Окна и слои](docs/WINDOWS_AND_LAYERS.md)
- [Границы исходного аудита](docs/SOURCE_BASELINE.md)

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-catalog",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@ -9,8 +9,8 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@nodedc/ui-core": "0.2.0",
"@nodedc/ui-react": "0.2.0",
"@nodedc/ui-core": "0.3.0",
"@nodedc/ui-react": "0.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

View File

@ -0,0 +1,4 @@
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<polygon fill="#e2e1e1" 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"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@ -3,6 +3,8 @@ import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-co
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
Checker,
ColorField,
@ -11,24 +13,24 @@ import {
Dropdown,
FieldFrame,
GlassSurface,
HeaderAvatar,
HeaderProfile,
HeaderProfileButton,
HeaderAvatar,
HeaderWorkspace,
Icon,
IconButton,
Inspector,
RangeControl,
SegmentedControl,
Select,
StatusBadge,
TextAreaField,
TextField,
Window,
WindowFooterActions,
type IconName,
} from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "windows" | "inspector";
type HubMode = "showcase" | "admin" | "platform";
type CatalogSection = "foundation" | "controls" | "windows" | "modals" | "inspector" | "icons";
const engineAccentStyle = {
"--nodedc-accent-rgb": "148 123 219",
@ -48,6 +50,123 @@ const selectOptions = [
{ value: "disabled", label: "Отключён", description: "Запуск запрещён" },
] as const;
const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = {
foundation: {
eyebrow: "01 / FOUNDATION",
title: "Основа",
description: "Тема, акцент, стекло и неизменяемая геометрия приложения.",
icon: "panel",
},
controls: {
eyebrow: "02 / CONTROLS",
title: "Контролы",
description: "Кнопки, поля, selection, Engine-настройки и понятные статусы операций.",
icon: "sliders",
},
windows: {
eyebrow: "03 / WINDOWS",
title: "Окна",
description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.",
icon: "apps",
},
modals: {
eyebrow: "04 / MODALS",
title: "Модалки",
description: "Создание, подтверждение и разрушительные действия в адаптивном формате.",
icon: "clipboard",
},
inspector: {
eyebrow: "05 / INSPECTOR",
title: "Инспектор",
description: "Точный новый Engine Environment Settings без legacy-инспекторов.",
icon: "settings",
},
icons: {
eyebrow: "06 / ICONS",
title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid",
},
};
const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{
title: "Окно и слой",
note: "Launcher / Hub",
icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"],
},
{
title: "Навигация",
note: "Launcher / Engine / BIM",
icons: ["chevron-left", "chevron-right", "chevron-down", "grid", "list", "sliders", "search"],
},
{
title: "Редактирование",
note: "Launcher / SEO",
icons: ["save", "edit", "trash", "copy", "upload", "download", "external"],
},
{
title: "Состояние и доступ",
note: "Вся платформа",
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
},
{
title: "Сущности",
note: "Общий словарь",
icons: ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"],
},
{
title: "Контент",
note: "SEO / BIM / Engine",
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
},
];
const iconLabels: Record<IconName, string> = {
activity: "Активность",
alert: "Предупреждение",
apps: "Приложения",
building: "Компания",
check: "Готово",
"chevron-down": "Раскрыть",
"chevron-left": "Назад",
"chevron-right": "Вперёд",
circle: "Маркер",
clipboard: "Список",
close: "Закрыть",
copy: "Копировать",
database: "База данных",
download: "Скачать",
edit: "Редактировать",
expand: "Развернуть",
external: "Открыть снаружи",
file: "Файл",
folder: "Папка",
globe: "Публичный контур",
grid: "Обзор",
image: "Изображение",
inbox: "Уведомления",
key: "Ключ",
list: "Список задач",
lock: "Закрыто",
mail: "Приглашение",
minimize: "Свернуть",
network: "Связи",
panel: "Панель",
plus: "Добавить",
profile: "Профиль",
refresh: "Обновить",
save: "Сохранить",
search: "Поиск",
settings: "Настройки",
shield: "Доступ",
sliders: "Параметры",
trash: "Удалить",
upload: "Загрузить",
users: "Участники",
video: "Видео",
};
function rgbFromHex(hex: string): RgbTuple | null {
const normalized = hex.trim().replace(/^#/, "");
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null;
@ -58,25 +177,6 @@ function rgbFromHex(hex: string): RgbTuple | null {
];
}
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;
@ -97,9 +197,9 @@ function Preview({ title, note, children, className }: {
export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66");
const [section, setSection] = useState<CatalogSection>("foundation");
const [hubMode, setHubMode] = useState<HubMode>("showcase");
const [adminSection, setAdminSection] = useState("invites");
const [guidelineOpen, setGuidelineOpen] = useState(false);
const [activeSection, setActiveSection] = useState<CatalogSection | null>(null);
const [panelExpanded, setPanelExpanded] = useState(true);
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
const [checker, setChecker] = useState(true);
const [brightness, setBrightness] = useState(49);
@ -110,8 +210,9 @@ export function CatalogApp() {
const [connectionColor, setConnectionColor] = useState("#404040");
const [fillColor, setFillColor] = useState("#8f83ff");
const [strokeColor, setStrokeColor] = useState("#2b2b36");
const [modalOpen, setModalOpen] = useState(false);
const [windowOpen, setWindowOpen] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [projectName, setProjectName] = useState("NODE.DC Platform");
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
@ -122,9 +223,16 @@ export function CatalogApp() {
applyNodedcTheme(document.documentElement, { theme, accent });
}, [accent, theme]);
const openSection = (next: CatalogSection) => {
setSection(next);
document.getElementById(next)?.scrollIntoView({ behavior: "smooth", block: "start" });
const openSection = (next: string) => {
const section = next as CatalogSection;
setGuidelineOpen(true);
setActiveSection(section);
setPanelExpanded(true);
};
const closeGuideline = () => {
setGuidelineOpen(false);
setActiveSection(null);
};
const inspectorSections = [
@ -161,9 +269,7 @@ export function CatalogApp() {
</ControlRow>
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
<ControlRow label="Применить ко всем">
<Button variant="accent" shape="pill" width="full">Применить</Button>
</ControlRow>
<ControlRow label="Применить ко всем"><Button variant="accent" shape="pill" width="full">Применить</Button></ControlRow>
</>
),
},
@ -182,102 +288,15 @@ export function CatalogApp() {
},
];
const renderSectionContent = () => {
switch (activeSection) {
case "foundation":
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="DC" />
<SegmentedControl
label="Навигация Hub"
value={hubMode}
items={[
{ value: "showcase", label: "Витрина" },
{ value: "admin", label: "Администрирование" },
{ value: "platform", label: "Платформа" },
]}
onChange={setHubMode}
/>
</>
}
right={
<HeaderProfile>
<IconButton label="Уведомления"></IconButton>
<HeaderProfileButton>Профиль</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>
}
/>
{hubMode === "admin" ? (
<div className="catalog-admin-layer">
<AdminNavigationPanel
title="Администрирование"
onClose={() => setHubMode("showcase")}
contexts={[
{
id: "public",
label: "Открытый контур",
description: "Public access pool",
icon: "◎",
},
{
id: "company",
groupLabel: "Компании",
label: "DCTOUCH",
description: "ООО ДИСТАЧ",
icon: "▥",
active: true,
onSelect: () => undefined,
onToggle: () => undefined,
},
]}
items={[
{ id: "overview", label: "Обзор", icon: "▦" },
{ id: "invites", label: "Инвайты", icon: "✉" },
{ id: "members", label: "Участники", icon: "♙" },
{ id: "access", label: "Доступы", icon: "⌁" },
{ id: "groups", label: "Группы", icon: "☷" },
]}
activeId={adminSection}
onItemChange={setAdminSection}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"></span>
<span>Root Admin</span>
</>
}
/>
</div>
) : null}
<main className="catalog-main" data-admin-open={hubMode === "admin" ? "true" : undefined}>
<div className="catalog-hero">
<StatusBadge tone="accent">NODE.DC UI baseline</StatusBadge>
<h1>Один UI-контракт.<br />Разные приложения.</h1>
<p>Геометрия, окна и поведение остаются общими. Приложение выбирает тему и акцент, затем использует готовые компоненты.</p>
<div className="catalog-section-nav">
<SegmentedControl
label="Разделы каталога"
value={section}
items={[
{ value: "foundation", label: "Основа" },
{ value: "controls", label: "Контролы" },
{ value: "windows", label: "Окна" },
{ value: "inspector", label: "Инспектор" },
]}
onChange={openSection}
/>
</div>
</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"}>
<Preview title="Тема приложения" 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>
<Button variant={theme === "dark" ? "accent" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
<Button variant={theme === "light" ? "accent" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
</div>
<div className="catalog-swatches">
{accents.map((item) => (
@ -295,34 +314,42 @@ export function CatalogApp() {
</div>
<ColorField label="Свой accent" value={accentHex} onChange={setAccentHex} />
</Preview>
<Preview title="Glass hierarchy" note="default / strong / soft">
<Preview title="Иерархия стекла" 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" shape="pill">Сохранить</Button>
<Button>Отмена</Button>
<Button variant="ghost">Подробнее</Button>
<Button variant="danger">Удалить</Button>
<IconButton label="Настройки"></IconButton>
<Preview title="Каркас Launcher" note="fixed header / stage / panels" className="catalog-preview--wide">
<div className="catalog-shell-diagram" aria-label="Схема каркаса приложения">
<span className="catalog-shell-diagram__header">Header</span>
<span className="catalog-shell-diagram__nav">Navigation</span>
<span className="catalog-shell-diagram__content">Content window</span>
<span className="catalog-shell-diagram__stage">Stage</span>
</div>
</Preview>
<Preview title="Fields" note="label / hint / control">
</div>
);
case "controls":
return (
<div className="catalog-grid">
<Preview title="Действия" note="общая геометрия">
<div className="catalog-inline catalog-inline--wrap">
<Button variant="accent" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
<Button icon={<Icon name="refresh" />}>Обновить</Button>
<Button variant="ghost" icon={<Icon name="external" />}>Открыть</Button>
<Button variant="danger" icon={<Icon name="trash" />}>Удалить</Button>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
</div>
</Preview>
<Preview title="Поля" 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">
<Preview title="Selection" note="integrated / split">
<div className="catalog-form">
<FieldFrame label="Hub / integrated select">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
@ -344,20 +371,20 @@ export function CatalogApp() {
</FieldFrame>
<Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} onClick={toggle}>Действия</Button>
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} 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>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><Icon name="edit" /><span className="nodedc-dropdown-option__label">Переименовать</span></button>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><Icon name="copy" /><span className="nodedc-dropdown-option__label">Дублировать</span></button>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><Icon name="folder" /><span className="nodedc-dropdown-option__label">Архивировать</span></button>
</>
)}
</Dropdown>
</div>
</Preview>
<Preview title="Environment controls" note="exact Engine geometry">
<Preview title="Environment controls" note="точная геометрия Engine">
<div className="catalog-engine-control-stack" style={engineAccentStyle}>
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
@ -365,47 +392,189 @@ export function CatalogApp() {
<ControlRow label="Применить ко всем"><Button variant="accent" shape="pill" width="full">Применить</Button></ControlRow>
</div>
</Preview>
<Preview title="Semantic status" note="not tied to accent" className="catalog-preview--wide">
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
<div className="catalog-inline catalog-inline--wrap">
<StatusBadge>Черновик</StatusBadge>
<StatusBadge tone="success">Готово</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 и modeless side panel используют общий portal, Escape и focus restore; trap и scroll lock остаются только у modal.">
<Preview title="Controlled windows" note="center / end / confirm">
);
case "windows":
return (
<div className="catalog-grid catalog-grid--single">
<Preview title="Управляемые окна" note="center / modeless end">
<p className="catalog-preview__explanation">Обычное окно блокирует фон и удерживает фокус. Правый инспектор не затемняет, не блокирует stage и не превращается в modal.</p>
<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>
<Button variant="accent" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть инспектор</Button>
</div>
</Preview>
</Section>
<Section id="inspector" eyebrow="04 / Pattern" title="Inspector / Settings" description="Канон основан только на новом Environment Settings и NDC Agent Inspector, без legacy-инспекторов Engine.">
<Preview title="Оконные действия" note="круг `46.72 px`">
<div className="catalog-window-actions-demo">
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
</Preview>
</div>
);
case "modals":
return (
<div className="catalog-grid">
<Preview title="Создание" note="форма адаптируется по ширине">
<p className="catalog-preview__explanation">Поля переходят в одну колонку на узком viewport; footer остаётся видимым, прокручивается только тело окна.</p>
<Button variant="accent" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(true)}>Создать проект</Button>
</Preview>
<Preview title="Подтверждение удаления" note="async-safe">
<p className="catalog-preview__explanation">Destructive-цвет появляется у подтверждения, а не окрашивает всё окно заранее.</p>
<Button variant="danger" shape="pill" icon={<Icon name="trash" />} onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
</Preview>
</div>
);
case "inspector":
return (
<div className="catalog-inspector-stage">
<GlassSurface tone="strong" radius="panel" className="catalog-inspector-card" style={engineAccentStyle}>
<div className="catalog-inspector-title"><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div>
<Inspector sections={inspectorSections} defaultOpen={["illumination", "connections", "ports"]} />
</GlassSurface>
</div>
</Section>
</main>
);
case "icons":
return (
<div className="catalog-icon-catalog">
<Preview title="Размеры и поверхности" note="16 / 18 / 20 px" className="catalog-preview--wide">
<div className="catalog-window-actions-demo">
<IconButton label="Добавить"><Icon name="plus" size={16} /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" size={18} /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" size={20} /></IconButton>
<IconButton label="Закрыть"><Icon name="close" size={18} /></IconButton>
</div>
<p className="catalog-preview__explanation">Иконка наследует `currentColor`; фон, размер кнопки и active-state задаёт компонент поверхности, а не SVG.</p>
</Preview>
{iconGroups.map((group) => (
<section className="catalog-icon-group" key={group.title}>
<header><strong>{group.title}</strong><span>{group.note}</span></header>
<div className="catalog-icon-grid">
{group.icons.map((name) => (
<div className="catalog-icon-tile" key={name}>
<span className="catalog-icon-tile__surface"><Icon name={name} size={20} /></span>
<span><strong>{iconLabels[name]}</strong><code>{name}</code></span>
</div>
))}
</div>
</section>
))}
</div>
);
default:
return null;
}
};
<Window
open={modalOpen}
title="Настройки сервиса"
subtitle="Единое окно Launcher / CMS / SEO"
onClose={() => setModalOpen(false)}
const activeDefinition = activeSection ? sectionDefinitions[activeSection] : null;
return (
<>
<ApplicationShell
className="catalog-app nodedc-ui-root"
style={{ "--catalog-accent": accentHex } as CSSProperties}
navigationOpen={guidelineOpen}
contentOpen={Boolean(activeSection)}
contentExpanded={panelExpanded}
header={
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
center={
<>
<HeaderWorkspace className="catalog-header-workspace" label="NODE.DC Design" imageUrl="/nodedc-mark.svg" />
<nav className="catalog-header-nav" aria-label="Навигация приложения">
<button
className="nodedc-header__nav-item"
type="button"
data-active={guidelineOpen ? "true" : undefined}
aria-expanded={guidelineOpen}
onClick={() => guidelineOpen ? closeGuideline() : setGuidelineOpen(true)}
>
Guideline
</button>
</nav>
</>
}
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" /></IconButton>
<HeaderProfileButton><Icon name="profile" size={17} />Профиль</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>
}
/>
}
stage={
<section className="catalog-launcher-stage">
<video className="catalog-launcher-stage__media" autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<source src="/launcher-stage.mp4" type="video/mp4" />
</video>
<div className="catalog-launcher-stage__shade" />
<div className="catalog-launcher-stage__title">
<span>NODE.DC</span>
<strong>DESIGN<br />GUIDELINE</strong>
<p>Общий форм-фактор приложений, компоненты и оконная механика платформы.</p>
</div>
</section>
}
navigation={
<AdminNavigationPanel
eyebrow="NODE.DC"
title="Visual Guideline"
closeLabel="Закрыть Guideline"
navigationLabel="Разделы Guideline"
onClose={closeGuideline}
items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
id,
label: item.title,
icon: <Icon name={item.icon} />,
}))}
activeId={activeSection ?? undefined}
onItemChange={openSection}
footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}>Отмена</Button>
<WindowFooterActions><Button variant="accent" onClick={() => setModalOpen(false)}>Сохранить</Button></WindowFooterActions>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.3</span>
</>
}
/>
}
content={activeDefinition ? (
<ApplicationPanel
key={activeSection}
eyebrow={activeDefinition.eyebrow}
title={activeDefinition.title}
description={activeDefinition.description}
expanded={panelExpanded}
onExpandedChange={setPanelExpanded}
onClose={() => setActiveSection(null)}
>
<div className="catalog-panel-content">{renderSectionContent()}</div>
</ApplicationPanel>
) : null}
/>
<Window
open={windowOpen}
title="Настройки сервиса"
subtitle="Launcher / CMS / SEO"
onClose={() => setWindowOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button>
<WindowFooterActions><Button variant="accent" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
</>
}
>
@ -427,6 +596,26 @@ export function CatalogApp() {
<Inspector sections={inspectorSections} defaultOpen={["illumination", "connections", "ports"]} />
</Window>
<Window
open={createModalOpen}
title="Создать проект"
subtitle="Адаптивная форма"
size="sm"
onClose={() => setCreateModalOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateModalOpen(false)}>Отмена</Button>
<WindowFooterActions><Button variant="accent" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(false)}>Создать</Button></WindowFooterActions>
</>
}
>
<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)} />
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
</div>
</Window>
<ConfirmationModal
open={confirmOpen}
title="Удалить конфигурацию?"
@ -436,6 +625,6 @@ export function CatalogApp() {
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
/>
</div>
</>
);
}

View File

@ -2,22 +2,20 @@
background: var(--nodedc-canvas);
}
html {
scroll-behavior: smooth;
html,
body,
#root {
width: 100%;
min-width: 320px;
height: 100%;
min-height: 100%;
margin: 0;
overflow: hidden;
}
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;
background: #050506;
color: var(--nodedc-text-primary);
transition: background-color 220ms ease, color 220ms ease;
}
button,
@ -30,96 +28,100 @@ textarea {
min-height: 100vh;
}
.catalog-admin-layer {
position: fixed;
z-index: 390;
top: 6.55rem;
bottom: 1.25rem;
left: 1.25rem;
.catalog-header-nav {
display: inline-flex;
min-height: var(--nodedc-header-pill-height);
align-items: center;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-glass-panel-bg-soft);
padding: 0.32rem;
box-shadow: var(--nodedc-glass-control-shadow);
}
[data-nodedc-theme="light"] .catalog-app .nodedc-header__brand img {
.catalog-header-nav .nodedc-header__nav-item {
min-width: 8.5rem;
}
.catalog-header-workspace img {
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
}
[data-nodedc-theme="light"] .catalog-app .nodedc-header__brand img,
[data-nodedc-theme="light"] .catalog-app .nodedc-header__workspace img {
filter: brightness(0.12);
}
.catalog-main {
.catalog-launcher-stage {
position: relative;
isolation: isolate;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: #0b0b0d url("/launcher-stage-poster.png") center / cover no-repeat;
box-shadow: 0 44px 150px rgba(0, 0, 0, 0.62);
}
.catalog-launcher-stage__media,
.catalog-launcher-stage__shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.catalog-launcher-stage__media {
object-fit: cover;
opacity: 0.92;
}
.catalog-launcher-stage__shade {
z-index: 1;
background:
radial-gradient(circle at 52% 48%, transparent 0 38%, rgba(0, 0, 0, 0.34) 100%),
linear-gradient(90deg, rgba(0, 0, 0, 0.5), transparent 38% 72%, rgba(0, 0, 0, 0.2));
}
.catalog-launcher-stage__title {
position: absolute;
z-index: 2;
top: clamp(2.8rem, 9vh, 5.75rem);
left: clamp(2.8rem, 6vw, 5.75rem);
display: grid;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
gap: 8rem;
padding: 5rem 0 10rem;
transition: width 500ms cubic-bezier(0.22, 1, 0.36, 1), margin-left 500ms cubic-bezier(0.22, 1, 0.36, 1);
max-width: 28rem;
gap: 0.24rem;
pointer-events: none;
}
.catalog-main[data-admin-open="true"] {
width: min(1180px, calc(100% - 25rem));
margin-right: 1rem;
margin-left: 24rem;
.catalog-launcher-stage__title span,
.catalog-launcher-stage__title strong {
font-size: clamp(2rem, 3vw, 3.25rem);
font-weight: 350;
letter-spacing: -0.035em;
line-height: 0.98;
}
.catalog-hero {
.catalog-launcher-stage__title span {
color: rgba(38, 38, 42, 0.76);
}
.catalog-launcher-stage__title strong {
color: rgba(255, 255, 255, 0.96);
}
.catalog-launcher-stage__title p {
max-width: 22rem;
margin: 0.9rem 0 0;
color: rgba(255, 255, 255, 0.68);
font-size: 0.86rem;
line-height: 1.45;
}
.catalog-panel-content {
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-nav {
margin-top: 0.6rem;
}
.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;
align-content: start;
gap: 1rem;
padding: 0.15rem 0.1rem 1rem;
}
.catalog-grid {
@ -132,8 +134,12 @@ textarea {
grid-template-columns: 1.1fr 0.9fr;
}
.catalog-grid--single {
grid-template-columns: 1fr;
}
.catalog-preview {
min-height: 18rem;
min-height: 16rem;
}
.catalog-preview--wide {
@ -145,7 +151,7 @@ textarea {
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.catalog-preview__head strong {
@ -155,24 +161,35 @@ textarea {
.catalog-preview__head span {
color: var(--nodedc-text-muted);
font-size: 0.68rem;
text-align: right;
text-transform: uppercase;
}
.catalog-preview__body {
display: grid;
min-height: 10rem;
min-height: 8rem;
align-content: center;
gap: 1rem;
}
.catalog-preview__explanation {
max-width: 42rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.catalog-theme-row,
.catalog-inline {
.catalog-inline,
.catalog-window-actions-demo {
display: flex;
align-items: center;
gap: 0.6rem;
}
.catalog-inline--wrap {
.catalog-inline--wrap,
.catalog-window-actions-demo {
flex-wrap: wrap;
}
@ -212,6 +229,14 @@ textarea {
gap: 0.75rem;
}
.catalog-surface-stack .nodedc-glass {
display: flex;
min-height: 4rem;
align-items: center;
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
}
.catalog-engine-control-stack {
display: grid;
width: min(var(--nodedc-inspector-inner-width), 100%);
@ -219,24 +244,49 @@ textarea {
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-shell-diagram {
display: grid;
min-height: 16rem;
grid-template-columns: minmax(8rem, 0.28fr) minmax(15rem, 1fr) minmax(7rem, 0.22fr);
grid-template-rows: 3.2rem 1fr;
gap: 0.6rem;
border-radius: 1.1rem;
background: rgba(0, 0, 0, 0.22);
padding: 0.6rem;
}
.catalog-shell-diagram span {
display: grid;
place-items: center;
border-radius: 0.9rem;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.catalog-shell-diagram__header {
grid-column: 1 / -1;
}
.catalog-shell-diagram__nav {
background: color-mix(in srgb, var(--nodedc-glass-control-hover) 90%, transparent) !important;
}
.catalog-shell-diagram__content {
background: color-mix(in srgb, var(--nodedc-glass-control-hover) 70%, transparent) !important;
}
.catalog-inspector-stage {
display: flex;
min-height: 40rem;
min-height: 43rem;
justify-content: flex-end;
align-items: flex-start;
padding: 4rem;
border-radius: 2rem;
border-radius: var(--nodedc-radius-card);
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);
padding: 2rem;
}
.catalog-inspector-card {
@ -255,17 +305,89 @@ textarea {
font-size: 0.7rem;
}
@media (max-width: 820px) {
.catalog-main {
gap: 6rem;
padding-top: 2rem;
.catalog-icon-catalog {
display: grid;
gap: 1rem;
}
.catalog-main[data-admin-open="true"] {
width: min(1180px, calc(100% - 2rem));
margin-inline: auto;
.catalog-icon-group {
display: grid;
gap: 0.75rem;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-glass-panel-surface);
padding: 1rem;
}
.catalog-icon-group > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding-inline: 0.2rem;
}
.catalog-icon-group > header strong {
font-size: 0.88rem;
}
.catalog-icon-group > header span {
color: var(--nodedc-text-muted);
font-size: 0.68rem;
text-transform: uppercase;
}
.catalog-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
}
.catalog-icon-tile {
display: grid;
min-width: 0;
grid-template-columns: 2.92rem minmax(0, 1fr);
align-items: center;
gap: 0.7rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-control-bg);
padding: 0.35rem 0.55rem 0.35rem 0.35rem;
}
.catalog-icon-tile__surface {
display: grid;
width: 2.92rem;
height: 2.92rem;
place-items: center;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.catalog-icon-tile > span:last-child {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.catalog-icon-tile strong,
.catalog-icon-tile code {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-icon-tile strong {
font-size: 0.72rem;
}
.catalog-icon-tile code {
color: var(--nodedc-text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.62rem;
}
@media (max-width: 1280px) {
.catalog-grid,
.catalog-grid--foundation {
grid-template-columns: 1fr;
@ -275,14 +397,92 @@ textarea {
grid-column: auto;
}
.catalog-inspector-stage {
padding: 1rem;
.catalog-icon-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.catalog-admin-layer {
top: 0.875rem;
right: 0.875rem;
bottom: 0.875rem;
left: 0.875rem;
@media (max-width: 900px) {
.catalog-icon-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.catalog-launcher-stage__media {
display: none;
}
.catalog-header-nav {
width: 100%;
}
.catalog-header-nav .nodedc-header__nav-item {
width: 100%;
}
.catalog-app .nodedc-header__profile-button {
display: none;
}
.catalog-launcher-stage__title {
top: 2rem;
left: 2rem;
max-width: calc(100% - 4rem);
}
.catalog-launcher-stage__title span,
.catalog-launcher-stage__title strong {
font-size: clamp(1.8rem, 9vw, 3rem);
}
.catalog-launcher-stage__title p {
display: none;
}
.catalog-preview {
min-height: auto;
}
.catalog-preview__head,
.catalog-icon-group > header {
align-items: flex-start;
flex-direction: column;
gap: 0.25rem;
}
.catalog-swatches,
.catalog-icon-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalog-shell-diagram {
grid-template-columns: 1fr 1.5fr;
grid-template-rows: 3rem 7rem 7rem;
}
.catalog-shell-diagram__header {
grid-column: 1 / -1;
}
.catalog-shell-diagram__stage {
grid-column: 1 / -1;
}
.catalog-inspector-stage {
min-height: 40rem;
justify-content: center;
padding: 0.75rem;
}
.catalog-window-actions-demo .nodedc-button {
width: 100%;
}
}
@media (max-width: 430px) {
.catalog-swatches,
.catalog-icon-grid {
grid-template-columns: 1fr;
}
}

View File

@ -19,6 +19,8 @@
- Dropdown/Select;
- Window/Confirmation;
- AppHeader.
- ApplicationShell/ApplicationPanel;
- Icon/IconButton.
Локальные реализации удаляются только после функционального и визуального сравнения.
@ -40,14 +42,17 @@
## Новые приложения
Шаблон приложения пока не входит в scope. До его появления новый проект должен:
Новый React-проект начинает с `ApplicationShell`:
- установить опубликованные NODE.DC UI packages;
- выбрать theme и accent;
- использовать AppHeader и Window/Dropdown primitives;
- передать фиксированный AppHeader, stage, navigation и content;
- использовать Window/Dropdown и канонический Icon вместо локальных реализаций;
- проверять registry перед созданием локального control;
- хранить доменные компоненты у себя.
Нижняя service rail добавляется только продуктам с витриной сервисов. В обычном оконном приложении она отсутствует.
## Adoption matrix
| Приложение | Текущий источник | Целевой adapter | Первый набор |
@ -66,4 +71,3 @@
- не поддерживать отдельную тему путём fork компонента;
- не использовать legacy Engine inspector как промежуточный канон;
- не удалять production local component до проверки package replacement.

View File

@ -0,0 +1,40 @@
# Шаблон приложения
`ApplicationShell` — канонический форм-фактор нового приложения NODE.DC. Он повторяет устойчивую механику Launcher/Hub, но не включает продуктовую витрину и нижнюю service rail.
## Состав
1. `AppHeader` — фиксированная трёхосевая шапка `logo / workspace + navigation / profile`.
2. `stage` — главное медиа- или предметное окно приложения.
3. `navigation` — левая панель шириной `clamp(332px, 19.5vw, 352px)`.
4. `content` — отдельное рабочее окно справа от навигации.
5. `ApplicationPanel` — заголовок, действия expand/close и независимо прокручиваемое тело контента.
Позиции шапки не пересчитываются под конкретное приложение. Меняются подписи, доступные navigation actions, профиль и содержимое stage.
## Состояния desktop
| Состояние | Навигация | Content window | Stage |
| --- | --- | --- | --- |
| Главная | закрыта | закрыто | занимает рабочую область |
| Guideline открыт | слева | закрыто | сдвинут вправо |
| Оконный режим | слева | средняя колонка | остаётся видимым справа |
| Развёрнутый режим | слева | до правого края | уезжает за viewport |
Между панелями используется page gap `20px`. На Guideline-главной нижней rail нет: она является продуктовым элементом витрины Launcher, а не частью общего shell.
## Узкий viewport
До `760px` шапка перестраивается в две строки. Navigation и content занимают доступную область под шапкой последовательно: открытый content скрывает navigation, но не уничтожает его состояние. Кнопка expand скрывается, потому что content уже использует всю доступную ширину.
Modal остаётся portal-слоем. Его тело прокручивается независимо, footer остаётся внутри viewport, а поля складываются в одну колонку.
## Что принадлежит приложению
- route/state выбранного раздела;
- данные профиля;
- медиа и содержание stage;
- пункты navigation;
- предметное содержимое content window.
Shell владеет геометрией, слоями, переходами и breakpoint-поведением.

View File

@ -27,6 +27,8 @@ Core не зависит от React.
`@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
### DOM
`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`.
@ -44,6 +46,8 @@ Core не зависит от React.
- floating layers;
- окна;
- навигационная геометрия;
- application shell и фиксированная шапка;
- канонический набор иконок;
- inspector/settings layout;
- состояния загрузки, пустоты и ошибки.
@ -76,4 +80,3 @@ Core не зависит от React.
- major — удаление/переименование экспорта или несовместимая геометрия/семантика.
Приложение должно зависеть от опубликованной версии пакета, а не от копии файлов или произвольного commit URL.

View File

@ -95,11 +95,32 @@ Pill navigation для верхней панели и компактного п
В Hub-форме центр состоит из круглого workspace trigger и segmented navigation. Справа используется единая profile-group поверхность с иконкой, подписью и круглым avatar. Эти элементы нельзя заменять случайными standalone-кнопками, сохраняя только приблизительную позицию.
В каноническом `ApplicationShell` шапка фиксирована. Её три оси не двигаются при открытии navigation/content и не зависят от ширины предметного контента.
## ApplicationShell и ApplicationPanel
Общий шаблон приложения по механике Launcher/Hub: фиксированный `AppHeader`, центральный stage, левая navigation panel и отдельное правое content window.
- на главной виден только stage;
- открытая navigation сдвигает stage;
- content открывается справа от navigation;
- expand растягивает content до правого края и уводит stage за viewport;
- на мобильном navigation и content используют всю область под шапкой последовательно;
- нижняя service rail не входит в шаблон — это элемент витрины Launcher.
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
## AdminNavigationPanel
Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity.
На desktop открытая панель занимает отдельную колонку с зазором `12 px` до основного контента, а не перекрывает его затемнённым overlay.
На desktop открытая панель занимает отдельную колонку с Launcher page gap `20 px` до content/stage, а не перекрывает их затемнённым overlay.
## Icon
`Icon` предоставляет только подтверждённый общий subset иконок. Каноническое имя описывает смысл (`close`, `expand`, `refresh`), а не конкретный путь SVG. Базовые размеры — `16`, `18` и `20 px`, stroke — `1.8`.
Поверхность, круглая форма, hit target, active и disabled состояния принадлежат `IconButton`, `Button` или navigation item. Полный список находится в `registry/icons.json` и `docs/ICONS.md`.
## StatusBadge

30
docs/ICONS.md Normal file
View File

@ -0,0 +1,30 @@
# Иконки
Канонический набор собран по реально используемым действиям Launcher/Hub, SEO, BIM Viewer и разрешённым новым участкам Engine. Полные vendor-наборы не являются частью дизайн-системы: наличие SVG в Font Awesome или export в Lucide не делает иконку канонической.
## Геометрия
| Контекст | Размер glyph | Hit target |
| --- | ---: | ---: |
| Плотная строка/label | `16px` | задаёт строка |
| Обычная кнопка | `18px` | `46.72px` круг или кнопка с текстом |
| Крупное оконное действие | `20px` | `46.72px` круг |
Базовый stroke — `1.8`. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности.
## Группы
| Группа | Канонические имена |
| --- | --- |
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle` |
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
| Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
## Правило расширения
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.

View File

@ -15,6 +15,8 @@
Для точной геометрии используются `src/widgets/top-bar/TopBar.tsx`, `src/widgets/admin-overlay/AdminOverlay.tsx` и `src/styles/globals.css`: трёхосевая шапка, круглый workspace trigger, segmented navigation, profile group и левая admin panel `352 px`.
`src/app/LauncherApp.tsx`, `src/widgets/service-stage/ServiceStage.tsx` и те же global styles подтверждают общий application shell: stage сдвигается при открытии navigation/content, а content разворачивается независимо. Нижняя ServiceRail исключена из общего шаблона как продуктовая часть витрины.
Существовавший `dc-ui-guideline` использован как исходный инвентарь. Он больше не должен развиваться как независимая копия после подключения центрального репозитория.
## SEO и CMS
@ -48,6 +50,10 @@ CMS подтверждает необходимость DOM-пакета без
Используется как ограничение архитектуры: общий UI должен работать без React. В baseline включены общие требования к toolbar, settings menu и glass select, но не BIM-domain controls.
## Аудит иконок
Launcher/Hub и SEO подтверждают Lucide как основной outline-язык; BIM Viewer использует Font Awesome в legacy/vendor-слое; Engine содержит локальные inline SVG. В библиотеку перенесён только пересекающийся семантический subset действий и сущностей. Полные vendor-каталоги и legacy Engine glyph не являются каноном.
## Почему код не копируется целиком
Исторические реализации содержат:

34
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nodedc-design-guideline",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodedc-design-guideline",
"version": "0.2.0",
"version": "0.3.0",
"workspaces": [
"packages/*",
"apps/*"
@ -25,10 +25,10 @@
},
"apps/catalog": {
"name": "@nodedc/ui-catalog",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@nodedc/ui-core": "0.2.0",
"@nodedc/ui-react": "0.2.0",
"@nodedc/ui-core": "0.3.0",
"@nodedc/ui-react": "0.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@ -1539,6 +1539,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1879,27 +1888,28 @@
},
"packages/tokens": {
"name": "@nodedc/tokens",
"version": "0.2.0"
"version": "0.3.0"
},
"packages/ui-core": {
"name": "@nodedc/ui-core",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@nodedc/tokens": "0.2.0"
"@nodedc/tokens": "0.3.0"
}
},
"packages/ui-dom": {
"name": "@nodedc/ui-dom",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@nodedc/ui-core": "0.2.0"
"@nodedc/ui-core": "0.3.0"
}
},
"packages/ui-react": {
"name": "@nodedc/ui-react",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@nodedc/ui-core": "0.2.0"
"@nodedc/ui-core": "0.3.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {
"@types/react": "^19.1.0",

View File

@ -1,6 +1,6 @@
{
"name": "nodedc-design-guideline",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/tokens",
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"files": ["tokens.json", "tokens.css", "themes.css"],
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-core",
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"files": ["dist", "styles.css"],
"main": "./dist/index.js",
@ -17,6 +17,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/tokens": "0.2.0"
"@nodedc/tokens": "0.3.0"
}
}

View File

@ -532,6 +532,14 @@ textarea.nodedc-field__control {
padding: 1rem var(--nodedc-page-padding) 0.75rem;
}
.nodedc-header-shell[data-fixed="true"] {
position: fixed;
top: 0;
left: 50%;
margin-inline: 0;
transform: translateX(-50%);
}
.nodedc-header {
display: grid;
min-height: 4.25rem;
@ -603,6 +611,7 @@ textarea.nodedc-field__control {
min-height: calc(var(--nodedc-header-pill-height) - 0.64rem);
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 0;
border-radius: var(--nodedc-radius-circle);
outline: 0;
@ -691,6 +700,189 @@ textarea.nodedc-field__control {
color: var(--nodedc-glass-control-active-text);
}
.nodedc-app-shell {
--nodedc-app-header-height: 6rem;
--nodedc-app-page-pad: 1.25rem;
--nodedc-app-panel-gap: 1.25rem;
--nodedc-app-nav-width: clamp(20.75rem, 19.5vw, 22rem);
--nodedc-app-content-width: calc(
100vw - (var(--nodedc-app-page-pad) * 2) - (var(--nodedc-app-panel-gap) * 2) - (var(--nodedc-app-nav-width) * 2)
);
min-height: 100vh;
overflow: hidden;
background: var(--nodedc-canvas);
}
.nodedc-app-shell__main {
position: relative;
height: calc(100vh - var(--nodedc-app-header-height));
min-height: 0;
margin-top: var(--nodedc-app-header-height);
overflow: hidden;
}
.nodedc-app-shell__stage {
position: absolute;
inset: 0;
overflow: hidden;
padding: 0 var(--nodedc-app-page-pad) var(--nodedc-app-page-pad);
transition: padding 440ms cubic-bezier(0.22, 1, 0.36, 1), transform 440ms cubic-bezier(0.22, 1, 0.36, 1);
}
.nodedc-app-shell__stage > * {
width: 100%;
height: 100%;
}
.nodedc-app-shell__navigation,
.nodedc-app-shell__content {
position: absolute;
z-index: var(--nodedc-layer-panel);
top: 0;
bottom: var(--nodedc-app-page-pad);
min-height: 0;
}
.nodedc-app-shell__navigation {
left: var(--nodedc-app-page-pad);
width: var(--nodedc-app-nav-width);
}
.nodedc-app-shell__content {
left: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap));
width: max(32rem, var(--nodedc-app-content-width));
transition: width 500ms cubic-bezier(0.22, 1, 0.36, 1), right 500ms cubic-bezier(0.22, 1, 0.36, 1);
}
.nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage {
padding-left: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap));
}
.nodedc-app-shell[data-content-open="true"]:not([data-content-expanded="true"]) .nodedc-app-shell__stage {
padding-left: calc(
var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap) +
max(32rem, var(--nodedc-app-content-width)) + var(--nodedc-app-panel-gap)
);
}
.nodedc-app-shell[data-content-open="true"][data-content-expanded="true"] .nodedc-app-shell__content {
right: var(--nodedc-app-page-pad);
width: auto;
}
.nodedc-app-shell[data-content-open="true"][data-content-expanded="true"] .nodedc-app-shell__stage {
pointer-events: none;
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
}
.nodedc-application-panel {
display: grid;
width: 100%;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.75rem;
overflow: hidden;
border: 0;
border-radius: var(--nodedc-radius-card);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
rgba(10, 10, 13, 0.9);
color: var(--nodedc-text-primary);
padding: 1rem;
box-shadow: 0 34px 110px rgba(0, 0, 0, 0.42);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
animation: nodedc-application-panel-in 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes nodedc-application-panel-in {
from { opacity: 0; transform: translateX(-1.1rem); }
to { opacity: 1; transform: translateX(0); }
}
.nodedc-application-panel__head {
display: flex;
min-height: 3.55rem;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.nodedc-application-panel__titles {
display: grid;
min-width: 0;
gap: 0.24rem;
padding: 0.2rem 0.25rem;
}
.nodedc-application-panel__titles span {
color: var(--nodedc-text-muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.nodedc-application-panel__titles h1,
.nodedc-application-panel__titles p {
margin: 0;
}
.nodedc-application-panel__titles h1 {
font-size: 1.15rem;
font-weight: 780;
line-height: 1.15;
}
.nodedc-application-panel__titles p {
max-width: 42rem;
color: var(--nodedc-text-muted);
font-size: 0.75rem;
line-height: 1.35;
}
.nodedc-application-panel__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.45rem;
}
.nodedc-application-panel__action {
display: grid;
width: 2.92rem;
height: 2.92rem;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: var(--nodedc-radius-circle);
outline: 0;
background: transparent;
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.nodedc-application-panel__action:hover,
.nodedc-application-panel__action:focus-visible {
border-color: rgba(255, 255, 255, 0.3);
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-application-panel[data-expanded="true"] .nodedc-application-panel__action:first-child {
border-color: transparent;
background: var(--nodedc-glass-control-active);
color: var(--nodedc-glass-control-active-text);
}
.nodedc-application-panel__body {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.nodedc-admin-panel {
position: relative;
display: grid;
@ -713,6 +905,10 @@ textarea.nodedc-field__control {
animation: nodedc-admin-panel-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.nodedc-admin-panel:not([data-has-contexts="true"]) {
grid-template-rows: auto minmax(0, 1fr) auto;
}
@keyframes nodedc-admin-panel-in {
from { opacity: 0; transform: translateX(-1.6rem); }
to { opacity: 1; transform: translateX(0); }
@ -915,6 +1111,7 @@ textarea.nodedc-field__control {
}
.nodedc-admin-panel__nav-item {
outline: 0;
cursor: pointer;
transition: background 160ms ease, color 160ms ease, opacity 160ms ease, transform 160ms ease;
}
@ -933,6 +1130,15 @@ textarea.nodedc-field__control {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.048), 0 10px 22px rgba(0, 0, 0, 0.12);
}
.nodedc-admin-panel__nav-item:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), 0 10px 22px rgba(0, 0, 0, 0.12);
}
.nodedc-app-shell__navigation > .nodedc-admin-panel {
width: 100%;
}
.nodedc-admin-panel__nav-icon {
position: absolute;
top: 50%;
@ -1514,9 +1720,30 @@ textarea.nodedc-field__control {
font-size: var(--nodedc-font-size-lg);
}
@media (max-width: 1100px) {
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__content {
right: var(--nodedc-app-page-pad);
width: auto;
}
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__stage {
pointer-events: none;
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
}
}
@media (max-width: 760px) {
.nodedc-header-shell {
width: 100%;
padding: 0.85rem 1rem 0.65rem;
background: var(--nodedc-canvas);
}
.nodedc-header {
min-height: 8.6rem;
grid-template-columns: minmax(0, 1fr) auto;
align-content: start;
row-gap: 0.65rem;
}
.nodedc-header__center {
@ -1530,6 +1757,54 @@ textarea.nodedc-field__control {
min-width: max-content;
}
.nodedc-app-shell {
--nodedc-app-header-height: 10.25rem;
--nodedc-app-page-pad: 0.7rem;
--nodedc-app-panel-gap: 0.7rem;
}
.nodedc-app-shell__stage {
padding: 0 var(--nodedc-app-page-pad) var(--nodedc-app-page-pad);
}
.nodedc-app-shell__navigation,
.nodedc-app-shell__content {
right: var(--nodedc-app-page-pad);
bottom: var(--nodedc-app-page-pad);
left: var(--nodedc-app-page-pad);
width: auto;
}
.nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage,
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__stage {
padding-left: var(--nodedc-app-page-pad);
pointer-events: none;
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
}
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__navigation {
opacity: 0;
pointer-events: none;
}
.nodedc-application-panel,
.nodedc-admin-panel {
width: 100%;
max-width: none;
}
.nodedc-application-panel {
padding: 0.75rem;
}
.nodedc-application-panel__titles p {
display: none;
}
.nodedc-application-panel__action:first-child {
display: none;
}
.nodedc-window[data-placement="end"] {
width: calc(100vw - 1.75rem);
max-height: calc(100vh - 1.75rem);
@ -1554,6 +1829,7 @@ textarea.nodedc-field__control {
.nodedc-dropdown-surface,
.nodedc-overlay,
.nodedc-window,
.nodedc-application-panel,
.nodedc-inspector__section-content {
animation: none;
}

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-dom",
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"files": ["dist"],
"main": "./dist/index.js",
@ -16,6 +16,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/ui-core": "0.2.0"
"@nodedc/ui-core": "0.3.0"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-react",
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"files": ["dist"],
"main": "./dist/index.js",
@ -16,7 +16,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/ui-core": "0.2.0"
"@nodedc/ui-core": "0.3.0",
"lucide-react": "^0.468.0"
},
"peerDependencies": {
"react": ">=18",

View File

@ -26,6 +26,7 @@ export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLEleme
activeId?: string;
footer?: ReactNode;
closeLabel?: string;
navigationLabel?: string;
onClose: () => void;
onItemChange: (id: string) => void;
}
@ -38,13 +39,14 @@ export function AdminNavigationPanel({
activeId,
footer,
closeLabel = "Закрыть администрирование",
navigationLabel = "Разделы администрирования",
onClose,
onItemChange,
className,
...props
}: AdminNavigationPanelProps) {
return (
<aside className={cn("nodedc-admin-panel", className)} {...props}>
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 ? "true" : undefined} {...props}>
<header className="nodedc-admin-panel__head">
<div>
<p>{eyebrow}</p>
@ -55,7 +57,7 @@ export function AdminNavigationPanel({
</button>
</header>
<div className="nodedc-admin-panel__contexts">
{contexts.length > 0 ? <div className="nodedc-admin-panel__contexts">
{contexts.map((context) => (
<div className="nodedc-admin-panel__context-group" key={context.id}>
{context.groupLabel ? <span className="nodedc-admin-panel__group-label">{context.groupLabel}</span> : null}
@ -78,9 +80,9 @@ export function AdminNavigationPanel({
</div>
</div>
))}
</div>
</div> : null}
<nav className="nodedc-admin-panel__nav" aria-label="Разделы администрирования">
<nav className="nodedc-admin-panel__nav" aria-label={navigationLabel}>
{items.map((item) => (
<button
key={item.id}
@ -99,4 +101,3 @@ export function AdminNavigationPanel({
</aside>
);
}

View File

@ -5,6 +5,7 @@ export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
brand: ReactNode;
brandHref?: string;
brandLabel?: string;
fixed?: boolean;
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
@ -14,6 +15,7 @@ export function AppHeader({
brand,
brandHref,
brandLabel = "NODE.DC",
fixed = true,
left,
center,
right,
@ -27,7 +29,7 @@ export function AppHeader({
);
return (
<header className={cn("nodedc-header-shell", className)} {...props}>
<header className={cn("nodedc-header-shell", className)} data-fixed={fixed ? "true" : undefined} {...props}>
<div className="nodedc-header">
<div className="nodedc-header__left">{brandNode}{left}</div>
<div className="nodedc-header__center">{center}</div>

View File

@ -0,0 +1,96 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { Icon } from "./Icon";
export interface ApplicationShellProps extends Omit<HTMLAttributes<HTMLDivElement>, "content"> {
header: ReactNode;
stage: ReactNode;
navigation?: ReactNode;
content?: ReactNode;
navigationOpen?: boolean;
contentOpen?: boolean;
contentExpanded?: boolean;
}
export function ApplicationShell({
header,
stage,
navigation,
content,
navigationOpen = false,
contentOpen = false,
contentExpanded = false,
className,
...props
}: ApplicationShellProps) {
return (
<div
className={cn("nodedc-app-shell", className)}
data-navigation-open={navigationOpen ? "true" : undefined}
data-content-open={contentOpen ? "true" : undefined}
data-content-expanded={contentOpen && contentExpanded ? "true" : undefined}
{...props}
>
{header}
<main className="nodedc-app-shell__main">
<div className="nodedc-app-shell__stage">{stage}</div>
{navigationOpen && navigation ? <div className="nodedc-app-shell__navigation">{navigation}</div> : null}
{contentOpen && content ? <div className="nodedc-app-shell__content">{content}</div> : null}
</main>
</div>
);
}
export interface ApplicationPanelProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
eyebrow?: ReactNode;
title: ReactNode;
description?: ReactNode;
expanded?: boolean;
expandLabel?: string;
closeLabel?: string;
onExpandedChange?: (expanded: boolean) => void;
onClose: () => void;
children: ReactNode;
}
export function ApplicationPanel({
eyebrow,
title,
description,
expanded = false,
expandLabel = "Развернуть окно",
closeLabel = "Закрыть окно",
onExpandedChange,
onClose,
children,
className,
...props
}: ApplicationPanelProps) {
return (
<section className={cn("nodedc-application-panel", className)} data-expanded={expanded ? "true" : undefined} {...props}>
<header className="nodedc-application-panel__head">
<div className="nodedc-application-panel__titles">
{eyebrow ? <span>{eyebrow}</span> : null}
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
</div>
<div className="nodedc-application-panel__actions">
{onExpandedChange ? (
<button
type="button"
className="nodedc-application-panel__action"
aria-label={expanded ? "Свернуть окно" : expandLabel}
onClick={() => onExpandedChange(!expanded)}
>
<Icon name={expanded ? "minimize" : "expand"} />
</button>
) : null}
<button type="button" className="nodedc-application-panel__action" aria-label={closeLabel} onClick={onClose}>
<Icon name="close" />
</button>
</div>
</header>
<div className="nodedc-application-panel__body">{children}</div>
</section>
);
}

View File

@ -0,0 +1,114 @@
import {
Activity,
AlertTriangle,
Boxes,
Building2,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
ClipboardList,
Copy,
Database,
Download,
ExternalLink,
FileText,
FolderOpen,
Globe2,
Image,
Inbox,
KeyRound,
LayoutDashboard,
ListChecks,
LockKeyhole,
MailPlus,
Maximize2,
Minimize2,
Network,
PanelTop,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Settings,
ShieldCheck,
SlidersHorizontal,
Trash2,
UploadCloud,
UserCircle,
UsersRound,
Video,
X,
type LucideIcon,
type LucideProps,
} from "lucide-react";
const icons = {
activity: Activity,
alert: AlertTriangle,
apps: Boxes,
building: Building2,
check: Check,
"chevron-down": ChevronDown,
"chevron-left": ChevronLeft,
"chevron-right": ChevronRight,
circle: Circle,
clipboard: ClipboardList,
close: X,
copy: Copy,
database: Database,
download: Download,
edit: Pencil,
expand: Maximize2,
external: ExternalLink,
file: FileText,
folder: FolderOpen,
globe: Globe2,
grid: LayoutDashboard,
image: Image,
inbox: Inbox,
key: KeyRound,
list: ListChecks,
lock: LockKeyhole,
mail: MailPlus,
minimize: Minimize2,
network: Network,
panel: PanelTop,
plus: Plus,
profile: UserCircle,
refresh: RefreshCw,
save: Save,
search: Search,
settings: Settings,
shield: ShieldCheck,
sliders: SlidersHorizontal,
trash: Trash2,
upload: UploadCloud,
users: UsersRound,
video: Video,
} satisfies Record<string, LucideIcon>;
export type IconName = keyof typeof icons;
export interface IconProps extends Omit<LucideProps, "ref"> {
name: IconName;
label?: string;
}
export function Icon({ name, label, size = 18, strokeWidth = 1.8, ...props }: IconProps) {
const IconComponent = icons[name];
return (
<IconComponent
size={size}
strokeWidth={strokeWidth}
aria-hidden={label ? undefined : "true"}
aria-label={label}
role={label ? "img" : undefined}
focusable="false"
{...props}
/>
);
}

View File

@ -1,5 +1,6 @@
export * from "./AppHeader";
export * from "./AdminNavigationPanel";
export * from "./ApplicationShell";
export * from "./Button";
export * from "./Checker";
export * from "./ConfirmationModal";
@ -7,6 +8,7 @@ export * from "./Dropdown";
export * from "./Field";
export * from "./Glass";
export * from "./Inspector";
export * from "./Icon";
export * from "./RangeControl";
export * from "./SegmentedControl";
export * from "./Select";

View File

@ -148,7 +148,37 @@
"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."
"Product routes and profile content are application data passed into stable slots.",
"The canonical application header is fixed and preserves the Launcher three-axis logo / navigation / profile positions."
]
},
{
"id": "application-shell",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ApplicationShell", "ApplicationPanel"],
"domContract": ["nodedc-app-shell", "nodedc-application-panel"],
"summary": "Launcher-derived application template with fixed header, media stage, left navigation and a separate expandable content window.",
"anatomy": ["fixed AppHeader", "stage", "left navigation", "right content window", "expand and close actions"],
"behavior": ["navigation shifts stage", "content opens beside navigation", "expanded content replaces stage", "mobile panels use the available viewport sequentially", "independent content scrolling"],
"rules": [
"The bottom Launcher service rail is product-specific and is not part of the general application template.",
"Desktop page gap is 20 px and navigation width follows the Launcher 332352 px range.",
"Applications supply routes, profile data, stage media and panel content without changing shell geometry."
]
},
{
"id": "icon",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Icon"],
"domContract": ["svg using currentColor"],
"summary": "Audited semantic icon subset shared by NODE.DC applications instead of product-local vendor imports.",
"variants": ["16 px", "18 px", "20 px"],
"rules": [
"Use semantic names from registry/icons.json.",
"The containing component owns surface, hit target and state; the icon owns only the glyph.",
"Do not copy complete Lucide or Font Awesome catalogs into an application."
]
},
{
@ -161,7 +191,7 @@
"anatomy": ["eyebrow and title", "close action", "workspace context", "company context", "route items", "footer identity"],
"rules": [
"The panel shell, full-bleed pills and icon circles are library geometry; routes and identity data remain application-owned.",
"The desktop panel is 352 px wide with a 21.6 px shell radius and opens beside, not over, the main Hub content.",
"The desktop panel follows the Launcher 332352 px width range with a 21.6 px shell radius and a 20 px page gap.",
"Active route state uses the active surface and icon contrast from the theme."
]
},

53
registry/icons.json Normal file
View File

@ -0,0 +1,53 @@
{
"schemaVersion": "1.0.0",
"component": "Icon",
"package": "@nodedc/ui-react",
"baseLibrary": "lucide-react",
"defaultSize": 18,
"supportedSizes": [16, 18, 20],
"strokeWidth": 1.8,
"groups": [
{
"id": "window-layer",
"label": "Окно и слой",
"referenceSources": ["launcher", "engine"],
"names": ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"]
},
{
"id": "navigation",
"label": "Навигация",
"referenceSources": ["launcher", "engine", "bim-viewer"],
"names": ["chevron-left", "chevron-right", "chevron-down", "grid", "list", "sliders", "search"]
},
{
"id": "editing",
"label": "Редактирование",
"referenceSources": ["launcher", "seo"],
"names": ["save", "edit", "trash", "copy", "upload", "download", "external"]
},
{
"id": "state-access",
"label": "Состояние и доступ",
"referenceSources": ["launcher", "seo", "task-manager"],
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
},
{
"id": "entities",
"label": "Сущности",
"referenceSources": ["launcher", "task-manager"],
"names": ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"]
},
{
"id": "content",
"label": "Контент",
"referenceSources": ["seo", "bim-viewer", "engine"],
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
}
],
"rules": [
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
"A new icon requires a confirmed product use, a registry entry and a catalog specimen."
]
}

View File

@ -1,6 +1,6 @@
{
"schemaVersion": "1.0.0",
"libraryVersion": "0.2.0",
"libraryVersion": "0.3.0",
"name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [
@ -28,6 +28,7 @@
"sourceRegistry": "sources.json",
"componentRegistry": "components.json",
"candidateRegistry": "candidates.json",
"iconRegistry": "icons.json",
"documentation": {
"architecture": "../docs/ARCHITECTURE.md",
"components": "../docs/COMPONENTS.md",
@ -36,7 +37,9 @@
"baseline": "../docs/SOURCE_BASELINE.md",
"governance": "../docs/GOVERNANCE.md",
"adoption": "../docs/ADOPTION.md",
"candidates": "../docs/CANDIDATES.md"
"candidates": "../docs/CANDIDATES.md",
"applicationTemplate": "../docs/APPLICATION_TEMPLATE.md",
"icons": "../docs/ICONS.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."
}

View File

@ -9,6 +9,7 @@ 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 icons = await parse("registry/icons.json");
const failures = [];
const requireValue = (condition, message) => {
@ -21,12 +22,16 @@ requireValue(Array.isArray(registry.packages) && registry.packages.length >= 4,
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");
requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon 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");
const iconNames = icons.groups.flatMap((group) => group.names ?? []);
requireValue(iconNames.length >= 35, "icon registry must contain the audited baseline set");
requireValue(new Set(iconNames).size === iconNames.length, "icon names must be unique across groups");
for (const component of components.components) {
requireValue(component.id && component.status && component.summary, `component entry is incomplete: ${component.id ?? "unknown"}`);
@ -53,4 +58,4 @@ if (failures.length > 0) {
process.exit(1);
}
console.log(`Registry valid: ${components.components.length} components, ${candidates.candidates.length} candidates, ${sources.sources.length} audited sources.`);
console.log(`Registry valid: ${components.components.length} components, ${iconNames.length} icons, ${candidates.candidates.length} candidates, ${sources.sources.length} audited sources.`);