Add pink media stage and sharing modal system

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 18:21:14 +03:00
parent ebae662b52
commit f1e28c267a
29 changed files with 917 additions and 64 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## 0.5.0 — media stage and modal inventory
- Switched the default NODE.DC application accent to pink while retaining live accent editing and semantic status colors.
- Reduced icon glyph weight/size by roughly 10% without changing button and circular hit-target geometry.
- Connected `MediaSourceField` MP4 uploads to the Launcher-style catalog stage with safe object URL replacement and cleanup.
- Added the Engine-derived `ShareAccessModal` with compact member stack, roles, invitation and immutable owner states.
- Added the BIM-derived `ShareLinkModal` and framework-neutral `createShareLinkController`.
- Audited BIM form, confirmation, share-link, contextual action, expandable detail and version-history modals and added runnable catalog specimens.
## 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.

View File

@ -15,6 +15,8 @@
- launcher-style ApplicationShell без обязательной нижней витрины;
- единый state-controller открытия navigation/content окон;
- CMS/Launcher media field: файл, URL, превью и storage-adapter;
- Engine workflow sharing и BIM share-link modal;
- каталог form/confirmation/action/detail/history modal-паттернов;
- аудированный общий набор иконок;
- инспектор/панель настроек нового поколения;
- поведение portal-слоёв, Escape, outside click и фокуса;
@ -51,7 +53,7 @@ npm run build
## Статус
Версия `0.4.x` — пакетный Launcher-пресет шапки и оконного workspace, media/settings patterns, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются.
Версия `0.5.x` — розовый NODE.DC accent, runtime MP4 stage media, workflow/BIM sharing modals и полная карта оконных паттернов. Исходные приложения на первом этапе не изменяются.
Подробности:

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import {
AdminNavigationPanel,
@ -24,6 +24,8 @@ import {
MediaSourceField,
RangeControl,
Select,
ShareAccessModal,
ShareLinkModal,
SettingsCard,
StatusBadge,
Switch,
@ -33,22 +35,31 @@ import {
Window,
WindowFooterActions,
type IconName,
type ShareAccessMember,
} from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "inspector" | "icons";
const engineAccentStyle = {
"--nodedc-accent-rgb": "148 123 219",
"--nodedc-on-accent-rgb": "247 248 244",
"--nodedc-accent-rgb": "255 47 146",
"--nodedc-on-accent-rgb": "11 17 23",
} as CSSProperties;
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "Hub", value: [195, 255, 102], hex: "#c3ff66" },
{ label: "Engine", value: [148, 123, 219], hex: "#947bdb" },
{ label: "BIM", value: [78, 164, 255], hex: "#4ea4ff" },
{ label: "SEO", value: [32, 32, 34], hex: "#202022" },
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
{ label: "Rose", value: [255, 62, 108], hex: "#ff3e6c" },
{ label: "Blush", value: [255, 108, 175], hex: "#ff6caf" },
{ label: "Magenta", value: [215, 70, 255], hex: "#d746ff" },
];
type ShareRole = "viewer" | "editor" | "admin";
const shareRoleOptions = [
{ value: "viewer", label: "Просмотр" },
{ value: "editor", label: "Редактор" },
{ value: "admin", label: "Соавтор" },
] satisfies Array<{ value: ShareRole; label: string }>;
const selectOptions = [
{ value: "active", label: "Активен", description: "Сервис доступен пользователям" },
{ value: "maintenance", label: "Техработы", description: "Временно недоступен" },
@ -83,7 +94,7 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
modals: {
eyebrow: "05 / MODALS",
title: "Модалки",
description: "Создание, подтверждение и разрушительные действия в адаптивном формате.",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard",
},
inspector: {
@ -207,7 +218,7 @@ function Preview({ title, note, children, className }: {
export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66");
const [accentHex, setAccentHex] = useState("#ff2f92");
const workspace = useApplicationWorkspace<CatalogSection>();
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
@ -216,20 +227,41 @@ export function CatalogApp() {
const [glowDistance, setGlowDistance] = useState(105);
const [usePortColors, setUsePortColors] = useState(false);
const [connectionType, setConnectionType] = useState("spline");
const [lightColor, setLightColor] = useState("#7a3cff");
const [lightColor, setLightColor] = useState("#ff2f92");
const [connectionColor, setConnectionColor] = useState("#404040");
const [fillColor, setFillColor] = useState("#8f83ff");
const [fillColor, setFillColor] = useState("#ff6caf");
const [strokeColor, setStrokeColor] = useState("#2b2b36");
const [windowOpen, setWindowOpen] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [shareAccessOpen, setShareAccessOpen] = useState(false);
const [shareLinkOpen, setShareLinkOpen] = useState(false);
const [contextActionOpen, setContextActionOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detailExpanded, setDetailExpanded] = 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 [mediaFileName, setMediaFileName] = useState("launcher-stage.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/launcher-stage.mp4");
const [mediaError, setMediaError] = useState("");
const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null);
const [shareEmail, setShareEmail] = useState("");
const [shareRole, setShareRole] = useState<ShareRole>("editor");
const [shareMessage, setShareMessage] = useState("");
const [shareMembers, setShareMembers] = useState<Array<ShareAccessMember<ShareRole>>>([
{ id: "owner", name: "DC Constructions", email: "dc@nodedc.ru", role: "admin", roleLabel: "Владелец", immutable: true },
{ id: "editor", name: "Maria Petrova", email: "maria@nodedc.ru", role: "editor", roleLabel: "Редактор" },
]);
const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl)
? mediaUrl
: null;
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc;
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
@ -237,6 +269,38 @@ export function CatalogApp() {
applyNodedcTheme(document.documentElement, { theme, accent });
}, [accent, theme]);
useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
}, []);
const handleStageMediaFile = (file?: File) => {
if (!file) return;
const isMp4 = file.type === "video/mp4" || /\.mp4$/i.test(file.name);
if (!isMp4) {
setMediaError("Для заставки нужен MP4-файл.");
return;
}
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
const objectUrl = URL.createObjectURL(file);
mediaObjectUrlRef.current = objectUrl;
setFileMediaSrc(objectUrl);
setMediaFileName(file.name);
setMediaSource("file");
setMediaVisible(true);
setMediaError("");
};
const inviteShareMember = () => {
const email = shareEmail.trim().toLowerCase();
if (!email) return;
setShareMembers((current) => [
...current.filter((member) => member.email !== email),
{ id: email, name: email.split("@")[0], email, role: shareRole, roleLabel: shareRoleOptions.find((item) => item.value === shareRole)?.label },
]);
setShareMessage(`Доступ выдан пользователю ${email}`);
setShareEmail("");
};
const openSection = (next: string) => {
workspace.openView(next as CatalogSection);
};
@ -425,15 +489,18 @@ export function CatalogApp() {
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."
previewSrc={stageMediaSrc}
previewKind="video"
accept="video/mp4,.mp4"
path="stage.videoSrc → runtime media source"
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage."
error={mediaError}
onSourceChange={setMediaSource}
onUrlChange={setMediaUrl}
onFileChange={(file) => {
if (file) setMediaFileName(file.name);
onUrlChange={(nextUrl) => {
setMediaUrl(nextUrl);
setMediaError(nextUrl && !/\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на видеофайл." : "");
}}
onFileChange={handleStageMediaFile}
/>
</SettingsCard>
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide">
@ -474,6 +541,26 @@ export function CatalogApp() {
<p className="catalog-preview__explanation">Destructive-цвет появляется у подтверждения, а не окрашивает всё окно заранее.</p>
<Button variant="danger" shape="pill" icon={<Icon name="trash" />} onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
</Preview>
<Preview title="Workflow sharing" note="ENGINE / access modal">
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец. API доступа остаётся в Engine.</p>
<Button variant="accent" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
</Preview>
<Preview title="Ссылка на модель" note="BIM / share link">
<p className="catalog-preview__explanation">Read-only ссылка, copy-state и единая механика focus/Escape/backdrop.</p>
<Button shape="pill" icon={<Icon name="copy" />} onClick={() => setShareLinkOpen(true)}>Открыть ссылку</Button>
</Preview>
<Preview title="Контекстные действия" note="BIM / measurement & object">
<p className="catalog-preview__explanation">Короткое окно действий над выбранным размером или объектом без отдельной modal-системы.</p>
<Button shape="pill" icon={<Icon name="sliders" />} onClick={() => setContextActionOpen(true)}>Действия размера</Button>
</Preview>
<Preview title="История версий" note="BIM / large data modal">
<p className="catalog-preview__explanation">Большое прокручиваемое data-окно; BIM-таблица остаётся доменной композицией внутри общего Window.</p>
<Button shape="pill" icon={<Icon name="list" />} onClick={() => setHistoryOpen(true)}>Открыть историю</Button>
</Preview>
<Preview title="Комментарий к модели" note="BIM / expandable detail">
<p className="catalog-preview__explanation">Рабочая detail-modal с контентом и вложениями, которая может расширяться без смены механики слоя.</p>
<Button shape="pill" icon={<Icon name="clipboard" />} onClick={() => setDetailOpen(true)}>Открыть комментарий</Button>
</Preview>
</div>
);
case "inspector":
@ -553,8 +640,8 @@ export function CatalogApp() {
}
stage={
<section className="catalog-launcher-stage">
<video className="catalog-launcher-stage__media" autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<source src="/launcher-stage.mp4" type="video/mp4" />
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<source src={stageMediaSrc} type="video/mp4" />
</video>
<div className="catalog-launcher-stage__shade" />
<div className="catalog-launcher-stage__title">
@ -581,7 +668,7 @@ export function CatalogApp() {
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.4</span>
<span>Design System 0.5</span>
</>
}
/>
@ -651,6 +738,105 @@ export function CatalogApp() {
</div>
</Window>
<ShareAccessModal<ShareRole>
open={shareAccessOpen}
resourceName="Workflow / Rail infrastructure analysis"
members={shareMembers}
roleOptions={shareRoleOptions}
inviteEmail={shareEmail}
inviteRole={shareRole}
message={shareMessage}
messageTone="accent"
onInviteEmailChange={(email) => {
setShareEmail(email);
setShareMessage("");
}}
onInviteRoleChange={setShareRole}
onInvite={inviteShareMember}
onMemberRoleChange={(member, role) => {
setShareMembers((current) => current.map((item) => item.id === member.id
? { ...item, role, roleLabel: shareRoleOptions.find((option) => option.value === role)?.label }
: item));
}}
onMemberRemove={(member) => setShareMembers((current) => current.filter((item) => item.id !== member.id))}
onClose={() => setShareAccessOpen(false)}
/>
<ShareLinkModal
open={shareLinkOpen}
link="https://bim.nodedc.ru/model/rail-hub?view=main"
onCopy={() => undefined}
onClose={() => setShareLinkOpen(false)}
/>
<Window
open={contextActionOpen}
title="Размер"
subtitle="BIM / contextual actions"
size="sm"
onClose={() => setContextActionOpen(false)}
footer={<WindowFooterActions><Button onClick={() => setContextActionOpen(false)}>Закрыть</Button></WindowFooterActions>}
>
<div className="catalog-modal-action-list">
<Button width="full" icon={<Icon name="activity" />}>Скрыть размер</Button>
<Button width="full" variant="danger" icon={<Icon name="trash" />}>Удалить размер</Button>
</div>
</Window>
<Window
open={historyOpen}
title="История версий"
subtitle="rail-hub-model.glb"
size="lg"
onClose={() => setHistoryOpen(false)}
>
<div className="catalog-history-table" role="table" aria-label="История версий модели">
<div className="catalog-history-row catalog-history-row--head" role="row">
<span>Файл</span><span>Версия</span><span>Автор</span><span>Статус</span><span>Действия</span>
</div>
{[
["rail-hub-model.glb", "v12 · текущая", "DC", "Модель готова"],
["rail-hub-model.glb", "v11", "Maria", "Модель готова"],
["rail-hub-model.glb", "v10", "Alex", "Ожидает подготовки"],
].map((row) => (
<div className="catalog-history-row" role="row" key={row[1]}>
<strong>{row[0]}</strong><span>{row[1]}</span><span>{row[2]}</span><span>{row[3]}</span>
<span className="catalog-history-actions">
<IconButton label="Посмотреть"><Icon name="external" /></IconButton>
<IconButton label="Скачать"><Icon name="download" /></IconButton>
</span>
</div>
))}
</div>
</Window>
<Window
open={detailOpen}
title="Комментарий к модели"
subtitle="Опора №42 · рабочая detail-modal"
size={detailExpanded ? "lg" : "md"}
onClose={() => setDetailOpen(false)}
footer={
<>
<Button icon={<Icon name={detailExpanded ? "minimize" : "expand"} />} onClick={() => setDetailExpanded((current) => !current)}>
{detailExpanded ? "Свернуть" : "Развернуть"}
</Button>
<WindowFooterActions>
<Button onClick={() => setDetailOpen(false)}>Отмена</Button>
<Button variant="accent" onClick={() => setDetailOpen(false)}>Сохранить</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-detail-modal">
<TextField label="Заголовок" value="Проверить узел крепления" readOnly />
<TextAreaField label="Описание" value="Нужно сверить положение опоры с последней версией модели." readOnly />
<SettingsCard title="Вложения" description="Файлы и изображения остаются BIM-domain content.">
<Button icon={<Icon name="upload" />}>Выбрать файл</Button>
</SettingsCard>
</div>
</Window>
<ConfirmationModal
open={confirmOpen}
title="Удалить конфигурацию?"

View File

@ -209,6 +209,56 @@ textarea {
gap: 0.75rem;
}
.catalog-modal-action-list,
.catalog-detail-modal {
display: grid;
gap: 0.75rem;
}
.catalog-history-table {
display: grid;
min-width: 46rem;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-glass-control-bg);
}
.catalog-history-row {
display: grid;
min-height: 3.5rem;
grid-template-columns: minmax(12rem, 1.7fr) minmax(7rem, 0.7fr) minmax(6rem, 0.6fr) minmax(9rem, 0.9fr) 6rem;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: var(--nodedc-text-secondary);
padding: 0.62rem 0.8rem;
font-size: var(--nodedc-font-size-sm);
}
.catalog-history-row:last-child {
border-bottom: 0;
}
.catalog-history-row--head {
min-height: 2.35rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: 760;
text-transform: uppercase;
}
.catalog-history-actions {
display: flex;
justify-content: flex-end;
gap: 0.25rem;
}
.catalog-history-actions .nodedc-icon-button {
width: 2rem;
height: 2rem;
flex-basis: 2rem;
}
.catalog-surface-stack .nodedc-glass {
display: flex;
min-height: 4rem;

View File

@ -79,6 +79,16 @@ Window — единая механика открытия modal и правой
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
## ShareAccessModal
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
Владелец отмечается `immutable`; отсутствие права управления задаётся `canManage`. Backdrop по умолчанию не закрывает access-modal, чтобы пользователь не потерял введённое приглашение.
## ShareLinkModal
Read-only link/copy окно из BIM Viewer. React-компонент использует общий Window, а Vanilla DOM consumer использует `createShareLinkController` вместе с `createModalController`. URL формируется сервисом модели; дизайн-система отвечает только за presentation, copy-state и ошибки адаптера.
## SegmentedControl
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
@ -130,7 +140,7 @@ Pill navigation для верхней панели и компактного п
## Icon
`Icon` предоставляет только подтверждённый общий subset иконок. Каноническое имя описывает смысл (`close`, `expand`, `refresh`), а не конкретный путь SVG. Базовые размеры — `16`, `18` и `20 px`, stroke — `1.8`.
`Icon` предоставляет только подтверждённый общий subset иконок. Каноническое имя описывает смысл (`close`, `expand`, `refresh`), а не конкретный путь SVG. Default glyph — `17 px`, stroke — `1.7`; внутри кнопок glyph оптически уменьшается до `90%`, но размеры surface/hit target остаются прежними.
Поверхность, круглая форма, hit target, active и disabled состояния принадлежат `IconButton`, `Button` или navigation item. Полный список находится в `registry/icons.json` и `docs/ICONS.md`.

View File

@ -21,7 +21,7 @@
## DOM consumer
CMS/BIM подключают те же tokens/core styles и `@nodedc/ui-dom`. `createApplicationWorkspaceController`, `createMediaSourceController`, `createFloatingLayer` и `createModalController` связывают стабильную DOM-анатомию с тем же поведением, не вводя React в приложение.
CMS/BIM подключают те же tokens/core styles и `@nodedc/ui-dom`. `createApplicationWorkspaceController`, `createMediaSourceController`, `createShareLinkController`, `createFloatingLayer` и `createModalController` связывают стабильную DOM-анатомию с тем же поведением, не вводя React в приложение.
## Adapter boundary

View File

@ -6,11 +6,11 @@
| Контекст | Размер glyph | Hit target |
| --- | ---: | ---: |
| Плотная строка/label | `16px` | задаёт строка |
| Обычная кнопка | `18px` | `46px` круг или кнопка с текстом |
| Крупное оконное действие | `20px` | `46.72px` круг |
| Плотная строка/label | `1516px` | задаёт строка |
| Обычная кнопка | `1617px` | `46px` круг или кнопка с текстом |
| Крупное оконное действие | `18px` | `46.72px` круг |
Базовый stroke — `1.8`. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности.
Базовый stroke — `1.7`. Внутри кнопок glyph дополнительно получает optical scale `0.9`; hit target и наружная окружность не уменьшаются. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности.
## Группы

View File

@ -38,19 +38,22 @@ CMS подтверждает необходимость DOM-пакета без
- NdcGlassSelect;
- NdcGlassModal;
- новый NDC Agent Inspector;
- GraphsPanel workflow sharing modal и NdcUserAvatarStack;
- styles, непосредственно обслуживающие эти элементы.
Все остальные legacy inspectors и старые plates не участвуют в принятии решений. Их будущее приведение к новому дизайну должно идти через эту библиотеку.
Размеры зафиксированы непосредственно по разрешённым исходникам: панель `390 px`, контент `330 px`, control row `154/14/162 px`, control height `46 px`, split-select `276/8/46 px`, section header `50 px` и radius `12 px`. Скриншоты от 10 июля 2026 года использованы как визуальная проверка, а не как замена исходному CSS.
Workflow sharing добавлен как отдельный разрешённый reference: modal `600 px`, compact stack до трёх avatar, roles `viewer/editor/admin`, immutable owner и приглашение через email. ACL endpoints, directory lookup и тексты backend-ошибок остаются Engine-domain.
## Task Manager / Ops
Используются только уже стандартизированные shared patterns и документы по dropdown/modal behavior. Известные legacy CustomMenu и screen-local wrappers не являются источником компонентов.
## BIM Viewer
Используется как ограничение архитектуры: общий UI должен работать без React. В baseline включены общие требования к toolbar, settings menu и glass select, но не BIM-domain controls.
Используется как ограничение архитектуры: общий UI должен работать без React. Аудит `frontend/index.html`, `dcViewer.css`, `dcViewer.js`, `measurementModal.js` и `objectModal.js` подтвердил шесть modal-композиций: save form, delete confirmation, share link, contextual measurement/object actions, expandable comment detail и large version history. В библиотеку вынесены общая window-механика и share-link controller; BIM-таблицы, комментарии и операции над моделью остаются domain content.
## Аудит иконок

View File

@ -38,6 +38,8 @@ NODE.DC-приложения отличаются цветовой схемой,
Accent задаётся RGB tuple. On-accent вычисляется по luminance и не должен вручную прописываться белым или чёрным в отдельном компоненте.
Default accent текущего NODE.DC shell — `#ff2f92`. Каталог позволяет менять его через presets и `ColorField`, чтобы проверить любой consumer-цвет без создания новой темы или fork компонентов.
Semantic status не обязан совпадать с accent. Например, ошибка остаётся danger, даже если приложение использует красный брендовый accent.
## Область применения
@ -53,4 +55,3 @@ Portal-компоненты рендерятся в `document.body`, поэто
## Совместимость
Существующие `--nodedc-*` имена сохранены намеренно. Это уменьшает стоимость будущего подключения Launcher, Engine, Task Manager и BIM Viewer.

View File

@ -44,6 +44,20 @@ Inline absolute dropdown внутри card/sidebar/sticky container являет
7. body scroll блокируется;
8. после закрытия focus возвращается на предыдущий trigger.
## Карта modal-паттернов
| Тип | Источник | Каноническая реализация |
| --- | --- | --- |
| Form/create/save | Launcher, CMS, BIM | `Window` + Field/Select + footer actions |
| Destructive confirmation | Launcher, Engine, BIM | `ConfirmationModal` |
| Workflow access sharing | новый Engine | `ShareAccessModal` |
| Resource link sharing | BIM Viewer | `ShareLinkModal` / `createShareLinkController` |
| Context actions | BIM measurement/object | `Window size="sm"` + application-owned actions |
| Expandable detail | BIM comment workspace | `Window` или `ApplicationPanel`, domain content остаётся в BIM |
| Large data/history | BIM version history | `Window size="lg"`, data table остаётся domain composition |
Новый тип появляется только если отличается поведением слоя или имеет устойчивую независимую anatomy. Разная таблица, форма или текст внутри Window не создают новую modal-систему.
## Side window / inspector
Side window использует ту же механику, но placement `end` и ограниченную ширину. Это канон для:

24
package-lock.json generated
View File

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

View File

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

View File

@ -1,7 +1,7 @@
:root,
[data-nodedc-theme="dark"] {
color-scheme: dark;
--nodedc-accent-rgb: 195 255 102;
--nodedc-accent-rgb: 255 47 146;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-danger-rgb: 255 116 116;
--nodedc-warning-rgb: 255 209 102;
@ -35,8 +35,8 @@
[data-nodedc-theme="light"] {
color-scheme: light;
--nodedc-accent-rgb: 32 32 34;
--nodedc-on-accent-rgb: 247 248 244;
--nodedc-accent-rgb: 255 47 146;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-danger-rgb: 205 47 67;
--nodedc-warning-rgb: 173 111 0;
--nodedc-success-rgb: 46 125 76;

View File

@ -25,6 +25,7 @@
--nodedc-inspector-button-radius: 1rem;
--nodedc-select-toggle-width: 2.875rem;
--nodedc-select-gap: 0.5rem;
--nodedc-icon-glyph-scale: 0.9;
--nodedc-space-1: 0.25rem;
--nodedc-space-2: 0.5rem;

View File

@ -3,7 +3,7 @@
"schemaVersion": "1.0.0",
"name": "NODE.DC Design Tokens",
"color": {
"accentRgb": { "$type": "color", "$value": "rgb(195 255 102)" },
"accentRgb": { "$type": "color", "$value": "rgb(255 47 146)" },
"onAccentRgb": { "$type": "color", "$value": "rgb(11 17 23)" },
"dangerRgb": { "$type": "color", "$value": "rgb(255 116 116)" },
"warningRgb": { "$type": "color", "$value": "rgb(255 209 102)" },

View File

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

View File

@ -180,6 +180,7 @@
.nodedc-button__icon > svg {
width: 1rem;
height: 1rem;
transform: scale(var(--nodedc-icon-glyph-scale));
}
.nodedc-icon-button {
@ -213,6 +214,16 @@
.nodedc-icon-button > svg {
width: 1rem;
height: 1rem;
transform: scale(var(--nodedc-icon-glyph-scale));
}
.nodedc-admin-panel__nav-icon > svg,
.nodedc-application-panel__action > svg,
.nodedc-dropdown-option svg,
.nodedc-select__toggle > svg,
.nodedc-confirmation__icon > svg,
.nodedc-media-preview > svg {
transform: scale(var(--nodedc-icon-glyph-scale));
}
.nodedc-field {
@ -1905,6 +1916,212 @@ textarea.nodedc-field__control {
gap: var(--nodedc-space-2);
}
.nodedc-window.nodedc-share-access-window {
width: min(37.5rem, calc(100vw - 2rem));
}
@media (min-width: 641px) {
.nodedc-window.nodedc-share-access-window,
.nodedc-share-access-window .nodedc-window__body {
overflow: visible;
}
}
.nodedc-share-access {
display: grid;
gap: 1rem;
}
.nodedc-share-access__members,
.nodedc-share-access__member-list,
.nodedc-share-access__identity,
.nodedc-share-access__readonly {
display: grid;
min-width: 0;
}
.nodedc-share-access__members {
position: relative;
z-index: 20;
gap: 0.62rem;
}
.nodedc-share-access__section-label {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
}
.nodedc-share-access__member-list {
position: absolute;
top: calc(100% + 0.45rem);
left: 0;
z-index: var(--nodedc-layer-popover);
width: 100%;
max-height: 18rem;
gap: 0.25rem;
overflow-y: auto;
border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-glass-panel-surface-strong);
padding: 0.6rem;
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.2);
-webkit-backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.2);
}
.nodedc-share-access__summary {
position: relative;
display: inline-flex;
width: 5.375rem;
height: 2.375rem;
align-items: center;
border: 0;
outline: 0;
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0;
cursor: pointer;
}
.nodedc-share-access__summary-avatars {
position: relative;
display: block;
width: 4.25rem;
height: 2.125rem;
}
.nodedc-share-access__summary-avatar {
position: absolute;
top: 0.25rem;
display: grid;
width: 1.875rem;
height: 1.875rem;
place-items: center;
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background: rgba(42, 43, 46, 0.96);
color: var(--nodedc-text-primary);
box-shadow: 0 0 0 1px var(--nodedc-canvas);
font-size: 0.62rem;
font-weight: 760;
}
.nodedc-share-access__summary-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.nodedc-share-access__summary-count {
position: absolute;
top: -0.05rem;
right: 0.1rem;
font-size: 0.68rem;
font-weight: 760;
}
.nodedc-share-access__member {
display: grid;
min-height: var(--nodedc-control-height);
grid-template-columns: 2.25rem minmax(0, 1fr) 8.75rem auto;
align-items: center;
gap: 0.65rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
padding: 0.28rem 0.35rem 0.28rem 0.4rem;
}
.nodedc-share-access__avatar {
display: grid;
width: 2.15rem;
height: 2.15rem;
place-items: center;
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.08);
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
font-weight: 760;
}
.nodedc-share-access__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.nodedc-share-access__identity {
gap: 0.12rem;
}
.nodedc-share-access__identity strong,
.nodedc-share-access__identity small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-share-access__identity strong {
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-md);
}
.nodedc-share-access__identity small,
.nodedc-share-access__role {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.nodedc-share-access__member .nodedc-select-trigger {
min-height: var(--nodedc-control-height-compact);
}
.nodedc-share-access__member .nodedc-icon-button {
width: var(--nodedc-control-height-compact);
height: var(--nodedc-control-height-compact);
flex-basis: var(--nodedc-control-height-compact);
background: transparent;
box-shadow: none;
}
.nodedc-share-access__empty,
.nodedc-share-access__readonly {
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-muted);
padding: 0.9rem 1rem;
font-size: var(--nodedc-font-size-sm);
}
.nodedc-share-access__invite {
display: grid;
grid-template-columns: minmax(0, 1fr) 11rem;
align-items: end;
gap: 0.75rem;
}
.nodedc-share-access__readonly {
gap: 0.25rem;
}
.nodedc-share-access__readonly strong {
color: var(--nodedc-text-primary);
}
.nodedc-share-access__message {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.4;
}
.nodedc-share-access__message[data-tone="success"],
.nodedc-share-access__message[data-tone="accent"] {
color: rgb(var(--nodedc-accent-rgb));
}
.nodedc-share-access__message[data-tone="danger"] {
color: rgb(var(--nodedc-danger-rgb));
}
.nodedc-confirmation {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@ -2144,6 +2361,24 @@ textarea.nodedc-field__control {
padding-inline: 0.75rem;
}
.nodedc-share-access__member {
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
}
.nodedc-share-access__member-list {
position: static;
max-height: 14rem;
}
.nodedc-share-access__member .nodedc-dropdown-anchor,
.nodedc-share-access__role {
grid-column: 2 / -1;
}
.nodedc-share-access__invite {
grid-template-columns: 1fr;
}
.nodedc-control-row {
grid-template-columns: 1fr;
align-items: stretch;

View File

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

View File

@ -4,3 +4,4 @@ export * from "./floating.js";
export * from "./mediaSource.js";
export * from "./modal.js";
export * from "./select.js";
export * from "./shareLink.js";

View File

@ -0,0 +1,59 @@
export interface ShareLinkControllerOptions {
input: HTMLInputElement;
copyControl: HTMLButtonElement;
copyLabel?: string;
copiedLabel?: string;
onCopy?: (link: string) => void | Promise<void>;
onError?: (error: unknown) => void;
}
export interface ShareLinkController {
getLink: () => string;
setLink: (link: string) => void;
copy: () => Promise<void>;
destroy: () => void;
}
export function createShareLinkController({
input,
copyControl,
copyLabel = "Скопировать",
copiedLabel = "Скопировано",
onCopy,
onError,
}: ShareLinkControllerOptions): ShareLinkController {
input.readOnly = true;
const render = (copied = false) => {
copyControl.disabled = !input.value;
copyControl.dataset.copied = copied ? "true" : "false";
copyControl.textContent = copied ? copiedLabel : copyLabel;
};
const copy = async () => {
const link = input.value;
if (!link) return;
try {
if (onCopy) await onCopy(link);
else await navigator.clipboard.writeText(link);
render(true);
} catch (error) {
render(false);
onError?.(error);
}
};
const handleClick = () => void copy();
copyControl.addEventListener("click", handleClick);
render();
return {
getLink: () => input.value,
setLink: (link) => {
input.value = link;
render(false);
},
copy,
destroy: () => copyControl.removeEventListener("click", handleClick),
};
}

View File

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

View File

@ -97,7 +97,7 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
label?: string;
}
export function Icon({ name, label, size = 18, strokeWidth = 1.8, ...props }: IconProps) {
export function Icon({ name, label, size = 17, strokeWidth = 1.7, ...props }: IconProps) {
const IconComponent = icons[name];
return (

View File

@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from "react";
import { Button, IconButton } from "./Button.js";
import { Icon } from "./Icon.js";
import { Select, type SelectOption } from "./Select.js";
import { TextField } from "./Field.js";
import { Window, WindowFooterActions } from "./Window.js";
export interface ShareAccessMember<Role extends string = string> {
id: string;
name: string;
email: string;
role: Role;
roleLabel?: string;
avatarUrl?: string;
immutable?: boolean;
}
export interface ShareAccessModalProps<Role extends string = string> {
open: boolean;
resourceName: string;
members: readonly ShareAccessMember<Role>[];
roleOptions: Array<SelectOption<Role>>;
inviteEmail: string;
inviteRole: Role;
canManage?: boolean;
pending?: boolean;
message?: string;
messageTone?: "neutral" | "success" | "danger" | "accent";
onInviteEmailChange: (email: string) => void;
onInviteRoleChange: (role: Role) => void;
onInvite: () => void | Promise<void>;
onMemberRoleChange?: (member: ShareAccessMember<Role>, role: Role) => void;
onMemberRemove?: (member: ShareAccessMember<Role>) => void;
onClose: () => void;
}
function initials(name: string, email: string) {
const words = (name || email).split(/[\s._@-]+/).filter(Boolean);
return `${words[0]?.[0] ?? "N"}${words[1]?.[0] ?? words[0]?.[1] ?? ""}`.toUpperCase();
}
export function ShareAccessModal<Role extends string>({
open,
resourceName,
members,
roleOptions,
inviteEmail,
inviteRole,
canManage = true,
pending = false,
message,
messageTone = "neutral",
onInviteEmailChange,
onInviteRoleChange,
onInvite,
onMemberRoleChange,
onMemberRemove,
onClose,
}: ShareAccessModalProps<Role>) {
const [membersOpen, setMembersOpen] = useState(false);
const membersRootRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!open) setMembersOpen(false);
}, [open]);
useEffect(() => {
if (!membersOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node)) return;
if (event.target instanceof Element && event.target.closest(".nodedc-dropdown-surface")) return;
if (!membersRootRef.current?.contains(event.target)) setMembersOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
setMembersOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [membersOpen]);
return (
<Window
open={open}
title="Поделиться графом"
subtitle={resourceName.toUpperCase()}
className="nodedc-share-access-window"
closeOnBackdrop={false}
onClose={onClose}
footer={
<WindowFooterActions>
<Button onClick={onClose} disabled={pending}>Отменить</Button>
{canManage ? (
<Button variant="accent" onClick={() => void onInvite()} disabled={pending || !inviteEmail.trim()}>
{pending ? "Сохранение..." : "Поделиться"}
</Button>
) : null}
</WindowFooterActions>
}
>
<div className="nodedc-share-access">
<section ref={membersRootRef} className="nodedc-share-access__members" aria-label="Пользователи с доступом">
<span className="nodedc-share-access__section-label">Уже добавлены</span>
{members.length ? (
<>
<button
type="button"
className="nodedc-share-access__summary"
aria-label={`Пользователи с доступом: ${members.length}`}
aria-expanded={membersOpen}
onClick={() => setMembersOpen((current) => !current)}
>
<span className="nodedc-share-access__summary-avatars" aria-hidden="true">
{members.slice(0, 3).map((member, index) => (
<span className="nodedc-share-access__summary-avatar" style={{ left: index * 18, zIndex: 10 + index }} key={member.id}>
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : initials(member.name, member.email)}
</span>
))}
</span>
<span className="nodedc-share-access__summary-count">{members.length}</span>
</button>
</>
) : <div className="nodedc-share-access__empty">Пока никому не открыт доступ</div>}
{membersOpen ? (
<div className="nodedc-share-access__member-list">
{members.map((member) => (
<div className="nodedc-share-access__member" key={member.id}>
<span className="nodedc-share-access__avatar" aria-hidden="true">
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : initials(member.name, member.email)}
</span>
<span className="nodedc-share-access__identity">
<strong>{member.name || member.email}</strong>
{member.name && member.name !== member.email ? <small>{member.email}</small> : null}
</span>
{canManage && !member.immutable && onMemberRoleChange ? (
<Select
value={member.role}
label={`Роль ${member.email}`}
options={roleOptions}
minMenuWidth={150}
onChange={(role) => onMemberRoleChange(member, role)}
/>
) : <span className="nodedc-share-access__role">{member.roleLabel ?? member.role}</span>}
{canManage && !member.immutable && onMemberRemove ? (
<IconButton label={`Удалить доступ ${member.email}`} onClick={() => onMemberRemove(member)}>
<Icon name="trash" />
</IconButton>
) : null}
</div>
))}
</div>
) : null}
</section>
{canManage ? (
<div className="nodedc-share-access__invite">
<TextField
label="Пользователь"
type="email"
value={inviteEmail}
disabled={pending}
placeholder="email@company.ru"
onChange={(event) => onInviteEmailChange(event.target.value)}
/>
<div className="nodedc-field">
<span className="nodedc-field__label-row"><span className="nodedc-field__label">Роль</span></span>
<Select
value={inviteRole}
label="Роль доступа"
options={roleOptions}
disabled={pending}
onChange={(role) => onInviteRoleChange(role)}
/>
</div>
</div>
) : (
<div className="nodedc-share-access__readonly">
<strong>Вы не можете пригласить пользователя</strong>
<span>Попросите владельца изменить ваш уровень доступа.</span>
</div>
)}
{message ? <div className="nodedc-share-access__message" data-tone={messageTone}>{message}</div> : null}
</div>
</Window>
);
}
export interface ShareLinkModalProps {
open: boolean;
title?: string;
link: string;
hint?: string;
copyLabel?: string;
onCopy?: (link: string) => void | Promise<void>;
onClose: () => void;
}
export function ShareLinkModal({
open,
title = "Ссылка на модель",
link,
hint = "Ссылка открывает текущую модель и сохранённую точку обзора.",
copyLabel = "Скопировать",
onCopy,
onClose,
}: ShareLinkModalProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!open) setCopied(false);
}, [open]);
const copy = async () => {
if (onCopy) await onCopy(link);
else await navigator.clipboard.writeText(link);
setCopied(true);
};
return (
<Window
open={open}
title={title}
size="sm"
onClose={onClose}
footer={
<>
<Button onClick={onClose}>Закрыть</Button>
<WindowFooterActions>
<Button variant="accent" icon={<Icon name={copied ? "check" : "copy"} />} onClick={() => void copy()} disabled={!link}>
{copied ? "Скопировано" : copyLabel}
</Button>
</WindowFooterActions>
</>
}
>
<TextField label="Публичная ссылка" value={link} readOnly description={hint} />
</Window>
);
}

View File

@ -16,4 +16,5 @@ export * from "./SegmentedControl.js";
export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./Window.js";

View File

@ -130,6 +130,35 @@
"summary": "Async-safe confirmation window for destructive and consequential operations.",
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
},
{
"id": "share-access-modal",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ShareAccessModal"],
"domContract": ["nodedc-share-access-window", "nodedc-share-access", "nodedc-share-access__summary", "nodedc-share-access__member-list"],
"summary": "Engine-derived workflow access modal with compact member stack, role management and invite adapter slots.",
"behavior": ["controlled members", "compact expandable member stack", "role change", "member removal", "invite state", "immutable owner", "backdrop close disabled"],
"rules": [
"The component owns access-modal geometry and interaction; the consumer owns ACL requests and directory lookup.",
"Owner immutability and permission checks are supplied as member and canManage data.",
"The 600 px Engine composition uses 46 px invite controls and collapses to one column on mobile."
]
},
{
"id": "share-link-modal",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ShareLinkModal"],
"domPackage": "@nodedc/ui-dom",
"domExports": ["createShareLinkController", "createModalController"],
"domContract": ["nodedc-window", "nodedc-field", "nodedc-button"],
"summary": "BIM-derived read-only resource link modal with copy progress and framework-neutral copy controller.",
"behavior": ["read-only link", "copy adapter", "copied state", "disabled empty state", "copy error adapter"],
"rules": [
"The resource service creates the URL; the modal only presents and copies it.",
"BIM DOM consumers combine the share-link controller with the canonical modal controller and core classes."
]
},
{
"id": "segmented-control",
"status": "baseline",
@ -177,10 +206,10 @@
"exports": ["Icon"],
"domContract": ["svg using currentColor"],
"summary": "Audited semantic icon subset shared by NODE.DC applications instead of product-local vendor imports.",
"variants": ["16 px", "18 px", "20 px"],
"variants": ["15 px", "17 px", "18 px"],
"rules": [
"Use semantic names from registry/icons.json.",
"The containing component owns surface, hit target and state; the icon owns only the glyph.",
"The containing component owns surface, hit target and state; the icon owns only the glyph and is optically scaled to 90% inside controls.",
"Do not copy complete Lucide or Font Awesome catalogs into an application."
]
},

View File

@ -1,6 +1,6 @@
{
"schemaVersion": "1.0.0",
"libraryVersion": "0.4.0",
"libraryVersion": "0.5.0",
"name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [

View File

@ -45,12 +45,14 @@
"id": "engine",
"repository": "https://git.dcserve.ru/dctouch/NODEDC_ENGINE_INFRA.git",
"auditedRevision": "d427272c7e",
"role": "reference only for redesigned environment settings, glass controls and NDC agent inspector",
"role": "reference only for redesigned environment settings, glass controls, NDC agent inspector and the approved workflow sharing modal",
"approvedPaths": [
"nodedc-source/src/components/environment",
"nodedc-source/src/components/ui/NdcGlassChecker.tsx",
"nodedc-source/src/components/ui/NdcGlassModal.tsx",
"nodedc-source/src/components/ui/NdcGlassSelect.tsx",
"nodedc-source/src/components/ui/NdcUserAvatarStack.tsx",
"nodedc-source/src/components/header/GraphsPanel.tsx",
"nodedc-source/src/driveinspector/NdcAgentInspector.tsx",
"nodedc-source/src/style/menu.css",
"nodedc-source/src/style/inspector.css"
@ -85,7 +87,12 @@
"auditedRevision": "1a78dd0",
"role": "non-React integration reference and validation target",
"approvedPaths": [
"frontend/index.html",
"frontend/dcViewer.css",
"frontend/dcViewer.js",
"frontend/src/ui/glassSelect.js",
"frontend/src/ui/measurementModal.js",
"frontend/src/ui/objectModal.js",
"frontend/src/ui/settingsMenu.js",
"frontend/src/ui/toolbar.js"
],
@ -104,4 +111,3 @@
}
]
}