Make application shell components package-native
This commit is contained in:
parent
9261d3af36
commit
ebae662b52
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
- хранить доменные компоненты у себя.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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-ом.
|
||||
|
|
@ -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 или другому компоненту поверхности.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createAccentVariables, type RgbTuple } from "./accent";
|
||||
import { createAccentVariables, type RgbTuple } from "./accent.js";
|
||||
|
||||
export type NodedcTheme = "dark" | "light";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface AdminNavigationContext {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 332–352 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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue