Make application shell components package-native

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 12:41:12 +03:00
parent 9261d3af36
commit ebae662b52
52 changed files with 1036 additions and 185 deletions

View File

@ -1,5 +1,15 @@
# Changelog
## 0.4.0 — package-native application contract
- Corrected `AppHeader` against the production Launcher two-layer geometry: 68 px outer toolbar and 48 px visible row.
- Made the Launcher header preset strict and added reusable `HeaderNavigation`, workspace and profile primitives.
- Added `useApplicationWorkspace` and `createApplicationWorkspaceController` so navigation/content open, close and expand behavior is shared instead of reimplemented per application.
- Added `MediaSourceField` and a DOM media-source controller from the common Launcher/CMS file/URL/preview pattern.
- Added reusable `SettingsCard` and `Switch` patterns and an executable media/settings catalog section.
- Added the package consumption contract and recorded the boundary between reusable UI and application storage/domain adapters.
- Made all published ESM entrypoints directly importable outside Vite by emitting explicit `.js` module specifiers.
## 0.3.0 — launcher application template
- Rebuilt the living catalog as a Launcher-style windowed application instead of a long documentation page.

View File

@ -13,6 +13,8 @@
- segmented navigation и статусы;
- Hub header и левая admin navigation panel;
- launcher-style ApplicationShell без обязательной нижней витрины;
- единый state-controller открытия navigation/content окон;
- CMS/Launcher media field: файл, URL, превью и storage-adapter;
- аудированный общий набор иконок;
- инспектор/панель настроек нового поколения;
- поведение portal-слоёв, Escape, outside click и фокуса;
@ -49,7 +51,7 @@ npm run build
## Статус
Версия `0.3.x` — готовый launcher-style шаблон приложения, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются.
Версия `0.4.x` — пакетный Launcher-пресет шапки и оконного workspace, media/settings patterns, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются.
Подробности:
@ -62,4 +64,5 @@ npm run build
- [Границы исходного аудита](docs/SOURCE_BASELINE.md)
- [Правила развития](docs/GOVERNANCE.md)
- [План подключения приложений](docs/ADOPTION.md)
- [Как подключать пакеты в новый проект](docs/CONSUMPTION.md)
- [Инвентарь следующих компонентов](docs/CANDIDATES.md)

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -14,23 +14,28 @@ import {
FieldFrame,
GlassSurface,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
HeaderProfileButton,
HeaderWorkspace,
Icon,
IconButton,
Inspector,
MediaSourceField,
RangeControl,
Select,
SettingsCard,
StatusBadge,
Switch,
TextAreaField,
TextField,
useApplicationWorkspace,
Window,
WindowFooterActions,
type IconName,
} from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "windows" | "modals" | "inspector" | "icons";
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "inspector" | "icons";
const engineAccentStyle = {
"--nodedc-accent-rgb": "148 123 219",
@ -63,26 +68,32 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
description: "Кнопки, поля, selection, Engine-настройки и понятные статусы операций.",
icon: "sliders",
},
media: {
eyebrow: "03 / MEDIA & SETTINGS",
title: "Медиа и настройки",
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
icon: "image",
},
windows: {
eyebrow: "03 / WINDOWS",
eyebrow: "04 / WINDOWS",
title: "Окна",
description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.",
icon: "apps",
},
modals: {
eyebrow: "04 / MODALS",
eyebrow: "05 / MODALS",
title: "Модалки",
description: "Создание, подтверждение и разрушительные действия в адаптивном формате.",
icon: "clipboard",
},
inspector: {
eyebrow: "05 / INSPECTOR",
eyebrow: "06 / INSPECTOR",
title: "Инспектор",
description: "Точный новый Engine Environment Settings без legacy-инспекторов.",
icon: "settings",
},
icons: {
eyebrow: "06 / ICONS",
eyebrow: "07 / ICONS",
title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid",
@ -197,9 +208,8 @@ function Preview({ title, note, children, className }: {
export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66");
const [guidelineOpen, setGuidelineOpen] = useState(false);
const [activeSection, setActiveSection] = useState<CatalogSection | null>(null);
const [panelExpanded, setPanelExpanded] = useState(true);
const workspace = useApplicationWorkspace<CatalogSection>();
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
const [checker, setChecker] = useState(true);
const [brightness, setBrightness] = useState(49);
@ -216,6 +226,10 @@ export function CatalogApp() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [projectName, setProjectName] = useState("NODE.DC Platform");
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
const [mediaSource, setMediaSource] = useState<"file" | "url">("file");
const [mediaUrl, setMediaUrl] = useState("");
const [mediaFileName, setMediaFileName] = useState("hub_vitrina-mqv7yd7k.mov");
const [mediaVisible, setMediaVisible] = useState(true);
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
@ -224,16 +238,10 @@ export function CatalogApp() {
}, [accent, theme]);
const openSection = (next: string) => {
const section = next as CatalogSection;
setGuidelineOpen(true);
setActiveSection(section);
setPanelExpanded(true);
workspace.openView(next as CatalogSection);
};
const closeGuideline = () => {
setGuidelineOpen(false);
setActiveSection(null);
};
const closeGuideline = workspace.closeNavigation;
const inspectorSections = [
{
@ -403,6 +411,38 @@ export function CatalogApp() {
</Preview>
</div>
);
case "media":
return (
<div className="catalog-grid catalog-grid--single">
<SettingsCard
eyebrow="CONTENT"
title="Видео-окно"
description="Окно демо-видео и подключённый медиа-файл. Хранилище и загрузка остаются в приложении; геометрия и переключение источника — в библиотеке."
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
>
<MediaSourceField
label="Видео / картинка"
source={mediaSource}
url={mediaUrl}
fileName={mediaFileName}
previewSrc={mediaSource === "url" && mediaUrl ? mediaUrl : "/launcher-stage-poster.png"}
previewKind={mediaSource === "file" ? "image" : undefined}
path="content.videoSrc → assets/uploads/media/component-library"
hint="Адаптер приложения сохраняет файл и возвращает URL; компонент не знает API storage."
onSourceChange={setMediaSource}
onUrlChange={setMediaUrl}
onFileChange={(file) => {
if (file) setMediaFileName(file.name);
}}
/>
</SettingsCard>
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide">
<p className="catalog-preview__explanation">
`MediaSourceField` владеет пикселями, доступностью и состояниями file/URL. Конкретный проект передаёт upload-обработчик, storage-path и результат валидации поэтому компонент можно подключить к CMS, Launcher или будущему приложению без копирования его вёрстки.
</p>
</Preview>
</div>
);
case "windows":
return (
<div className="catalog-grid catalog-grid--single">
@ -413,7 +453,7 @@ export function CatalogApp() {
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть инспектор</Button>
</div>
</Preview>
<Preview title="Оконные действия" note="круг `46.72 px`">
<Preview title="Оконные действия" note="круг `46 px`">
<div className="catalog-window-actions-demo">
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
@ -485,7 +525,7 @@ export function CatalogApp() {
className="catalog-app nodedc-ui-root"
style={{ "--catalog-accent": accentHex } as CSSProperties}
navigationOpen={guidelineOpen}
contentOpen={Boolean(activeSection)}
contentOpen={workspace.contentOpen}
contentExpanded={panelExpanded}
header={
<AppHeader
@ -493,24 +533,19 @@ export function CatalogApp() {
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>
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl="/nodedc-mark.svg" />
<HeaderNavigation
label="Навигация приложения"
value={guidelineOpen ? "guideline" : undefined}
items={[{ value: "guideline", label: "Guideline" }]}
onChange={() => guidelineOpen ? closeGuideline() : workspace.openNavigation()}
/>
</>
}
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" /></IconButton>
<HeaderProfileButton><Icon name="profile" size={17} />Профиль</HeaderProfileButton>
<IconButton label="Уведомления"><Icon name="inbox" size={20} /></IconButton>
<HeaderProfileButton>Профиль</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>
}
@ -546,7 +581,7 @@ export function CatalogApp() {
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.3</span>
<span>Design System 0.4</span>
</>
}
/>
@ -558,8 +593,8 @@ export function CatalogApp() {
title={activeDefinition.title}
description={activeDefinition.description}
expanded={panelExpanded}
onExpandedChange={setPanelExpanded}
onClose={() => setActiveSection(null)}
onExpandedChange={workspace.setContentExpanded}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderSectionContent()}</div>
</ApplicationPanel>

View File

@ -28,26 +28,6 @@ textarea {
min-height: 100vh;
}
.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);
}
.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);
@ -413,11 +393,11 @@ textarea {
display: none;
}
.catalog-header-nav {
.catalog-app .nodedc-header-navigation {
width: 100%;
}
.catalog-header-nav .nodedc-header__nav-item {
.catalog-app .nodedc-header-navigation .nodedc-header__nav-item {
width: 100%;
}

View File

@ -20,13 +20,14 @@
- Window/Confirmation;
- AppHeader.
- ApplicationShell/ApplicationPanel;
- useApplicationWorkspace;
- Icon/IconButton.
Локальные реализации удаляются только после функционального и визуального сравнения.
### 3. CMS и SEO
Используют те же компоненты и механику окон. CMS подключает DOM-layer, SEO — React-layer. Цветовые различия оформляются theme overrides.
Используют те же компоненты и механику окон. CMS подключает DOM-layer, SEO — React-layer. Первым общим admin pattern подключается `MediaSourceField`/`createMediaSourceController`; storage API остаётся в приложении. Цветовые различия оформляются theme overrides.
### 4. Engine
@ -47,6 +48,8 @@
- установить опубликованные NODE.DC UI packages;
- выбрать theme и accent;
- передать фиксированный AppHeader, stage, navigation и content;
- управлять shell через `useApplicationWorkspace`, а не набор локально связанных boolean;
- передавать storage/upload в `MediaSourceField` как adapter callback;
- использовать Window/Dropdown и канонический Icon вместо локальных реализаций;
- проверять registry перед созданием локального control;
- хранить доменные компоненты у себя.

View File

@ -4,13 +4,13 @@
## Состав
1. `AppHeader` — фиксированная трёхосевая шапка `logo / workspace + navigation / profile`.
1. `AppHeader` — фиксированная трёхосевая шапка `logo / workspace + navigation / profile`; внешний toolbar `68 px`, видимая строка `48 px`.
2. `stage` — главное медиа- или предметное окно приложения.
3. `navigation` — левая панель шириной `clamp(332px, 19.5vw, 352px)`.
4. `content` — отдельное рабочее окно справа от навигации.
5. `ApplicationPanel` — заголовок, действия expand/close и независимо прокручиваемое тело контента.
Позиции шапки не пересчитываются под конкретное приложение. Меняются подписи, доступные navigation actions, профиль и содержимое stage.
Позиции шапки не пересчитываются под конкретное приложение. Меняются подписи, доступные navigation actions, профиль и содержимое stage. `HeaderNavigation`, `HeaderWorkspace`, `HeaderProfile`, `HeaderProfileButton` и `HeaderAvatar` должны использоваться как готовые части пресета; consumer не назначает им собственные размеры и offsets.
## Состояния desktop
@ -21,6 +21,8 @@
| Оконный режим | слева | средняя колонка | остаётся видимым справа |
| Развёрнутый режим | слева | до правого края | уезжает за viewport |
Эти переходы принадлежат `useApplicationWorkspace` в React и `createApplicationWorkspaceController` в DOM. Оба API реализуют одинаковые команды: `openNavigation`, `closeNavigation`, `toggleNavigation`, `openView`, `closeView`, `setContentExpanded`.
Между панелями используется page gap `20px`. На Guideline-главной нижней rail нет: она является продуктовым элементом витрины Launcher, а не частью общего shell.
## Узкий viewport
@ -37,4 +39,4 @@ Modal остаётся portal-слоем. Его тело прокручивае
- пункты navigation;
- предметное содержимое content window.
Shell владеет геометрией, слоями, переходами и breakpoint-поведением.
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.

View File

@ -27,11 +27,11 @@ Core не зависит от React.
`@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
### DOM
`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`.
`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React, включая те же workspace- и media-source state machines. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`.
### Catalog

View File

@ -14,7 +14,6 @@ Baseline намеренно отделяет готовые exports от изв
- ServiceRailCard — зрелый Launcher pattern, пока связанный с service catalog data.
- WorkItemCard — зрелый Ops pattern, который нужно разделить на общий card shell и task-domain content.
- MediaSourceField — общий preview/picker, но storage adapter остаётся в приложении.
- Toast.
- Tooltip.
- Empty/Loading/Error states.

View File

@ -91,7 +91,7 @@ Pill navigation для верхней панели и компактного п
- по центру workspace/navigation;
- справа actions/profile.
Компонент фиксирует положение логотипа, высоту и выравнивание, но не знает маршруты конкретного приложения.
Компонент фиксирует положение логотипа, высоту и выравнивание, но не знает маршруты конкретного приложения. Launcher-пресет состоит из внешнего toolbar `68 px` и отдельной видимой строки `48 px`; логотип, центральный switcher и profile-group центрируются по строке `48 px`, а не по всей внешней высоте. Эта двухслойная геометрия закрыта внутри компонента и не переопределяется локальным `className/style`.
В Hub-форме центр состоит из круглого workspace trigger и segmented navigation. Справа используется единая profile-group поверхность с иконкой, подписью и круглым avatar. Эти элементы нельзя заменять случайными standalone-кнопками, сохраняя только приблизительную позицию.
@ -108,8 +108,20 @@ Pill navigation для верхней панели и компактного п
- на мобильном navigation и content используют всю область под шапкой последовательно;
- нижняя service rail не входит в шаблон — это элемент витрины Launcher.
`useApplicationWorkspace` является каноническим контроллером этих состояний для React. Приложение не должно заново связывать набор локальных boolean-state для navigation/content/expand. DOM-проекты используют идентичный `createApplicationWorkspaceController`.
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
## MediaSourceField
Общий компонент Launcher/CMS для медиа-поля: file control, имя файла, URL input, переключатель `HD / URL`, круглое превью, path/hint/error. Публичный API контролируемый.
Компонент владеет геометрией, доступностью и выбором источника. Приложение владеет file picker/media library, загрузкой в storage, валидацией, разрешениями и сохраняемым URL. Поэтому один и тот же компонент подключается к разным backend без fork. Для Vanilla DOM используется `createMediaSourceController`.
## SettingsCard и Switch
`SettingsCard` фиксирует нейтральную структуру админской группы: eyebrow/title/description/actions/body. `Switch` покрывает компактное включение/видимость внутри таких групп. Данные секции, сохранение и права остаются в consumer.
## AdminNavigationPanel
Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity.

48
docs/CONSUMPTION.md Normal file
View File

@ -0,0 +1,48 @@
# Подключение дизайн-системы
## Главный принцип
Новый проект не копирует исходники компонентов из Launcher, CMS или этого репозитория. Он устанавливает версию пакетов NODE.DC, импортирует общий CSS и собирает интерфейс из публичных exports. Обновление пакета должно исправлять геометрию во всех подключённых приложениях без повторной ручной вёрстки.
`control-c / control-v` допустим только для короткой композиции приложения: какие routes и данные переданы готовым компонентам. CSS, DOM-анатомия header, оконные переходы, media control и accessibility не копируются.
## React consumer
Минимальный состав нового оконного приложения:
1. `@nodedc/tokens`, `@nodedc/ui-core` и `@nodedc/ui-react` фиксируются одной версией.
2. Один раз подключаются `tokens.css`, `themes.css` и `@nodedc/ui-core/styles.css`.
3. Theme/accent задаются через `applyNodedcTheme`.
4. Приложение собирает `ApplicationShell` из `AppHeader`, `AdminNavigationPanel` и `ApplicationPanel`.
5. Все состояния shell берутся из `useApplicationWorkspace`.
6. Routes, profile, stage content и бизнес-данные передаются props.
Нельзя добавлять local CSS к `AppHeader`, `HeaderNavigation`, `HeaderWorkspace`, `HeaderProfile` и `HeaderAvatar`. Если production Launcher изменяет канон, исправляется preset здесь и выпускается новая версия.
## DOM consumer
CMS/BIM подключают те же tokens/core styles и `@nodedc/ui-dom`. `createApplicationWorkspaceController`, `createMediaSourceController`, `createFloatingLayer` и `createModalController` связывают стабильную DOM-анатомию с тем же поведением, не вводя React в приложение.
## Adapter boundary
Общие компоненты не импортируют API конкретного проекта.
| Библиотека владеет | Приложение владеет |
| --- | --- |
| размеры, радиусы, поверхности | маршруты и тексты |
| header/window/media DOM anatomy | API, auth и permissions |
| open/close/expand state machine | получение и сохранение данных |
| file/URL selector и preview | file picker/media library/storage upload |
| focus, Escape, portal, responsive flow | доменная валидация и ошибки backend |
Например, `MediaSourceField` получает `onFileChange`. Consumer загружает файл, получает URL и возвращает его в контролируемое состояние. Компонент не знает S3, локальный диск, CMS media library или endpoint Launcher.
## Definition of done для нового общего компонента
- экспортируется из React или DOM package;
- использует tokens/core styles без screen-local geometry;
- показан в живом каталоге в ключевых состояниях;
- записан в `registry/components.json`;
- имеет документированную adapter boundary;
- проходит desktop/mobile visual QA;
- версия пакета зафиксирована consumer-ом.

View File

@ -7,7 +7,7 @@
| Контекст | Размер glyph | Hit target |
| --- | ---: | ---: |
| Плотная строка/label | `16px` | задаёт строка |
| Обычная кнопка | `18px` | `46.72px` круг или кнопка с текстом |
| Обычная кнопка | `18px` | `46px` круг или кнопка с текстом |
| Крупное оконное действие | `20px` | `46.72px` круг |
Базовый stroke — `1.8`. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности.

View File

@ -17,6 +17,8 @@
`src/app/LauncherApp.tsx`, `src/widgets/service-stage/ServiceStage.tsx` и те же global styles подтверждают общий application shell: stage сдвигается при открытии navigation/content, а content разворачивается независимо. Нижняя ServiceRail исключена из общего шаблона как продуктовая часть витрины.
Точный header состоит из внешнего toolbar `68 px` и внутренней видимой строки `48 px`. Именно внутренняя строка задаёт вертикальный центр logo/workspace/navigation/profile. `AdminOverlay.tsx` также подтверждает media-source pattern: `3.35rem` control, `2.78rem` file/source/preview controls и разделение UI от storage handler.
Существовавший `dc-ui-guideline` использован как исходный инвентарь. Он больше не должен развиваться как независимая копия после подключения центрального репозитория.
## SEO и CMS
@ -25,7 +27,7 @@
SEO содержит большой объём накопленных локальных overrides. Они являются материалом аудита, но не переносятся автоматически. В библиотеку попадает очищенный общий контракт.
CMS подтверждает необходимость DOM-пакета без React.
CMS подтверждает необходимость DOM-пакета без React. `admin/admin.js` и `admin/admin.css` независимо повторяют устойчивый media pattern с file/URL source, круглым preview, path и upload hint; поэтому он перенесён в baseline, а не оставлен product candidate.
## Engine

24
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nodedc-design-guideline",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodedc-design-guideline",
"version": "0.3.0",
"version": "0.4.0",
"workspaces": [
"packages/*",
"apps/*"
@ -25,10 +25,10 @@
},
"apps/catalog": {
"name": "@nodedc/ui-catalog",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@nodedc/ui-core": "0.3.0",
"@nodedc/ui-react": "0.3.0",
"@nodedc/ui-core": "0.4.0",
"@nodedc/ui-react": "0.4.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@ -1888,27 +1888,27 @@
},
"packages/tokens": {
"name": "@nodedc/tokens",
"version": "0.3.0"
"version": "0.4.0"
},
"packages/ui-core": {
"name": "@nodedc/ui-core",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@nodedc/tokens": "0.3.0"
"@nodedc/tokens": "0.4.0"
}
},
"packages/ui-dom": {
"name": "@nodedc/ui-dom",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@nodedc/ui-core": "0.3.0"
"@nodedc/ui-core": "0.4.0"
}
},
"packages/ui-react": {
"name": "@nodedc/ui-react",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@nodedc/ui-core": "0.3.0",
"@nodedc/ui-core": "0.4.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "nodedc-design-guideline",
"version": "0.3.0",
"version": "0.4.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.3.0",
"version": "0.4.0",
"type": "module",
"files": ["tokens.json", "tokens.css", "themes.css"],
"exports": {

View File

@ -17,6 +17,7 @@
--nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72);
--nodedc-glass-panel-bg-strong: rgba(18, 18, 21, 0.88);
--nodedc-glass-panel-bg-soft: rgba(42, 42, 46, 0.5);
--nodedc-header-pill-bg: rgba(64, 64, 64, 0.48);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.014)), var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)), var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-control-bg: rgba(110, 110, 110, 0.07);
@ -50,6 +51,7 @@
--nodedc-glass-panel-bg: rgba(255, 255, 255, 0.48);
--nodedc-glass-panel-bg-strong: rgba(255, 255, 255, 0.72);
--nodedc-glass-panel-bg-soft: rgba(255, 255, 255, 0.34);
--nodedc-header-pill-bg: rgba(255, 255, 255, 0.56);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.22)), var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.34)), var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-control-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.22)), rgba(255, 255, 255, 0.38);
@ -64,4 +66,3 @@
--nodedc-glass-dropdown-shadow: 0 22px 60px rgba(24, 32, 29, 0.2), 0 6px 18px rgba(24, 32, 29, 0.1);
--nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.1);
}

View File

@ -13,6 +13,7 @@
--nodedc-icon-button-size: 2.875rem;
--nodedc-header-row-height: 3rem;
--nodedc-header-pill-height: 3.45rem;
--nodedc-header-control-height: 2.78rem;
--nodedc-header-logo-width: 7.25rem;
--nodedc-header-logo-height: 1.79rem;
--nodedc-inspector-width: 24.375rem;

View File

@ -24,6 +24,7 @@
"iconButton": { "$type": "dimension", "$value": "46px" },
"headerRow": { "$type": "dimension", "$value": "48px" },
"headerPill": { "$type": "dimension", "$value": "55.2px" },
"headerControl": { "$type": "dimension", "$value": "44.48px" },
"logoWidth": { "$type": "dimension", "$value": "116px" },
"logoHeight": { "$type": "dimension", "$value": "28.64px" },
"inspectorWidth": { "$type": "dimension", "$value": "390px" },

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-core",
"version": "0.3.0",
"version": "0.4.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.3.0"
"@nodedc/tokens": "0.4.0"
}
}

View File

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

View File

@ -1,4 +1,4 @@
import { createAccentVariables, type RgbTuple } from "./accent";
import { createAccentVariables, type RgbTuple } from "./accent.js";
export type NodedcTheme = "dark" | "light";

View File

@ -277,6 +277,299 @@ textarea.nodedc-field__control {
resize: vertical;
}
.nodedc-media-field {
display: grid;
min-width: 0;
gap: 0.42rem;
}
.nodedc-media-field__label-row {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: var(--nodedc-space-2);
padding-inline: 0.15rem;
}
.nodedc-media-field__label,
.nodedc-media-field__kind {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.nodedc-media-field__kind {
border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.07);
padding: 0.16rem 0.56rem;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.05em;
line-height: 1;
text-transform: uppercase;
}
.nodedc-media-control {
display: grid;
min-height: 3.35rem;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.22rem;
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-field-bg);
padding: 0.25rem;
box-shadow: var(--nodedc-glass-control-shadow);
}
.nodedc-media-file {
display: flex;
min-width: 0;
height: 100%;
align-items: center;
gap: 0.7rem;
padding-left: 0.15rem;
}
.nodedc-media-file[hidden],
.nodedc-media-url[hidden] {
display: none;
}
.nodedc-media-file input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.nodedc-media-file__button {
display: inline-flex;
min-height: 2.78rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.92);
color: rgba(8, 8, 10, 0.96);
padding: 0 1rem;
font-size: 0.78rem;
font-weight: 850;
white-space: nowrap;
cursor: pointer;
}
.nodedc-media-file__button:hover {
background: #fff;
}
.nodedc-media-file__name {
min-width: 0;
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.78rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-media-url {
min-width: 0;
height: 100%;
border: 0;
border-radius: var(--nodedc-radius-circle);
outline: 0;
background: transparent;
color: var(--nodedc-text-primary);
padding: 0 0.82rem;
font: inherit;
font-size: 0.78rem;
}
.nodedc-media-url::placeholder {
color: var(--nodedc-text-muted);
}
.nodedc-media-source-switch {
display: inline-flex;
align-items: center;
gap: 0.12rem;
}
.nodedc-media-source-button {
display: grid;
width: 2.78rem;
height: 2.78rem;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: var(--nodedc-radius-circle);
background: transparent;
color: var(--nodedc-text-secondary);
font: inherit;
font-size: 0.68rem;
font-weight: 800;
cursor: pointer;
}
.nodedc-media-source-button:hover {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-media-source-button[data-active="true"] {
border-color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.92);
color: rgba(8, 8, 10, 0.96);
}
.nodedc-media-preview {
position: relative;
display: grid;
width: 2.78rem;
min-width: 2.78rem;
height: 2.78rem;
place-items: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--nodedc-radius-circle);
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.16) 75%),
linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.16) 75%),
rgba(18, 18, 20, 0.92);
background-position: 0 0, 6px 6px, 0 0;
background-size: 12px 12px, 12px 12px, auto;
color: var(--nodedc-text-secondary);
}
.nodedc-media-preview img,
.nodedc-media-preview video {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
}
.nodedc-media-field__path,
.nodedc-media-field__hint,
.nodedc-media-field__error {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
}
.nodedc-media-field__error {
color: rgb(var(--nodedc-danger-rgb));
}
.nodedc-settings-card {
display: grid;
min-width: 0;
gap: 1rem;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-glass-control-bg);
padding: 1rem;
}
.nodedc-settings-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.nodedc-settings-card__titles {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.nodedc-settings-card__titles > span {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.nodedc-settings-card__titles h2,
.nodedc-settings-card__titles p {
margin: 0;
}
.nodedc-settings-card__titles h2 {
color: var(--nodedc-text-primary);
font-size: 1rem;
line-height: 1.25;
}
.nodedc-settings-card__titles p {
color: var(--nodedc-text-muted);
font-size: 0.75rem;
line-height: 1.4;
}
.nodedc-settings-card__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.4rem;
}
.nodedc-settings-card__body {
display: grid;
min-width: 0;
gap: 0.8rem;
}
.nodedc-switch {
display: inline-flex;
width: max-content;
align-items: center;
gap: 0.48rem;
border: 0;
background: transparent;
color: var(--nodedc-text-secondary);
font: inherit;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
cursor: pointer;
}
.nodedc-switch__track {
display: flex;
width: 2rem;
height: 1.12rem;
align-items: center;
border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.14);
padding: 0.14rem;
transition: background var(--nodedc-duration-normal) var(--nodedc-ease-standard);
}
.nodedc-switch__track > span {
width: 0.84rem;
height: 0.84rem;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-text-primary);
transition: transform var(--nodedc-duration-normal) var(--nodedc-ease-standard);
}
.nodedc-switch[aria-checked="true"] .nodedc-switch__track {
background: rgb(var(--nodedc-accent-rgb));
}
.nodedc-switch[aria-checked="true"] .nodedc-switch__track > span {
transform: translateX(0.88rem);
}
.nodedc-checker {
display: grid;
width: 100%;
@ -541,8 +834,16 @@ textarea.nodedc-field__control {
}
.nodedc-header {
display: grid;
display: flex;
min-height: 4.25rem;
width: 100%;
flex-direction: column;
}
.nodedc-header__row {
display: grid;
min-height: var(--nodedc-header-row-height);
width: 100%;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--nodedc-space-4);
@ -589,26 +890,27 @@ textarea.nodedc-field__control {
.nodedc-header__profile {
display: inline-flex;
height: var(--nodedc-header-pill-height);
min-height: var(--nodedc-header-pill-height);
align-items: center;
gap: 0.2rem;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-glass-panel-bg-soft);
background: var(--nodedc-header-pill-bg);
padding: 0.32rem;
box-shadow: var(--nodedc-glass-control-shadow);
}
.nodedc-header__profile .nodedc-icon-button {
width: var(--nodedc-header-row-height);
height: var(--nodedc-header-row-height);
flex-basis: var(--nodedc-header-row-height);
width: var(--nodedc-header-control-height);
height: var(--nodedc-header-control-height);
flex-basis: var(--nodedc-header-control-height);
background: transparent;
box-shadow: none;
}
.nodedc-header__profile-button {
display: inline-flex;
min-height: calc(var(--nodedc-header-pill-height) - 0.64rem);
min-height: var(--nodedc-header-control-height);
align-items: center;
justify-content: center;
gap: 0.45rem;
@ -639,8 +941,8 @@ textarea.nodedc-field__control {
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background:
radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.72), transparent 24%),
linear-gradient(135deg, rgb(166, 194, 109), rgb(142, 123, 139));
radial-gradient(circle at 70% 18%, rgba(255, 255, 255, 0.8), transparent 24%),
linear-gradient(135deg, #c8c9cb, #8d8f96 55%, #f1f1f1);
color: rgba(8, 8, 10, 0.96);
font-size: 0.72rem;
font-weight: 800;
@ -660,15 +962,23 @@ textarea.nodedc-field__control {
place-items: center;
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-canvas);
background: transparent;
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
}
.nodedc-header__workspace img {
display: block;
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
}
.nodedc-header__workspace[data-kind="avatar"] img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
}
@ -676,9 +986,20 @@ textarea.nodedc-field__control {
display: contents;
}
.nodedc-header-navigation {
display: inline-flex;
min-height: var(--nodedc-header-pill-height);
align-items: center;
gap: 0.18rem;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-header-pill-bg);
padding: 0.32rem;
box-shadow: none;
}
.nodedc-header__nav-item {
display: inline-flex;
min-height: calc(var(--nodedc-header-pill-height) - 0.64rem);
min-height: var(--nodedc-header-control-height);
align-items: center;
justify-content: center;
border-radius: var(--nodedc-radius-circle);
@ -1741,6 +2062,10 @@ textarea.nodedc-field__control {
.nodedc-header {
min-height: 8.6rem;
}
.nodedc-header__row {
min-height: 8.6rem;
grid-template-columns: minmax(0, 1fr) auto;
align-content: start;
row-gap: 0.65rem;
@ -1753,7 +2078,8 @@ textarea.nodedc-field__control {
overflow-x: auto;
}
.nodedc-header__center .nodedc-segmented {
.nodedc-header__center .nodedc-segmented,
.nodedc-header__center .nodedc-header-navigation {
min-width: max-content;
}
@ -1823,6 +2149,21 @@ textarea.nodedc-field__control {
align-items: stretch;
gap: var(--nodedc-space-2);
}
.nodedc-media-control {
grid-template-columns: minmax(0, 1fr) auto;
border-radius: var(--nodedc-radius-card);
}
.nodedc-media-file,
.nodedc-media-url {
grid-column: 1 / -1;
min-height: 2.78rem;
}
.nodedc-media-source-switch {
justify-self: start;
}
}
@media (prefers-reduced-motion: reduce) {
@ -1837,6 +2178,8 @@ textarea.nodedc-field__control {
.nodedc-button,
.nodedc-icon-button,
.nodedc-checker,
.nodedc-media-source-button,
.nodedc-switch,
.nodedc-select-trigger,
.nodedc-segmented__item {
transition: none;

View File

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

View File

@ -0,0 +1,60 @@
export interface ApplicationWorkspaceSnapshot<ViewId extends string = string> {
navigationOpen: boolean;
activeView: ViewId | null;
contentOpen: boolean;
contentExpanded: boolean;
}
export interface ApplicationWorkspaceController<ViewId extends string = string> {
getState: () => ApplicationWorkspaceSnapshot<ViewId>;
openNavigation: () => void;
toggleNavigation: () => void;
closeNavigation: () => void;
openView: (view: ViewId) => void;
closeView: () => void;
setContentExpanded: (expanded: boolean) => void;
}
export interface ApplicationWorkspaceOptions<ViewId extends string = string> {
root?: HTMLElement;
navigationOpen?: boolean;
activeView?: ViewId | null;
contentExpanded?: boolean;
onChange?: (state: ApplicationWorkspaceSnapshot<ViewId>) => void;
}
export function createApplicationWorkspaceController<ViewId extends string = string>({
root,
navigationOpen = false,
activeView = null,
contentExpanded = true,
onChange,
}: ApplicationWorkspaceOptions<ViewId> = {}): ApplicationWorkspaceController<ViewId> {
let state: ApplicationWorkspaceSnapshot<ViewId> = {
navigationOpen: navigationOpen || activeView !== null,
activeView,
contentOpen: activeView !== null,
contentExpanded,
};
const commit = (patch: Partial<ApplicationWorkspaceSnapshot<ViewId>>) => {
state = { ...state, ...patch };
state.contentOpen = state.activeView !== null;
root?.toggleAttribute("data-navigation-open", state.navigationOpen);
root?.toggleAttribute("data-content-open", state.contentOpen);
root?.toggleAttribute("data-content-expanded", state.contentOpen && state.contentExpanded);
onChange?.({ ...state });
};
commit({});
return {
getState: () => ({ ...state }),
openNavigation: () => commit({ navigationOpen: true }),
toggleNavigation: () => commit({ navigationOpen: !state.navigationOpen, activeView: state.navigationOpen ? null : state.activeView }),
closeNavigation: () => commit({ navigationOpen: false, activeView: null }),
openView: (view) => commit({ navigationOpen: true, activeView: view, contentExpanded: true }),
closeView: () => commit({ activeView: null }),
setContentExpanded: (expanded) => commit({ contentExpanded: expanded }),
};
}

View File

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

View File

@ -0,0 +1,55 @@
export type MediaSource = "file" | "url";
export interface MediaSourceController {
getSource: () => MediaSource;
setSource: (source: MediaSource) => void;
destroy: () => void;
}
export interface MediaSourceControllerOptions {
root: HTMLElement;
source?: MediaSource;
onChange?: (source: MediaSource) => void;
}
export function createMediaSourceController({ root, source = "file", onChange }: MediaSourceControllerOptions): MediaSourceController {
const options = Array.from(root.querySelectorAll<HTMLElement>("[data-nodedc-media-source-option]"));
const panels = Array.from(root.querySelectorAll<HTMLElement>("[data-nodedc-media-source-panel]"));
let current = source;
const render = () => {
root.dataset.source = current;
options.forEach((option) => {
const active = option.dataset.nodedcMediaSourceOption === current;
option.dataset.active = active ? "true" : "false";
option.setAttribute("aria-pressed", String(active));
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.nodedcMediaSourcePanel !== current;
});
};
const setSource = (next: MediaSource) => {
if (next === current) return;
current = next;
render();
onChange?.(current);
};
const handlers = options.map((option) => {
const handler = () => {
const next = option.dataset.nodedcMediaSourceOption;
if (next === "file" || next === "url") setSource(next);
};
option.addEventListener("click", handler);
return { option, handler };
});
render();
return {
getSource: () => current,
setSource,
destroy: () => handlers.forEach(({ option, handler }) => option.removeEventListener("click", handler)),
};
}

View File

@ -1,4 +1,4 @@
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating";
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating.js";
export interface SelectControllerOptions<T extends string> extends Omit<FloatingLayerOptions, "closeOnSelect"> {
value: T;

View File

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

View File

@ -1,5 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface AdminNavigationContext {
id: string;

View File

@ -1,11 +1,9 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
export interface AppHeaderProps {
brand: ReactNode;
brandHref?: string;
brandLabel?: string;
fixed?: boolean;
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
@ -15,12 +13,9 @@ export function AppHeader({
brand,
brandHref,
brandLabel = "NODE.DC",
fixed = true,
left,
center,
right,
className,
...props
}: AppHeaderProps) {
const brandNode = brandHref ? (
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
@ -29,45 +24,87 @@ export function AppHeader({
);
return (
<header className={cn("nodedc-header-shell", className)} data-fixed={fixed ? "true" : undefined} {...props}>
<header className="nodedc-header-shell" data-fixed="true" data-preset="launcher">
<div className="nodedc-header">
<div className="nodedc-header__row">
<div className="nodedc-header__left">{brandNode}{left}</div>
<div className="nodedc-header__center">{center}</div>
<div className="nodedc-header__right">{right}</div>
</div>
</div>
</header>
);
}
export function HeaderProfile({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-header__profile", className)} {...props}>{children}</div>;
export interface HeaderNavigationItem<T extends string> {
value: T;
label: ReactNode;
disabled?: boolean;
}
export function HeaderProfileButton({ className, children, type = "button", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
return <button type={type} className={cn("nodedc-header__profile-button", className)} {...props}>{children}</button>;
export interface HeaderNavigationProps<T extends string> {
label: string;
value?: T;
items: readonly HeaderNavigationItem<T>[];
onChange: (value: T) => void;
}
export interface HeaderAvatarProps extends HTMLAttributes<HTMLSpanElement> {
export function HeaderNavigation<T extends string>({ label, value, items, onChange }: HeaderNavigationProps<T>) {
return (
<nav className="nodedc-header-navigation" aria-label={label}>
{items.map((item) => (
<button
key={item.value}
type="button"
className="nodedc-header__nav-item"
data-active={item.value === value ? "true" : undefined}
aria-current={item.value === value ? "page" : undefined}
disabled={item.disabled}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
))}
</nav>
);
}
export interface HeaderProfileProps {
children: ReactNode;
}
export function HeaderProfile({ children }: HeaderProfileProps) {
return <div className="nodedc-header__profile">{children}</div>;
}
export interface HeaderProfileButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "style"> {}
export function HeaderProfileButton({ children, type = "button", ...props }: HeaderProfileButtonProps) {
return <button type={type} className="nodedc-header__profile-button" {...props}>{children}</button>;
}
export interface HeaderAvatarProps {
label: string;
imageUrl?: string;
}
export function HeaderAvatar({ label, imageUrl, className, ...props }: HeaderAvatarProps) {
export function HeaderAvatar({ label, imageUrl }: HeaderAvatarProps) {
return (
<span className={cn("nodedc-header__avatar", className)} title={label} {...props}>
<span className="nodedc-header__avatar" title={label}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);
}
export interface HeaderWorkspaceProps extends HTMLAttributes<HTMLSpanElement> {
export interface HeaderWorkspaceProps {
label: string;
imageUrl?: string;
kind?: "mark" | "avatar";
}
export function HeaderWorkspace({ label, imageUrl, className, ...props }: HeaderWorkspaceProps) {
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
return (
<span className={cn("nodedc-header__workspace", className)} title={label} {...props}>
<span className="nodedc-header__workspace" data-kind={kind} title={label}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);

View File

@ -1,6 +1,6 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { Icon } from "./Icon";
import { cn } from "./cn.js";
import { Icon } from "./Icon.js";
export interface ApplicationShellProps extends Omit<HTMLAttributes<HTMLDivElement>, "content"> {
header: ReactNode;

View File

@ -0,0 +1,67 @@
import { useCallback, useState } from "react";
export interface ApplicationWorkspaceState<ViewId extends string> {
navigationOpen: boolean;
activeView: ViewId | null;
contentOpen: boolean;
contentExpanded: boolean;
}
export interface ApplicationWorkspaceController<ViewId extends string> extends ApplicationWorkspaceState<ViewId> {
openNavigation: () => void;
toggleNavigation: () => void;
closeNavigation: () => void;
openView: (view: ViewId) => void;
closeView: () => void;
setContentExpanded: (expanded: boolean) => void;
}
export interface ApplicationWorkspaceInitialState<ViewId extends string> {
navigationOpen?: boolean;
activeView?: ViewId | null;
contentExpanded?: boolean;
}
/** Owns the Launcher-derived navigation/content-window state machine. */
export function useApplicationWorkspace<ViewId extends string>({
navigationOpen: initialNavigationOpen = false,
activeView: initialActiveView = null,
contentExpanded: initialContentExpanded = true,
}: ApplicationWorkspaceInitialState<ViewId> = {}): ApplicationWorkspaceController<ViewId> {
const [navigationOpen, setNavigationOpen] = useState(initialNavigationOpen || initialActiveView !== null);
const [activeView, setActiveView] = useState<ViewId | null>(initialActiveView);
const [contentExpanded, setContentExpanded] = useState(initialContentExpanded);
const openNavigation = useCallback(() => setNavigationOpen(true), []);
const closeView = useCallback(() => setActiveView(null), []);
const closeNavigation = useCallback(() => {
setNavigationOpen(false);
setActiveView(null);
}, []);
const toggleNavigation = useCallback(() => {
if (navigationOpen) {
setNavigationOpen(false);
setActiveView(null);
} else {
setNavigationOpen(true);
}
}, [navigationOpen]);
const openView = useCallback((view: ViewId) => {
setNavigationOpen(true);
setActiveView(view);
setContentExpanded(true);
}, []);
return {
navigationOpen,
activeView,
contentOpen: activeView !== null,
contentExpanded,
openNavigation,
toggleNavigation,
closeNavigation,
openView,
closeView,
setContentExpanded,
};
}

View File

@ -1,6 +1,6 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn";
import { cn } from "./cn.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact";

View File

@ -1,5 +1,5 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;

View File

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "./Button";
import { Window, WindowFooterActions } from "./Window";
import { Button } from "./Button.js";
import { Window, WindowFooterActions } from "./Window.js";
export interface ConfirmationModalProps {
open: boolean;

View File

@ -10,7 +10,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface DropdownTriggerApi {
open: boolean;

View File

@ -1,5 +1,5 @@
import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface FieldFrameProps {
label: string;

View File

@ -1,5 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal";

View File

@ -1,5 +1,5 @@
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface InspectorSectionSpec {
id: string;

View File

@ -0,0 +1,118 @@
import { useId, type ChangeEvent, type ReactNode } from "react";
import { Icon } from "./Icon.js";
export type MediaSource = "file" | "url";
export type MediaPreviewKind = "image" | "gif" | "video";
export interface MediaSourceFieldProps {
label: string;
kindLabel?: string;
source: MediaSource;
url: string;
fileName?: string | null;
uploading?: boolean;
previewSrc?: string | null;
previewKind?: MediaPreviewKind | null;
accept?: string;
fileButtonLabel?: string;
emptyFileLabel?: string;
path?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
onSourceChange: (source: MediaSource) => void;
onUrlChange: (url: string) => void;
onFileChange: (file?: File) => void | Promise<void>;
}
function inferredPreviewKind(src: string): MediaPreviewKind {
if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src)) return "video";
if (/\.gif(\?.*)?$/i.test(src)) return "gif";
return "image";
}
function MediaPreview({ src, kind }: { src: string; kind?: MediaPreviewKind | null }) {
const resolvedKind = kind ?? inferredPreviewKind(src);
if (resolvedKind === "video") return <video src={src} autoPlay loop muted playsInline />;
return <img src={src} alt="" />;
}
export function MediaSourceField({
label,
kindLabel = "media",
source,
url,
fileName,
uploading = false,
previewSrc,
previewKind,
accept = "image/*,video/*",
fileButtonLabel = "Выберите файл",
emptyFileLabel = "Файл не выбран",
path,
hint,
error,
onSourceChange,
onUrlChange,
onFileChange,
}: MediaSourceFieldProps) {
const inputId = useId();
const displayFileName = uploading ? "Сохраняем в storage..." : (fileName || emptyFileLabel);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
void onFileChange(event.currentTarget.files?.[0]);
event.currentTarget.value = "";
};
return (
<div className="nodedc-media-field">
<div className="nodedc-media-field__label-row">
<span className="nodedc-media-field__label">{label}</span>
<span className="nodedc-media-field__kind">{kindLabel}</span>
</div>
<div className="nodedc-media-control" data-source={source}>
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
</div>
<input
className="nodedc-media-url"
type="url"
value={url}
hidden={source !== "url"}
data-nodedc-media-source-panel="url"
placeholder="https://..."
autoComplete="off"
aria-label={`${label}: внешняя ссылка`}
onChange={(event) => onUrlChange(event.target.value)}
/>
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
<button
type="button"
className="nodedc-media-source-button"
data-active={source === "file" ? "true" : undefined}
data-nodedc-media-source-option="file"
aria-label="Файл с диска"
aria-pressed={source === "file"}
onClick={() => onSourceChange("file")}
>HD</button>
<button
type="button"
className="nodedc-media-source-button"
data-active={source === "url" ? "true" : undefined}
data-nodedc-media-source-option="url"
aria-label="Внешняя ссылка"
aria-pressed={source === "url"}
onClick={() => onSourceChange("url")}
>URL</button>
</div>
<div className="nodedc-media-preview" aria-hidden="true">
{previewSrc ? <MediaPreview src={previewSrc} kind={previewKind} /> : <Icon name="image" size={16} />}
</div>
</div>
{path ? <span className="nodedc-media-field__path">{path}</span> : null}
{hint ? <span className="nodedc-media-field__hint">{hint}</span> : null}
{error ? <span className="nodedc-media-field__error" role="alert">{error}</span> : null}
</div>
);
}

View File

@ -1,5 +1,5 @@
import type { CSSProperties, InputHTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string;

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface SegmentedItem<T extends string> {
value: T;

View File

@ -1,7 +1,7 @@
import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import type { FloatingPlacement } from "@nodedc/ui-core";
import { Dropdown } from "./Dropdown";
import { cn } from "./cn";
import { Dropdown } from "./Dropdown.js";
import { cn } from "./cn.js";
export interface SelectOption<T extends string> {
value: T;

View File

@ -0,0 +1,48 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn.js";
export interface SettingsCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
eyebrow?: ReactNode;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
}
export function SettingsCard({ eyebrow, title, description, actions, children, className, ...props }: SettingsCardProps) {
return (
<section className={cn("nodedc-settings-card", className)} {...props}>
<header className="nodedc-settings-card__head">
<div className="nodedc-settings-card__titles">
{eyebrow ? <span>{eyebrow}</span> : null}
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="nodedc-settings-card__actions">{actions}</div> : null}
</header>
<div className="nodedc-settings-card__body">{children}</div>
</section>
);
}
export interface SwitchProps {
checked: boolean;
label: string;
disabled?: boolean;
onChange: (checked: boolean) => void;
}
export function Switch({ checked, label, disabled = false, onChange }: SwitchProps) {
return (
<button
type="button"
className="nodedc-switch"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
>
<span className="nodedc-switch__track" aria-hidden="true"><span /></span>
<span>{label}</span>
</button>
);
}

View File

@ -1,5 +1,5 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent";

View File

@ -7,7 +7,7 @@ import {
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn";
import { cn } from "./cn.js";
const focusableSelector = [
"a[href]",

View File

@ -1,16 +1,19 @@
export * from "./AppHeader";
export * from "./AdminNavigationPanel";
export * from "./ApplicationShell";
export * from "./Button";
export * from "./Checker";
export * from "./ConfirmationModal";
export * from "./Dropdown";
export * from "./Field";
export * from "./Glass";
export * from "./Inspector";
export * from "./Icon";
export * from "./RangeControl";
export * from "./SegmentedControl";
export * from "./Select";
export * from "./StatusBadge";
export * from "./Window";
export * from "./AppHeader.js";
export * from "./AdminNavigationPanel.js";
export * from "./ApplicationShell.js";
export * from "./ApplicationWorkspace.js";
export * from "./Button.js";
export * from "./Checker.js";
export * from "./ConfirmationModal.js";
export * from "./Dropdown.js";
export * from "./Field.js";
export * from "./Glass.js";
export * from "./Inspector.js";
export * from "./Icon.js";
export * from "./MediaSourceField.js";
export * from "./RangeControl.js";
export * from "./SegmentedControl.js";
export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./Window.js";

View File

@ -72,16 +72,6 @@
"requiredBehavior": ["selected state", "drag state", "assignees", "dates", "quick actions"],
"reasonNotBaseline": "The visual pattern is mature, but task-domain fields must be separated from the reusable card shell."
},
{
"id": "media-source-field",
"classification": "planned-pattern",
"priority": "P2",
"referenceSources": ["launcher", "seo"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["field", "button", "glass-surface"],
"requiredBehavior": ["preview", "source picker", "validation", "clear/replace"],
"reasonNotBaseline": "File and storage integration remains application-specific and needs adapter slots."
},
{
"id": "toast",
"classification": "missing-primitive",

View File

@ -142,25 +142,28 @@
"id": "app-header",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["AppHeader", "HeaderProfile", "HeaderProfileButton", "HeaderWorkspace", "HeaderAvatar"],
"domContract": ["nodedc-header-shell", "nodedc-header"],
"summary": "Three-axis NODE.DC application header with fixed brand geometry and composable center/right slots.",
"exports": ["AppHeader", "HeaderNavigation", "HeaderProfile", "HeaderProfileButton", "HeaderWorkspace", "HeaderAvatar"],
"domContract": ["nodedc-header-shell", "nodedc-header", "nodedc-header__row"],
"summary": "Strict Launcher-preset NODE.DC header with a 68 px outer toolbar, 48 px visible row and fixed three-axis geometry.",
"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.",
"The canonical application header is fixed and preserves the Launcher three-axis logo / navigation / profile positions."
"The canonical application header is fixed and preserves the Launcher three-axis logo / navigation / profile positions.",
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
]
},
{
"id": "application-shell",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ApplicationShell", "ApplicationPanel"],
"exports": ["ApplicationShell", "ApplicationPanel", "useApplicationWorkspace"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createApplicationWorkspaceController"],
"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"],
"behavior": ["one shared navigation/content state machine", "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.",
@ -195,6 +198,34 @@
"Active route state uses the active surface and icon contrast from the theme."
]
},
{
"id": "media-source-field",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["MediaSourceField"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createMediaSourceController"],
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"],
"summary": "Launcher/CMS media control for file or URL sources, filename, preview and adapter-provided storage state.",
"behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
"rules": [
"The component owns geometry, source switching and accessibility.",
"The consumer owns media library, storage upload, validation and the persisted URL.",
"Do not fork the field for a different backend; provide a different adapter callback."
]
},
{
"id": "settings-card",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["SettingsCard", "Switch"],
"domContract": ["nodedc-settings-card", "nodedc-switch"],
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
"rules": [
"The card owns grouping geometry but not product form schemas.",
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
]
},
{
"id": "status-badge",
"status": "baseline",

View File

@ -1,6 +1,6 @@
{
"schemaVersion": "1.0.0",
"libraryVersion": "0.3.0",
"libraryVersion": "0.4.0",
"name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [
@ -39,7 +39,8 @@
"adoption": "../docs/ADOPTION.md",
"candidates": "../docs/CANDIDATES.md",
"applicationTemplate": "../docs/APPLICATION_TEMPLATE.md",
"icons": "../docs/ICONS.md"
"icons": "../docs/ICONS.md",
"consumption": "../docs/CONSUMPTION.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."
}