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 # 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 ## 0.2.0 — source-faithful geometry
- Corrected the Hub header to the production three-axis shell with workspace, navigation and profile groups. - Corrected the Hub header to the production three-axis shell with workspace, navigation and profile groups.

View File

@ -12,6 +12,8 @@
- модальные окна, подтверждения и боковые окна; - модальные окна, подтверждения и боковые окна;
- segmented navigation и статусы; - segmented navigation и статусы;
- Hub header и левая admin navigation panel; - Hub header и левая admin navigation panel;
- launcher-style ApplicationShell без обязательной нижней витрины;
- аудированный общий набор иконок;
- инспектор/панель настроек нового поколения; - инспектор/панель настроек нового поколения;
- поведение portal-слоёв, Escape, outside click и фокуса; - поведение 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/ARCHITECTURE.md)
- [Компоненты](docs/COMPONENTS.md) - [Компоненты](docs/COMPONENTS.md)
- [Шаблон приложения](docs/APPLICATION_TEMPLATE.md)
- [Иконки](docs/ICONS.md)
- [Темы](docs/THEMING.md) - [Темы](docs/THEMING.md)
- [Окна и слои](docs/WINDOWS_AND_LAYERS.md) - [Окна и слои](docs/WINDOWS_AND_LAYERS.md)
- [Границы исходного аудита](docs/SOURCE_BASELINE.md) - [Границы исходного аудита](docs/SOURCE_BASELINE.md)

View File

@ -1,6 +1,6 @@
{ {
"name": "@nodedc/ui-catalog", "name": "@nodedc/ui-catalog",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -9,8 +9,8 @@
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.2.0", "@nodedc/ui-core": "0.3.0",
"@nodedc/ui-react": "0.2.0", "@nodedc/ui-react": "0.3.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^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 { import {
AdminNavigationPanel, AdminNavigationPanel,
AppHeader, AppHeader,
ApplicationPanel,
ApplicationShell,
Button, Button,
Checker, Checker,
ColorField, ColorField,
@ -11,24 +13,24 @@ import {
Dropdown, Dropdown,
FieldFrame, FieldFrame,
GlassSurface, GlassSurface,
HeaderAvatar,
HeaderProfile, HeaderProfile,
HeaderProfileButton, HeaderProfileButton,
HeaderAvatar,
HeaderWorkspace, HeaderWorkspace,
Icon,
IconButton, IconButton,
Inspector, Inspector,
RangeControl, RangeControl,
SegmentedControl,
Select, Select,
StatusBadge, StatusBadge,
TextAreaField, TextAreaField,
TextField, TextField,
Window, Window,
WindowFooterActions, WindowFooterActions,
type IconName,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "windows" | "inspector"; type CatalogSection = "foundation" | "controls" | "windows" | "modals" | "inspector" | "icons";
type HubMode = "showcase" | "admin" | "platform";
const engineAccentStyle = { const engineAccentStyle = {
"--nodedc-accent-rgb": "148 123 219", "--nodedc-accent-rgb": "148 123 219",
@ -48,6 +50,123 @@ const selectOptions = [
{ value: "disabled", label: "Отключён", description: "Запуск запрещён" }, { value: "disabled", label: "Отключён", description: "Запуск запрещён" },
] as const; ] 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 { function rgbFromHex(hex: string): RgbTuple | null {
const normalized = hex.trim().replace(/^#/, ""); const normalized = hex.trim().replace(/^#/, "");
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null; 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 }: { function Preview({ title, note, children, className }: {
title: string; title: string;
note?: string; note?: string;
@ -97,9 +197,9 @@ function Preview({ title, note, children, className }: {
export function CatalogApp() { export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark"); const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66"); const [accentHex, setAccentHex] = useState("#c3ff66");
const [section, setSection] = useState<CatalogSection>("foundation"); const [guidelineOpen, setGuidelineOpen] = useState(false);
const [hubMode, setHubMode] = useState<HubMode>("showcase"); const [activeSection, setActiveSection] = useState<CatalogSection | null>(null);
const [adminSection, setAdminSection] = useState("invites"); const [panelExpanded, setPanelExpanded] = useState(true);
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active"); const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
const [checker, setChecker] = useState(true); const [checker, setChecker] = useState(true);
const [brightness, setBrightness] = useState(49); const [brightness, setBrightness] = useState(49);
@ -110,8 +210,9 @@ export function CatalogApp() {
const [connectionColor, setConnectionColor] = useState("#404040"); const [connectionColor, setConnectionColor] = useState("#404040");
const [fillColor, setFillColor] = useState("#8f83ff"); const [fillColor, setFillColor] = useState("#8f83ff");
const [strokeColor, setStrokeColor] = useState("#2b2b36"); const [strokeColor, setStrokeColor] = useState("#2b2b36");
const [modalOpen, setModalOpen] = useState(false); const [windowOpen, setWindowOpen] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [projectName, setProjectName] = useState("NODE.DC Platform"); const [projectName, setProjectName] = useState("NODE.DC Platform");
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения."); const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
@ -122,9 +223,16 @@ export function CatalogApp() {
applyNodedcTheme(document.documentElement, { theme, accent }); applyNodedcTheme(document.documentElement, { theme, accent });
}, [accent, theme]); }, [accent, theme]);
const openSection = (next: CatalogSection) => { const openSection = (next: string) => {
setSection(next); const section = next as CatalogSection;
document.getElementById(next)?.scrollIntoView({ behavior: "smooth", block: "start" }); setGuidelineOpen(true);
setActiveSection(section);
setPanelExpanded(true);
};
const closeGuideline = () => {
setGuidelineOpen(false);
setActiveSection(null);
}; };
const inspectorSections = [ const inspectorSections = [
@ -161,9 +269,7 @@ export function CatalogApp() {
</ControlRow> </ControlRow>
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} /> <Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow> <ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
<ControlRow label="Применить ко всем"> <ControlRow label="Применить ко всем"><Button variant="accent" shape="pill" width="full">Применить</Button></ControlRow>
<Button variant="accent" shape="pill" width="full">Применить</Button>
</ControlRow>
</> </>
), ),
}, },
@ -182,102 +288,15 @@ export function CatalogApp() {
}, },
]; ];
return ( const renderSectionContent = () => {
<div className="catalog-app nodedc-ui-root" style={{ "--catalog-accent": accentHex } as CSSProperties}> switch (activeSection) {
<AppHeader case "foundation":
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} return (
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"> <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"> <div className="catalog-theme-row">
<Button variant={theme === "dark" ? "accent" : "secondary"} onClick={() => setTheme("dark")}>Dark</Button> <Button variant={theme === "dark" ? "accent" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
<Button variant={theme === "light" ? "accent" : "secondary"} onClick={() => setTheme("light")}>Light</Button> <Button variant={theme === "light" ? "accent" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
</div> </div>
<div className="catalog-swatches"> <div className="catalog-swatches">
{accents.map((item) => ( {accents.map((item) => (
@ -295,34 +314,42 @@ export function CatalogApp() {
</div> </div>
<ColorField label="Свой accent" value={accentHex} onChange={setAccentHex} /> <ColorField label="Свой accent" value={accentHex} onChange={setAccentHex} />
</Preview> </Preview>
<Preview title="Glass hierarchy" note="default / strong / soft"> <Preview title="Иерархия стекла" note="default / strong / soft">
<div className="catalog-surface-stack"> <div className="catalog-surface-stack">
<GlassSurface padding="md">Default surface</GlassSurface> <GlassSurface padding="md">Default surface</GlassSurface>
<GlassSurface tone="strong" padding="md">Strong floating surface</GlassSurface> <GlassSurface tone="strong" padding="md">Strong floating surface</GlassSurface>
<GlassSurface tone="soft" padding="md">Soft nested surface</GlassSurface> <GlassSurface tone="soft" padding="md">Soft nested surface</GlassSurface>
</div> </div>
</Preview> </Preview>
</div> <Preview title="Каркас Launcher" note="fixed header / stage / panels" className="catalog-preview--wide">
</Section> <div className="catalog-shell-diagram" aria-label="Схема каркаса приложения">
<span className="catalog-shell-diagram__header">Header</span>
<Section id="controls" eyebrow="02 / Primitives" title="Контролы" description="Общие actions, fields и selection primitives для React и DOM consumers."> <span className="catalog-shell-diagram__nav">Navigation</span>
<div className="catalog-grid"> <span className="catalog-shell-diagram__content">Content window</span>
<Preview title="Actions" note="shared geometry"> <span className="catalog-shell-diagram__stage">Stage</span>
<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>
</div> </div>
</Preview> </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"> <div className="catalog-form">
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} /> <TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} /> <TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div> </div>
</Preview> </Preview>
<Preview title="Selection" note="portal dropdown"> <Preview title="Selection" note="integrated / split">
<div className="catalog-form"> <div className="catalog-form">
<FieldFrame label="Hub / integrated select"> <FieldFrame label="Hub / integrated select">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} /> <Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
@ -344,20 +371,20 @@ export function CatalogApp() {
</FieldFrame> </FieldFrame>
<Dropdown <Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => ( 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 }) => ( {({ 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}><Icon name="edit" /><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="copy" /><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="folder" /><span className="nodedc-dropdown-option__label">Архивировать</span></button>
</> </>
)} )}
</Dropdown> </Dropdown>
</div> </div>
</Preview> </Preview>
<Preview title="Environment controls" note="exact Engine geometry"> <Preview title="Environment controls" note="точная геометрия Engine">
<div className="catalog-engine-control-stack" style={engineAccentStyle}> <div className="catalog-engine-control-stack" style={engineAccentStyle}>
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow> <ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} /> <RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
@ -365,77 +392,239 @@ export function CatalogApp() {
<ControlRow label="Применить ко всем"><Button variant="accent" shape="pill" width="full">Применить</Button></ControlRow> <ControlRow label="Применить ко всем"><Button variant="accent" shape="pill" width="full">Применить</Button></ControlRow>
</div> </div>
</Preview> </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"> <div className="catalog-inline catalog-inline--wrap">
<StatusBadge>Черновик</StatusBadge> <StatusBadge>Черновик</StatusBadge>
<StatusBadge tone="success">Готово</StatusBadge> <StatusBadge tone="success">Синхронизировано</StatusBadge>
<StatusBadge tone="warning">Нужна проверка</StatusBadge> <StatusBadge tone="warning">Нужна проверка</StatusBadge>
<StatusBadge tone="danger">Ошибка</StatusBadge> <StatusBadge tone="danger">Ошибка</StatusBadge>
<StatusBadge tone="accent">В работе</StatusBadge>
</div> </div>
</Preview> </Preview>
</div> </div>
</Section> );
case "windows":
<Section id="windows" eyebrow="03 / Layers" title="Окна и подтверждения" description="Modal и modeless side panel используют общий portal, Escape и focus restore; trap и scroll lock остаются только у modal."> return (
<Preview title="Controlled windows" note="center / end / confirm"> <div className="catalog-grid catalog-grid--single">
<div className="catalog-inline catalog-inline--wrap"> <Preview title="Управляемые окна" note="center / modeless end">
<Button variant="accent" onClick={() => setModalOpen(true)}>Открыть окно</Button> <p className="catalog-preview__explanation">Обычное окно блокирует фон и удерживает фокус. Правый инспектор не затемняет, не блокирует stage и не превращается в modal.</p>
<Button onClick={() => setInspectorOpen(true)}>Открыть инспектор</Button> <div className="catalog-inline catalog-inline--wrap">
<Button variant="danger" onClick={() => setConfirmOpen(true)}>Проверить удаление</Button> <Button variant="accent" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
</div> <Button shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть инспектор</Button>
</Preview> </div>
</Section> </Preview>
<Preview title="Оконные действия" note="круг `46.72 px`">
<Section id="inspector" eyebrow="04 / Pattern" title="Inspector / Settings" description="Канон основан только на новом Environment Settings и NDC Agent Inspector, без legacy-инспекторов Engine."> <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"> <div className="catalog-inspector-stage">
<GlassSurface tone="strong" radius="panel" className="catalog-inspector-card" style={engineAccentStyle}> <GlassSurface tone="strong" radius="panel" className="catalog-inspector-card" style={engineAccentStyle}>
<div className="catalog-inspector-title"><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div> <div className="catalog-inspector-title"><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div>
<Inspector sections={inspectorSections} defaultOpen={["illumination", "connections", "ports"]} /> <Inspector sections={inspectorSections} defaultOpen={["illumination", "connections", "ports"]} />
</GlassSurface> </GlassSurface>
</div> </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 const activeDefinition = activeSection ? sectionDefinitions[activeSection] : null;
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 return (
open={inspectorOpen} <>
title="Настройки окружения" <ApplicationShell
subtitle="ENGINE / новый канон" className="catalog-app nodedc-ui-root"
placement="end" style={{ "--catalog-accent": accentHex } as CSSProperties}
style={engineAccentStyle} navigationOpen={guidelineOpen}
onClose={() => setInspectorOpen(false)} contentOpen={Boolean(activeSection)}
> contentExpanded={panelExpanded}
<Inspector sections={inspectorSections} defaultOpen={["illumination", "connections", "ports"]} /> header={
</Window> <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={
<>
<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}
/>
<ConfirmationModal <Window
open={confirmOpen} open={windowOpen}
title="Удалить конфигурацию?" title="Настройки сервиса"
description={<>Конфигурация будет удалена. Это действие нельзя отменить.</>} subtitle="Launcher / CMS / SEO"
confirmLabel="Удалить" onClose={() => setWindowOpen(false)}
danger footer={
onClose={() => setConfirmOpen(false)} <>
onConfirm={() => setConfirmOpen(false)} <Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button>
/> <WindowFooterActions><Button variant="accent" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions>
</div> </>
}
>
<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="ENGINE / новый канон"
placement="end"
style={engineAccentStyle}
onClose={() => setInspectorOpen(false)}
>
<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="Удалить конфигурацию?"
description={<>Конфигурация будет удалена. Это действие нельзя отменить.</>}
confirmLabel="Удалить"
danger
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
/>
</>
); );
} }

View File

@ -2,22 +2,20 @@
background: var(--nodedc-canvas); background: var(--nodedc-canvas);
} }
html { html,
scroll-behavior: smooth; body,
#root {
width: 100%;
min-width: 320px;
height: 100%;
min-height: 100%;
margin: 0;
overflow: hidden;
} }
body { body {
min-width: 320px; background: #050506;
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); color: var(--nodedc-text-primary);
transition: background-color 220ms ease, color 220ms ease;
} }
button, button,
@ -30,96 +28,100 @@ textarea {
min-height: 100vh; min-height: 100vh;
} }
.catalog-admin-layer { .catalog-header-nav {
position: fixed; display: inline-flex;
z-index: 390; min-height: var(--nodedc-header-pill-height);
top: 6.55rem; align-items: center;
bottom: 1.25rem; border-radius: var(--nodedc-radius-circle);
left: 1.25rem; 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); 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; display: grid;
width: min(1180px, calc(100% - 2rem)); max-width: 28rem;
margin: 0 auto; gap: 0.24rem;
gap: 8rem; pointer-events: none;
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);
} }
.catalog-main[data-admin-open="true"] { .catalog-launcher-stage__title span,
width: min(1180px, calc(100% - 25rem)); .catalog-launcher-stage__title strong {
margin-right: 1rem; font-size: clamp(2rem, 3vw, 3.25rem);
margin-left: 24rem; 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; display: grid;
min-height: 32rem; align-content: start;
align-content: center; gap: 1rem;
justify-items: start; padding: 0.15rem 0.1rem 1rem;
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;
} }
.catalog-grid { .catalog-grid {
@ -132,8 +134,12 @@ textarea {
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.1fr 0.9fr;
} }
.catalog-grid--single {
grid-template-columns: 1fr;
}
.catalog-preview { .catalog-preview {
min-height: 18rem; min-height: 16rem;
} }
.catalog-preview--wide { .catalog-preview--wide {
@ -145,7 +151,7 @@ textarea {
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 1.5rem;
} }
.catalog-preview__head strong { .catalog-preview__head strong {
@ -155,24 +161,35 @@ textarea {
.catalog-preview__head span { .catalog-preview__head span {
color: var(--nodedc-text-muted); color: var(--nodedc-text-muted);
font-size: 0.68rem; font-size: 0.68rem;
text-align: right;
text-transform: uppercase; text-transform: uppercase;
} }
.catalog-preview__body { .catalog-preview__body {
display: grid; display: grid;
min-height: 10rem; min-height: 8rem;
align-content: center; align-content: center;
gap: 1rem; 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-theme-row,
.catalog-inline { .catalog-inline,
.catalog-window-actions-demo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
} }
.catalog-inline--wrap { .catalog-inline--wrap,
.catalog-window-actions-demo {
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -212,6 +229,14 @@ textarea {
gap: 0.75rem; 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 { .catalog-engine-control-stack {
display: grid; display: grid;
width: min(var(--nodedc-inspector-inner-width), 100%); width: min(var(--nodedc-inspector-inner-width), 100%);
@ -219,24 +244,49 @@ textarea {
gap: 0.75rem; gap: 0.75rem;
} }
.catalog-surface-stack .nodedc-glass { .catalog-shell-diagram {
min-height: 4rem; display: grid;
display: flex; min-height: 16rem;
align-items: center; grid-template-columns: minmax(8rem, 0.28fr) minmax(15rem, 1fr) minmax(7rem, 0.22fr);
color: var(--nodedc-text-secondary); grid-template-rows: 3.2rem 1fr;
font-size: 0.78rem; 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 { .catalog-inspector-stage {
display: flex; display: flex;
min-height: 40rem; min-height: 43rem;
justify-content: flex-end; justify-content: flex-end;
align-items: flex-start; align-items: flex-start;
padding: 4rem; border-radius: var(--nodedc-radius-card);
border-radius: 2rem;
background: background:
radial-gradient(circle at 18% 24%, rgba(var(--nodedc-accent-rgb), 0.13), transparent 22rem), 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); color-mix(in srgb, var(--nodedc-canvas-soft) 86%, transparent);
padding: 2rem;
} }
.catalog-inspector-card { .catalog-inspector-card {
@ -255,17 +305,89 @@ textarea {
font-size: 0.7rem; font-size: 0.7rem;
} }
@media (max-width: 820px) { .catalog-icon-catalog {
.catalog-main { display: grid;
gap: 6rem; gap: 1rem;
padding-top: 2rem; }
}
.catalog-main[data-admin-open="true"] { .catalog-icon-group {
width: min(1180px, calc(100% - 2rem)); display: grid;
margin-inline: auto; 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,
.catalog-grid--foundation { .catalog-grid--foundation {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -275,14 +397,92 @@ textarea {
grid-column: auto; grid-column: auto;
} }
.catalog-inspector-stage { .catalog-icon-grid {
padding: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr));
} }
}
.catalog-admin-layer {
top: 0.875rem; @media (max-width: 900px) {
right: 0.875rem; .catalog-icon-grid {
bottom: 0.875rem; grid-template-columns: repeat(2, minmax(0, 1fr));
left: 0.875rem; }
}
@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; - Dropdown/Select;
- Window/Confirmation; - Window/Confirmation;
- AppHeader. - AppHeader.
- ApplicationShell/ApplicationPanel;
- Icon/IconButton.
Локальные реализации удаляются только после функционального и визуального сравнения. Локальные реализации удаляются только после функционального и визуального сравнения.
@ -40,14 +42,17 @@
## Новые приложения ## Новые приложения
Шаблон приложения пока не входит в scope. До его появления новый проект должен: Новый React-проект начинает с `ApplicationShell`:
- установить опубликованные NODE.DC UI packages; - установить опубликованные NODE.DC UI packages;
- выбрать theme и accent; - выбрать theme и accent;
- использовать AppHeader и Window/Dropdown primitives; - передать фиксированный AppHeader, stage, navigation и content;
- использовать Window/Dropdown и канонический Icon вместо локальных реализаций;
- проверять registry перед созданием локального control; - проверять registry перед созданием локального control;
- хранить доменные компоненты у себя. - хранить доменные компоненты у себя.
Нижняя service rail добавляется только продуктам с витриной сервисов. В обычном оконном приложении она отсутствует.
## Adoption matrix ## Adoption matrix
| Приложение | Текущий источник | Целевой adapter | Первый набор | | Приложение | Текущий источник | Целевой adapter | Первый набор |
@ -66,4 +71,3 @@
- не поддерживать отдельную тему путём fork компонента; - не поддерживать отдельную тему путём fork компонента;
- не использовать legacy Engine inspector как промежуточный канон; - не использовать legacy Engine inspector как промежуточный канон;
- не удалять production local component до проверки package replacement. - не удалять 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-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке. `@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
### DOM ### DOM
`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`. `@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`.
@ -44,6 +46,8 @@ Core не зависит от React.
- floating layers; - floating layers;
- окна; - окна;
- навигационная геометрия; - навигационная геометрия;
- application shell и фиксированная шапка;
- канонический набор иконок;
- inspector/settings layout; - inspector/settings layout;
- состояния загрузки, пустоты и ошибки. - состояния загрузки, пустоты и ошибки.
@ -76,4 +80,3 @@ Core не зависит от React.
- major — удаление/переименование экспорта или несовместимая геометрия/семантика. - major — удаление/переименование экспорта или несовместимая геометрия/семантика.
Приложение должно зависеть от опубликованной версии пакета, а не от копии файлов или произвольного commit URL. Приложение должно зависеть от опубликованной версии пакета, а не от копии файлов или произвольного commit URL.

View File

@ -95,11 +95,32 @@ Pill navigation для верхней панели и компактного п
В Hub-форме центр состоит из круглого workspace trigger и segmented navigation. Справа используется единая profile-group поверхность с иконкой, подписью и круглым avatar. Эти элементы нельзя заменять случайными standalone-кнопками, сохраняя только приблизительную позицию. В 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 ## AdminNavigationPanel
Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity. Левая выезжающая панель 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 ## 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/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` использован как исходный инвентарь. Он больше не должен развиваться как независимая копия после подключения центрального репозитория. Существовавший `dc-ui-guideline` использован как исходный инвентарь. Он больше не должен развиваться как независимая копия после подключения центрального репозитория.
## SEO и CMS ## SEO и CMS
@ -48,6 +50,10 @@ CMS подтверждает необходимость DOM-пакета без
Используется как ограничение архитектуры: общий UI должен работать без React. В baseline включены общие требования к toolbar, settings menu и glass select, но не BIM-domain controls. Используется как ограничение архитектуры: общий 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", "name": "nodedc-design-guideline",
"version": "0.2.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodedc-design-guideline", "name": "nodedc-design-guideline",
"version": "0.2.0", "version": "0.3.0",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*"
@ -25,10 +25,10 @@
}, },
"apps/catalog": { "apps/catalog": {
"name": "@nodedc/ui-catalog", "name": "@nodedc/ui-catalog",
"version": "0.2.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.2.0", "@nodedc/ui-core": "0.3.0",
"@nodedc/ui-react": "0.2.0", "@nodedc/ui-react": "0.3.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@ -1539,6 +1539,15 @@
"yallist": "^3.0.2" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1879,27 +1888,28 @@
}, },
"packages/tokens": { "packages/tokens": {
"name": "@nodedc/tokens", "name": "@nodedc/tokens",
"version": "0.2.0" "version": "0.3.0"
}, },
"packages/ui-core": { "packages/ui-core": {
"name": "@nodedc/ui-core", "name": "@nodedc/ui-core",
"version": "0.2.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@nodedc/tokens": "0.2.0" "@nodedc/tokens": "0.3.0"
} }
}, },
"packages/ui-dom": { "packages/ui-dom": {
"name": "@nodedc/ui-dom", "name": "@nodedc/ui-dom",
"version": "0.2.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.2.0" "@nodedc/ui-core": "0.3.0"
} }
}, },
"packages/ui-react": { "packages/ui-react": {
"name": "@nodedc/ui-react", "name": "@nodedc/ui-react",
"version": "0.2.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@nodedc/ui-core": "0.2.0" "@nodedc/ui-core": "0.3.0",
"lucide-react": "^0.468.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.0", "@types/react": "^19.1.0",

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@nodedc/ui-core", "name": "@nodedc/ui-core",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"files": ["dist", "styles.css"], "files": ["dist", "styles.css"],
"main": "./dist/index.js", "main": "./dist/index.js",
@ -17,6 +17,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "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; 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 { .nodedc-header {
display: grid; display: grid;
min-height: 4.25rem; min-height: 4.25rem;
@ -603,6 +611,7 @@ textarea.nodedc-field__control {
min-height: calc(var(--nodedc-header-pill-height) - 0.64rem); min-height: calc(var(--nodedc-header-pill-height) - 0.64rem);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.45rem;
border: 0; border: 0;
border-radius: var(--nodedc-radius-circle); border-radius: var(--nodedc-radius-circle);
outline: 0; outline: 0;
@ -691,6 +700,189 @@ textarea.nodedc-field__control {
color: var(--nodedc-glass-control-active-text); 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 { .nodedc-admin-panel {
position: relative; position: relative;
display: grid; 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; 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 { @keyframes nodedc-admin-panel-in {
from { opacity: 0; transform: translateX(-1.6rem); } from { opacity: 0; transform: translateX(-1.6rem); }
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
@ -915,6 +1111,7 @@ textarea.nodedc-field__control {
} }
.nodedc-admin-panel__nav-item { .nodedc-admin-panel__nav-item {
outline: 0;
cursor: pointer; cursor: pointer;
transition: background 160ms ease, color 160ms ease, opacity 160ms ease, transform 160ms ease; 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); 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 { .nodedc-admin-panel__nav-icon {
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -1514,9 +1720,30 @@ textarea.nodedc-field__control {
font-size: var(--nodedc-font-size-lg); 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) { @media (max-width: 760px) {
.nodedc-header-shell {
width: 100%;
padding: 0.85rem 1rem 0.65rem;
background: var(--nodedc-canvas);
}
.nodedc-header { .nodedc-header {
min-height: 8.6rem;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-content: start;
row-gap: 0.65rem;
} }
.nodedc-header__center { .nodedc-header__center {
@ -1530,6 +1757,54 @@ textarea.nodedc-field__control {
min-width: max-content; 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"] { .nodedc-window[data-placement="end"] {
width: calc(100vw - 1.75rem); width: calc(100vw - 1.75rem);
max-height: calc(100vh - 1.75rem); max-height: calc(100vh - 1.75rem);
@ -1554,6 +1829,7 @@ textarea.nodedc-field__control {
.nodedc-dropdown-surface, .nodedc-dropdown-surface,
.nodedc-overlay, .nodedc-overlay,
.nodedc-window, .nodedc-window,
.nodedc-application-panel,
.nodedc-inspector__section-content { .nodedc-inspector__section-content {
animation: none; animation: none;
} }

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
brand: ReactNode; brand: ReactNode;
brandHref?: string; brandHref?: string;
brandLabel?: string; brandLabel?: string;
fixed?: boolean;
left?: ReactNode; left?: ReactNode;
center?: ReactNode; center?: ReactNode;
right?: ReactNode; right?: ReactNode;
@ -14,6 +15,7 @@ export function AppHeader({
brand, brand,
brandHref, brandHref,
brandLabel = "NODE.DC", brandLabel = "NODE.DC",
fixed = true,
left, left,
center, center,
right, right,
@ -27,7 +29,7 @@ export function AppHeader({
); );
return ( 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">
<div className="nodedc-header__left">{brandNode}{left}</div> <div className="nodedc-header__left">{brandNode}{left}</div>
<div className="nodedc-header__center">{center}</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 "./AppHeader";
export * from "./AdminNavigationPanel"; export * from "./AdminNavigationPanel";
export * from "./ApplicationShell";
export * from "./Button"; export * from "./Button";
export * from "./Checker"; export * from "./Checker";
export * from "./ConfirmationModal"; export * from "./ConfirmationModal";
@ -7,6 +8,7 @@ export * from "./Dropdown";
export * from "./Field"; export * from "./Field";
export * from "./Glass"; export * from "./Glass";
export * from "./Inspector"; export * from "./Inspector";
export * from "./Icon";
export * from "./RangeControl"; export * from "./RangeControl";
export * from "./SegmentedControl"; export * from "./SegmentedControl";
export * from "./Select"; export * from "./Select";

View File

@ -148,7 +148,37 @@
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"], "anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
"rules": [ "rules": [
"The logo occupies the same visual position and geometry across applications.", "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"], "anatomy": ["eyebrow and title", "close action", "workspace context", "company context", "route items", "footer identity"],
"rules": [ "rules": [
"The panel shell, full-bleed pills and icon circles are library geometry; routes and identity data remain application-owned.", "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." "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", "schemaVersion": "1.0.0",
"libraryVersion": "0.2.0", "libraryVersion": "0.3.0",
"name": "NODE.DC Design System", "name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.", "purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [ "packages": [
@ -28,6 +28,7 @@
"sourceRegistry": "sources.json", "sourceRegistry": "sources.json",
"componentRegistry": "components.json", "componentRegistry": "components.json",
"candidateRegistry": "candidates.json", "candidateRegistry": "candidates.json",
"iconRegistry": "icons.json",
"documentation": { "documentation": {
"architecture": "../docs/ARCHITECTURE.md", "architecture": "../docs/ARCHITECTURE.md",
"components": "../docs/COMPONENTS.md", "components": "../docs/COMPONENTS.md",
@ -36,7 +37,9 @@
"baseline": "../docs/SOURCE_BASELINE.md", "baseline": "../docs/SOURCE_BASELINE.md",
"governance": "../docs/GOVERNANCE.md", "governance": "../docs/GOVERNANCE.md",
"adoption": "../docs/ADOPTION.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." "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 sources = await parse("registry/sources.json");
const components = await parse("registry/components.json"); const components = await parse("registry/components.json");
const candidates = await parse("registry/candidates.json"); const candidates = await parse("registry/candidates.json");
const icons = await parse("registry/icons.json");
const failures = []; const failures = [];
const requireValue = (condition, message) => { 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(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(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(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); const ids = components.components.map((component) => component.id);
requireValue(new Set(ids).size === ids.length, "component ids must be unique"); requireValue(new Set(ids).size === ids.length, "component ids must be unique");
const candidateIds = candidates.candidates.map((component) => component.id); const candidateIds = candidates.candidates.map((component) => component.id);
requireValue(new Set(candidateIds).size === candidateIds.length, "candidate ids must be unique"); 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"); 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) { for (const component of components.components) {
requireValue(component.id && component.status && component.summary, `component entry is incomplete: ${component.id ?? "unknown"}`); 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); 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.`);