Persist catalog layout and add inspector toolbar

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 23:28:10 +03:00
parent 8ca12f2676
commit 406e692a5e
14 changed files with 715 additions and 268 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ dist/
.vite/ .vite/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
runtime-data/

View File

@ -23,6 +23,7 @@ import {
Inspector, Inspector,
MediaSourceField, MediaSourceField,
RangeControl, RangeControl,
SegmentedControl,
Select, Select,
ShareAccessModal, ShareAccessModal,
ShareLinkModal, ShareLinkModal,
@ -31,14 +32,16 @@ import {
Switch, Switch,
TextAreaField, TextAreaField,
TextField, TextField,
Toolbar,
useApplicationWorkspace, useApplicationWorkspace,
Window, Window,
WindowFooterActions, WindowFooterActions,
type IconName, type IconName,
type ShareAccessMember, type ShareAccessMember,
type ToolbarPlacement,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "icons"; type CatalogSection = "controls" | "media" | "modals" | "icons";
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [ const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" }, { label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
@ -52,20 +55,58 @@ type MaterialDraft = {
panelOpacity: number; panelOpacity: number;
fieldHex: string; fieldHex: string;
fieldOpacity: number; fieldOpacity: number;
nestedHex: string;
}; };
interface StoredLayout {
theme?: NodedcTheme;
accentHex?: string;
materialByTheme?: Record<NodedcTheme, MaterialDraft>;
environment?: {
lightColor?: string;
brightness?: number;
glowDistance?: number;
connectionType?: string;
connectionColor?: string;
usePortColors?: boolean;
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeOpacity?: number;
};
media?: {
source?: "file" | "url";
url?: string;
fileName?: string;
fileSrc?: string;
visible?: boolean;
};
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
};
}
const materialDefaults: Record<NodedcTheme, MaterialDraft> = { const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
dark: { dark: {
panelHex: "#151517", panelHex: "#151517",
panelOpacity: 100, panelOpacity: 100,
fieldHex: "#2a2a2c", fieldHex: "#2a2a2c",
fieldOpacity: 100, fieldOpacity: 100,
nestedHex: "#0b0b0d",
}, },
light: { light: {
panelHex: "#ffffff", panelHex: "#ffffff",
panelOpacity: 100, panelOpacity: 100,
fieldHex: "#ffffff", fieldHex: "#ffffff",
fieldOpacity: 100, fieldOpacity: 100,
nestedHex: "#f4f4f4",
}, },
}; };
@ -84,38 +125,26 @@ const selectOptions = [
] as const; ] as const;
const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = { const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: string; description: string; icon: IconName }> = {
foundation: {
eyebrow: "01 / FOUNDATION",
title: "Основа",
description: "Тема, нейтральные поверхности и неизменяемая геометрия приложения.",
icon: "panel",
},
controls: { controls: {
eyebrow: "02 / CONTROLS", eyebrow: "01 / CONTROLS",
title: "Контролы", title: "Контролы",
description: "Кнопки, поля, выбор, Environment Settings и сворачиваемые секции нового Engine.", description: "Кнопки, поля, выбор, оконные действия и вызываемый Inspector нового Engine.",
icon: "sliders", icon: "sliders",
}, },
media: { media: {
eyebrow: "03 / MEDIA & SETTINGS", eyebrow: "02 / MEDIA & SETTINGS",
title: "Медиа и настройки", title: "Медиа и настройки",
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.", description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
icon: "image", icon: "image",
}, },
windows: {
eyebrow: "04 / WINDOWS",
title: "Окна",
description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.",
icon: "apps",
},
modals: { modals: {
eyebrow: "05 / MODALS", eyebrow: "03 / MODALS",
title: "Модалки", title: "Модалки",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.", description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard", icon: "clipboard",
}, },
icons: { icons: {
eyebrow: "06 / ICONS", eyebrow: "04 / ICONS",
title: "Иконки", title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.", description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid", icon: "grid",
@ -234,11 +263,10 @@ export function CatalogApp() {
dark: { ...materialDefaults.dark }, dark: { ...materialDefaults.dark },
light: { ...materialDefaults.light }, light: { ...materialDefaults.light },
})); }));
const [environmentLayoutSaved, setEnvironmentLayoutSaved] = useState(false); const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const workspace = useApplicationWorkspace<CatalogSection>(); const workspace = useApplicationWorkspace<CatalogSection>();
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace; const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active"); const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
const [checker, setChecker] = useState(true);
const [brightness, setBrightness] = useState(49); const [brightness, setBrightness] = useState(49);
const [glowDistance, setGlowDistance] = useState(105); const [glowDistance, setGlowDistance] = useState(105);
const [usePortColors, setUsePortColors] = useState(false); const [usePortColors, setUsePortColors] = useState(false);
@ -249,8 +277,7 @@ export function CatalogApp() {
const [fillOpacity, setFillOpacity] = useState(100); const [fillOpacity, setFillOpacity] = useState(100);
const [strokeColor, setStrokeColor] = useState("#2b2b36"); const [strokeColor, setStrokeColor] = useState("#2b2b36");
const [strokeOpacity, setStrokeOpacity] = useState(100); const [strokeOpacity, setStrokeOpacity] = useState(100);
const [windowOpen, setWindowOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [sidePanelOpen, setSidePanelOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [shareAccessOpen, setShareAccessOpen] = useState(false); const [shareAccessOpen, setShareAccessOpen] = useState(false);
@ -268,6 +295,15 @@ export function CatalogApp() {
const [mediaError, setMediaError] = useState(""); const [mediaError, setMediaError] = useState("");
const [mediaVisible, setMediaVisible] = useState(true); const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null); const mediaObjectUrlRef = useRef<string | null>(null);
const pendingMediaFileRef = useRef<File | null>(null);
const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
const [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117");
const [toolbarOutline, setToolbarOutline] = useState("#1c1c1c");
const [toolbarMinSize, setToolbarMinSize] = useState(25);
const [toolbarMaxSize, setToolbarMaxSize] = useState(87);
const [toolbarLensCount, setToolbarLensCount] = useState(5);
const [toolbarAutoHide, setToolbarAutoHide] = useState(false);
const [shareEmail, setShareEmail] = useState(""); const [shareEmail, setShareEmail] = useState("");
const [shareRole, setShareRole] = useState<ShareRole>("editor"); const [shareRole, setShareRole] = useState<ShareRole>("editor");
const [shareMessage, setShareMessage] = useState(""); const [shareMessage, setShareMessage] = useState("");
@ -297,7 +333,63 @@ export function CatalogApp() {
fieldOpacity: material.fieldOpacity / 100, fieldOpacity: material.fieldOpacity / 100,
}, },
}); });
}, [accent, fieldMaterial, material.fieldOpacity, material.panelOpacity, panelMaterial, theme]); document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex);
}, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]);
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("layout_load_failed");
return await response.json() as StoredLayout | null;
})
.then((stored) => {
if (!active || !stored) return;
if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme);
if (stored.accentHex) setAccentHex(stored.accentHex);
if (stored.materialByTheme?.dark && stored.materialByTheme?.light) setMaterialByTheme(stored.materialByTheme);
const environment = stored.environment;
if (environment) {
if (environment.lightColor) setLightColor(environment.lightColor);
if (typeof environment.brightness === "number") setBrightness(environment.brightness);
if (typeof environment.glowDistance === "number") setGlowDistance(environment.glowDistance);
if (environment.connectionType) setConnectionType(environment.connectionType);
if (environment.connectionColor) setConnectionColor(environment.connectionColor);
if (typeof environment.usePortColors === "boolean") setUsePortColors(environment.usePortColors);
if (environment.fillColor) setFillColor(environment.fillColor);
if (typeof environment.fillOpacity === "number") setFillOpacity(environment.fillOpacity);
if (environment.strokeColor) setStrokeColor(environment.strokeColor);
if (typeof environment.strokeOpacity === "number") setStrokeOpacity(environment.strokeOpacity);
}
const media = stored.media;
if (media) {
if (media.source === "file" || media.source === "url") setMediaSource(media.source);
if (typeof media.url === "string") setMediaUrl(media.url);
if (media.fileName) setMediaFileName(media.fileName);
if (media.fileSrc) setFileMediaSrc(media.fileSrc);
if (typeof media.visible === "boolean") setMediaVisible(media.visible);
}
const toolbar = stored.toolbar;
if (toolbar) {
if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement);
if (toolbar.background) setToolbarBg(toolbar.background);
if (toolbar.border) setToolbarBorder(toolbar.border);
if (toolbar.outline) setToolbarOutline(toolbar.outline);
if (typeof toolbar.minSize === "number") setToolbarMinSize(toolbar.minSize);
if (typeof toolbar.maxSize === "number") setToolbarMaxSize(toolbar.maxSize);
if (typeof toolbar.lensCount === "number") setToolbarLensCount(toolbar.lensCount);
if (typeof toolbar.autoHide === "boolean") setToolbarAutoHide(toolbar.autoHide);
}
setLayoutSaveState("saved");
})
.catch(() => {
if (active) setLayoutSaveState("error");
})
.finally(() => {
if (active) setLayoutSaveState((current) => current === "loading" ? "idle" : current);
});
return () => { active = false; };
}, []);
useEffect(() => () => { useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
@ -313,6 +405,7 @@ export function CatalogApp() {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
mediaObjectUrlRef.current = objectUrl; mediaObjectUrlRef.current = objectUrl;
pendingMediaFileRef.current = file;
setFileMediaSrc(objectUrl); setFileMediaSrc(objectUrl);
setMediaFileName(file.name); setMediaFileName(file.name);
setMediaSource("file"); setMediaSource("file");
@ -344,11 +437,28 @@ export function CatalogApp() {
})); }));
}; };
const saveEnvironmentLayout = () => { const saveEnvironmentLayout = async () => {
const draft = { setLayoutSaveState("saving");
try {
let persistedFileSrc = fileMediaSrc;
const pendingMedia = pendingMediaFileRef.current;
if (pendingMedia) {
const uploadResponse = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": "video/mp4", "x-file-name": encodeURIComponent(pendingMedia.name) },
body: pendingMedia,
});
if (!uploadResponse.ok) throw new Error("media_upload_failed");
const uploaded = await uploadResponse.json() as { url: string; fileName: string };
persistedFileSrc = uploaded.url;
setFileMediaSrc(uploaded.url);
setMediaFileName(uploaded.fileName);
pendingMediaFileRef.current = null;
}
const draft: StoredLayout = {
theme, theme,
accentHex, accentHex,
material, materialByTheme,
environment: { environment: {
lightColor, lightColor,
brightness, brightness,
@ -361,13 +471,34 @@ export function CatalogApp() {
strokeColor, strokeColor,
strokeOpacity, strokeOpacity,
}, },
media: {
source: mediaSource,
url: mediaUrl,
fileName: mediaFileName,
fileSrc: persistedFileSrc,
visible: mediaVisible,
},
toolbar: {
placement: toolbarPlacement,
background: toolbarBg,
border: toolbarBorder,
outline: toolbarOutline,
minSize: toolbarMinSize,
maxSize: toolbarMaxSize,
lensCount: toolbarLensCount,
autoHide: toolbarAutoHide,
},
}; };
try { const response = await fetch("/api/layout", {
window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft)); method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(draft),
});
if (!response.ok) throw new Error("layout_save_failed");
setLayoutSaveState("saved");
} catch { } catch {
// The preview remains useful in restricted browser contexts even without local persistence. setLayoutSaveState("error");
} }
setEnvironmentLayoutSaved(true);
}; };
const environmentSections = [ const environmentSections = [
@ -389,6 +520,42 @@ export function CatalogApp() {
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} /> <ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
</ControlRow> </ControlRow>
<RangeControl label="Прозрачность полей" value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} /> <RangeControl label="Прозрачность полей" value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
<ControlRow label="Вложенная область">
<ColorField label="Цвет вложенной области" value={material.nestedHex} onChange={(nestedHex) => updateMaterial({ nestedHex })} />
</ControlRow>
</>
),
},
{
id: "toolbar-settings",
label: "Рабочее поле — Toolbar",
tone: "accent" as const,
content: (
<>
<ControlRow label="Позиция">
<Select
variant="split"
label="Позиция Toolbar"
value={toolbarPlacement}
options={[
{ value: "left", label: "Слева" },
{ value: "right", label: "Справа" },
{ value: "bottom", label: "Снизу" },
]}
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
/>
</ControlRow>
<ControlRow label="Фон"><ColorField value={toolbarBg} onChange={setToolbarBg} /></ControlRow>
<ControlRow label="Border"><ColorField value={toolbarBorder} onChange={setToolbarBorder} /></ControlRow>
<ControlRow label="Outline"><ColorField value={toolbarOutline} onChange={setToolbarOutline} /></ControlRow>
<RangeControl label="Минимальный размер" value={toolbarMinSize} min={18} max={42} formatValue={(value) => `${value}px`} onChange={(value) => {
const next = Math.round(value);
setToolbarMinSize(next);
setToolbarMaxSize((current) => Math.max(current, next));
}} />
<RangeControl label="Максимальный размер" value={toolbarMaxSize} min={28} max={88} formatValue={(value) => `${value}px`} onChange={(value) => setToolbarMaxSize(Math.max(toolbarMinSize, Math.round(value)))} />
<RangeControl label="Линза" value={toolbarLensCount} min={1} max={13} step={2} formatValue={(value) => `${value} иконок`} onChange={(value) => setToolbarLensCount(Math.round(value) % 2 === 0 ? Math.round(value) + 1 : Math.round(value))} />
<Checker checked={toolbarAutoHide} label="Автоскрытие" onChange={setToolbarAutoHide} />
</> </>
), ),
}, },
@ -446,36 +613,21 @@ export function CatalogApp() {
const renderSectionContent = () => { const renderSectionContent = () => {
switch (activeSection) { switch (activeSection) {
case "foundation":
return (
<div className="catalog-grid catalog-grid--foundation">
<Preview title="Тема приложения" note={theme === "dark" ? "Launcher / dark" : "SEO / light"}>
<div className="catalog-theme-row">
<Button variant={theme === "dark" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("dark")}>Dark</Button>
<Button variant={theme === "light" ? "primary" : "secondary"} shape="pill" onClick={() => setTheme("light")}>Light</Button>
</div>
<p className="catalog-preview__explanation">Две базовые темы фиксированы по эталонам: чёрная Launcher/Hub, белая SEO. Стекло используется только в модальных окнах.</p>
</Preview>
<Preview title="Поверхности приложения" note="Launcher / SEO">
<div className="catalog-surface-stack">
<GlassSurface padding="md">Основная поверхность</GlassSurface>
<GlassSurface tone="strong" padding="md">Контентное окно</GlassSurface>
<GlassSurface tone="soft" padding="md">Вложенная область</GlassSurface>
</div>
</Preview>
<Preview title="Каркас Launcher" note="fixed header / stage / panels" className="catalog-preview--wide">
<div className="catalog-shell-diagram" aria-label="Схема каркаса приложения">
<span className="catalog-shell-diagram__header">Header</span>
<span className="catalog-shell-diagram__nav">Navigation</span>
<span className="catalog-shell-diagram__content">Content window</span>
<span className="catalog-shell-diagram__stage">Stage</span>
</div>
</Preview>
</div>
);
case "controls": case "controls":
return ( return (
<div className="catalog-grid"> <div className="catalog-grid">
<Preview title="Inspector" note="modeless / draggable">
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
</Preview>
<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>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
</Preview>
<Preview title="Действия" note="общая геометрия"> <Preview title="Действия" note="общая геометрия">
<div className="catalog-inline catalog-inline--wrap"> <div className="catalog-inline catalog-inline--wrap">
<Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button> <Button variant="primary" shape="pill" icon={<Icon name="save" />}>Сохранить</Button>
@ -524,17 +676,6 @@ export function CatalogApp() {
</Dropdown> </Dropdown>
</div> </div>
</Preview> </Preview>
<Preview title="Environment controls" note="Engine / select + accordion" className="catalog-preview--wide">
<div className="catalog-environment-demo">
<GlassSurface tone="strong" radius="panel" className="catalog-environment-card">
<div className="catalog-environment-title">
<div><strong>Настройки окружения</strong><span>ENGINE / новый канон</span></div>
</div>
<Inspector sections={environmentSections} defaultOpen={["application-material"]} />
</GlassSurface>
<p className="catalog-preview__explanation">Environment controls единый референс Engine: palette, range, checker, split-select и раскрывающиеся sections. Верхняя секция меняет цвет плашек и полей приложения; весь layout сохраняется общей кнопкой в шапке окна.</p>
</div>
</Preview>
<Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide"> <Preview title="Статусы операций" note="не цвет приложения" className="catalog-preview--wide">
<p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p> <p className="catalog-preview__explanation">Статус сообщает результат операции: нейтрально, успешно, требуется внимание или ошибка. Акцент приложения для этого не переиспользуется.</p>
<div className="catalog-inline catalog-inline--wrap"> <div className="catalog-inline catalog-inline--wrap">
@ -552,7 +693,7 @@ export function CatalogApp() {
<SettingsCard <SettingsCard
eyebrow="CONTENT" eyebrow="CONTENT"
title="Видео-окно" title="Видео-окно"
description="Окно демо-видео и подключённый медиа-файл. Хранилище и загрузка остаются в приложении; геометрия и переключение источника — в библиотеке." description="MP4 или URL становятся частью единого server-side layout вместе с темой, материалом и Toolbar."
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />} actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
> >
<MediaSourceField <MediaSourceField
@ -564,7 +705,7 @@ export function CatalogApp() {
previewKind="video" previewKind="video"
accept="video/mp4,.mp4" accept="video/mp4,.mp4"
path="stage.videoSrc → runtime media source" path="stage.videoSrc → runtime media source"
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage." hint="Выбранный MP4 сразу показывается в stage и загружается на сервер общей кнопкой Save в шапке."
error={mediaError} error={mediaError}
onSourceChange={setMediaSource} onSourceChange={setMediaSource}
onUrlChange={(nextUrl) => { onUrlChange={(nextUrl) => {
@ -574,33 +715,13 @@ export function CatalogApp() {
onFileChange={handleStageMediaFile} onFileChange={handleStageMediaFile}
/> />
</SettingsCard> </SettingsCard>
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide"> <Preview title="Серверное состояние" note="layout / media" className="catalog-preview--wide">
<p className="catalog-preview__explanation"> <p className="catalog-preview__explanation">
`MediaSourceField` владеет пикселями, доступностью и состояниями file/URL. Конкретный проект передаёт upload-обработчик, storage-path и результат валидации поэтому компонент можно подключить к CMS, Launcher или будущему приложению без копирования его вёрстки. Save записывает один layout: обе темы, текущий accent, материалы, Environment controls, Toolbar и выбранный media-source. Очистка browser cache больше не удаляет конфигурацию.
</p> </p>
</Preview> </Preview>
</div> </div>
); );
case "windows":
return (
<div className="catalog-grid catalog-grid--single">
<Preview title="Управляемые окна" note="center / modeless end">
<p className="catalog-preview__explanation">Обычное окно блокирует фон и удерживает фокус. Правый инспектор не затемняет, не блокирует stage и не превращается в modal.</p>
<div className="catalog-inline catalog-inline--wrap">
<Button variant="primary" shape="pill" icon={<Icon name="apps" />} onClick={() => setWindowOpen(true)}>Открыть окно</Button>
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setSidePanelOpen(true)}>Открыть боковую панель</Button>
</div>
</Preview>
<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>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
</Preview>
</div>
);
case "modals": case "modals":
return ( return (
<div className="catalog-modal-groups"> <div className="catalog-modal-groups">
@ -753,10 +874,19 @@ export function CatalogApp() {
description={activeDefinition.description} description={activeDefinition.description}
expanded={panelExpanded} expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded} onExpandedChange={workspace.setContentExpanded}
headerTools={
<SegmentedControl
label="Тема приложения"
value={theme}
items={[{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]}
onChange={setTheme}
/>
}
utilityActions={[{ utilityActions={[{
label: environmentLayoutSaved ? "Layout сохранён локально" : "Сохранить layout", label: layoutSaveState === "saving" ? "Сохраняется на сервер" : layoutSaveState === "saved" ? "Layout сохранён на сервере" : layoutSaveState === "error" ? "Повторить сохранение layout" : "Сохранить layout на сервер",
icon: "save", icon: "save",
onClick: saveEnvironmentLayout, onClick: () => { void saveEnvironmentLayout(); },
disabled: layoutSaveState === "saving" || layoutSaveState === "loading",
}]} }]}
onClose={workspace.closeView} onClose={workspace.closeView}
> >
@ -765,36 +895,36 @@ export function CatalogApp() {
) : null} ) : null}
/> />
<Window {guidelineOpen ? (
open={windowOpen} <Toolbar<CatalogSection>
title="Настройки сервиса" placement={toolbarPlacement}
subtitle="Launcher / CMS / SEO" background={toolbarBg}
onClose={() => setWindowOpen(false)} border={toolbarBorder}
footer={ outline={toolbarOutline}
<> accent={accentHex}
<Button variant="ghost" onClick={() => setWindowOpen(false)}>Отмена</Button> minSize={toolbarMinSize}
<WindowFooterActions><Button variant="primary" shape="pill" onClick={() => setWindowOpen(false)}>Сохранить</Button></WindowFooterActions> maxSize={toolbarMaxSize}
</> lensCount={toolbarLensCount}
} autoHide={toolbarAutoHide}
> items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({
<div className="catalog-form"> id,
<TextField label="Название" value={projectName} onChange={(event) => setProjectName(event.target.value)} /> label: item.title,
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame> icon: item.icon,
<Checker checked={checker} label="Сервис активен" onChange={setChecker} /> active: activeSection === id,
</div> onSelect: openSection,
</Window> }))}
/>
) : null}
<Window <Window
open={sidePanelOpen} open={inspectorOpen}
title="Боковая панель" title="Настройки окружения"
subtitle="modeless / shell contract" subtitle="ENGINE / draggable inspector"
placement="end" placement="end"
onClose={() => setSidePanelOpen(false)} draggable
onClose={() => setInspectorOpen(false)}
> >
<div className="catalog-form"> <Inspector sections={environmentSections} defaultOpen={["application-material"]} />
<TextField label="Название панели" value="Параметры рабочей области" readOnly />
<p className="catalog-preview__explanation">Боковая панель демонстрирует только layer-механику: она modeless, не затемняет stage и не забирает фокус. Environment controls живут в разделе «Контролы».</p>
</div>
</Window> </Window>
<Window <Window

View File

@ -110,10 +110,6 @@ textarea {
gap: 1rem; gap: 1rem;
} }
.catalog-grid--foundation {
grid-template-columns: 1.1fr 0.9fr;
}
.catalog-grid--single { .catalog-grid--single {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -160,7 +156,6 @@ textarea {
line-height: 1.5; line-height: 1.5;
} }
.catalog-theme-row,
.catalog-inline, .catalog-inline,
.catalog-window-actions-demo { .catalog-window-actions-demo {
display: flex; display: flex;
@ -173,37 +168,6 @@ textarea {
flex-wrap: wrap; flex-wrap: wrap;
} }
.catalog-swatches {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
}
.catalog-swatch {
display: grid;
min-width: 0;
gap: 0.4rem;
border: 0;
border-radius: 0.9rem;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-muted);
padding: 0.55rem;
font-size: 0.68rem;
cursor: pointer;
}
.catalog-swatch span {
height: 2.4rem;
border-radius: 0.65rem;
background: var(--swatch);
}
.catalog-swatch[data-active="true"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.catalog-surface-stack,
.catalog-form { .catalog-form {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@ -313,14 +277,6 @@ textarea {
flex-basis: 2rem; flex-basis: 2rem;
} }
.catalog-surface-stack .nodedc-glass {
display: flex;
min-height: 4rem;
align-items: center;
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
}
.catalog-engine-control-stack { .catalog-engine-control-stack {
display: grid; display: grid;
width: min(var(--nodedc-inspector-inner-width), 100%); width: min(var(--nodedc-inspector-inner-width), 100%);
@ -328,80 +284,6 @@ textarea {
gap: 0.75rem; gap: 0.75rem;
} }
.catalog-shell-diagram {
display: grid;
min-height: 16rem;
grid-template-columns: minmax(8rem, 0.28fr) minmax(15rem, 1fr) minmax(7rem, 0.22fr);
grid-template-rows: 3.2rem 1fr;
gap: 0.6rem;
border-radius: 1.1rem;
background: var(--nodedc-canvas-soft);
padding: 0.6rem;
}
.catalog-shell-diagram span {
display: grid;
place-items: center;
border-radius: 0.9rem;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.catalog-shell-diagram__header {
grid-column: 1 / -1;
}
.catalog-shell-diagram__nav {
background: var(--nodedc-glass-control-hover) !important;
}
.catalog-shell-diagram__content {
background: var(--nodedc-glass-control-bg) !important;
}
.catalog-environment-demo {
display: grid;
min-height: 30rem;
grid-template-columns: minmax(0, 24.375rem) minmax(14rem, 1fr);
align-items: start;
gap: clamp(1rem, 4vw, 4rem);
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-canvas-soft);
padding: clamp(1rem, 3vw, 2rem);
}
.catalog-environment-card {
width: min(24.375rem, 100%);
padding: 1.125rem 0.75rem;
}
.catalog-environment-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0 0.875rem 0.875rem;
}
.catalog-environment-title > div {
display: grid;
gap: 0.3rem;
}
.catalog-environment-title span {
color: var(--nodedc-text-muted);
font-size: 0.7rem;
}
.catalog-environment-draft {
margin: -0.25rem 0.875rem 0.75rem;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.4;
}
.catalog-icon-catalog { .catalog-icon-catalog {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@ -489,8 +371,7 @@ textarea {
} }
@media (max-width: 1280px) { @media (max-width: 1280px) {
.catalog-grid, .catalog-grid {
.catalog-grid--foundation {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -557,7 +438,6 @@ textarea {
gap: 0.25rem; gap: 0.25rem;
} }
.catalog-swatches,
.catalog-icon-grid { .catalog-icon-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@ -566,32 +446,12 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.catalog-shell-diagram {
grid-template-columns: 1fr 1.5fr;
grid-template-rows: 3rem 7rem 7rem;
}
.catalog-shell-diagram__header {
grid-column: 1 / -1;
}
.catalog-shell-diagram__stage {
grid-column: 1 / -1;
}
.catalog-environment-demo {
min-height: auto;
grid-template-columns: 1fr;
padding: 0.75rem;
}
.catalog-window-actions-demo .nodedc-button { .catalog-window-actions-demo .nodedc-button {
width: 100%; width: 100%;
} }
} }
@media (max-width: 430px) { @media (max-width: 430px) {
.catalog-swatches,
.catalog-icon-grid { .catalog-icon-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -14,6 +14,8 @@
Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки. Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки.
Постоянный theme switch передаётся через `ApplicationPanel.headerTools` и располагается в той же action-зоне. Если приложение использует Engine/BIM toolbar, он подключается отдельно к общему workspace-state и может располагаться слева, справа или снизу.
## Состояния desktop ## Состояния desktop
| Состояние | Навигация | Content window | Stage | | Состояние | Навигация | Content window | Stage |

View File

@ -4,7 +4,7 @@
NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику. NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику.
Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Он не является backend-сервисом и не владеет данными приложений. Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Пакеты дизайн-системы не владеют данными приложений. Living catalog имеет собственный минимальный runtime-store только для сохранения единого демонстрационного layout и загруженного stage-media между браузерными сессиями.
## Слои ## Слои
@ -27,7 +27,7 @@ Core не зависит от React.
`@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке. `@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке.
`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. `ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Toolbar` фиксирует Engine/BIM dock, magnification, placement и autohide. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо.
### DOM ### DOM
@ -37,6 +37,8 @@ Core не зависит от React.
Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге. Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге.
Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar и media source. Browser localStorage не является источником истины.
## Граница shared/domain ## Граница shared/domain
В дизайн-системе живут элементы, значение которых не зависит от предметной области: В дизайн-системе живут элементы, значение которых не зависит от предметной области:

View File

@ -77,6 +77,14 @@ Window — единая механика открытия modal и правой
Содержание, сохранение и запросы принадлежат приложению. Содержание, сохранение и запросы принадлежат приложению.
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
## Toolbar
`Toolbar` — канонический dock из нового Engine с требованиями BIM Viewer. Он поддерживает позиции `left`, `right`, `bottom`, lens magnification и autohide. Контролируются фон, border, outline, минимальный/максимальный размер и количество иконок линзы.
Дефолты перенесены из Engine без переизобретения: `#111115`, `#111117`, `#1c1c1c`, `25 px`, `87 px`, `5`. Toolbar получает те же section actions, что и основная navigation, но не владеет route-state.
## ConfirmationModal ## ConfirmationModal
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать. ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.

View File

@ -13,6 +13,7 @@
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs" "validate:registry": "node scripts/validate-registry.mjs"
}, },
"engines": { "engines": {

View File

@ -1304,6 +1304,22 @@ textarea.nodedc-field__control {
gap: 0.45rem; gap: 0.45rem;
} }
.nodedc-application-panel__tools {
display: flex;
align-items: center;
margin-right: 0.1rem;
}
.nodedc-application-panel__tools .nodedc-segmented {
min-height: 2.92rem;
padding: 0.22rem;
}
.nodedc-application-panel__tools .nodedc-segmented__item {
min-height: 2.48rem;
padding-inline: 1rem;
}
.nodedc-application-panel__action { .nodedc-application-panel__action {
display: grid; display: grid;
width: 2.92rem; width: 2.92rem;
@ -1926,6 +1942,16 @@ textarea.nodedc-field__control {
padding: 1.125rem 0.75rem 0.875rem 1.625rem; padding: 1.125rem 0.75rem 0.875rem 1.625rem;
} }
.nodedc-window[data-placement="end"][data-draggable="true"] .nodedc-window__head {
cursor: grab;
touch-action: none;
user-select: none;
}
.nodedc-window[data-placement="end"][data-draggable="true"] .nodedc-window__head:active {
cursor: grabbing;
}
.nodedc-window[data-placement="end"] .nodedc-window__body { .nodedc-window[data-placement="end"] .nodedc-window__body {
padding: 0 0.75rem 1.125rem; padding: 0 0.75rem 1.125rem;
} }
@ -2050,6 +2076,108 @@ textarea.nodedc-field__control {
gap: var(--nodedc-space-2); gap: var(--nodedc-space-2);
} }
.nodedc-toolbar-wrap {
position: fixed;
z-index: calc(var(--nodedc-layer-header) + 20);
pointer-events: auto;
}
.nodedc-toolbar-wrap[data-placement="left"] {
top: 50%;
left: 0.75rem;
transform: translateY(-50%);
}
.nodedc-toolbar-wrap[data-placement="right"] {
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
}
.nodedc-toolbar-wrap[data-placement="bottom"] {
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
}
.nodedc-toolbar {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem;
border: 1px solid var(--nodedc-toolbar-outline);
border-radius: calc(var(--nodedc-toolbar-radius) + 0.42rem);
background: color-mix(in srgb, var(--nodedc-toolbar-bg) 88%, transparent);
box-shadow: 0 10px 28px color-mix(in srgb, var(--nodedc-toolbar-outline) 36%, transparent);
backdrop-filter: blur(24px) saturate(1.12);
-webkit-backdrop-filter: blur(24px) saturate(1.12);
transition: opacity 180ms ease, transform 220ms ease;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar,
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar {
flex-direction: column;
}
.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
opacity: 0;
pointer-events: none;
}
.nodedc-toolbar-wrap[data-placement="left"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
transform: translateX(calc(-100% - 1rem));
}
.nodedc-toolbar-wrap[data-placement="right"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
transform: translateX(calc(100% + 1rem));
}
.nodedc-toolbar-wrap[data-placement="bottom"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
transform: translateY(calc(100% + 1rem));
}
.nodedc-toolbar__hotzone {
position: fixed;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone {
inset: 5rem auto 5rem 0;
width: 1.1rem;
}
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar__hotzone {
inset: 5rem 0 5rem auto;
width: 1.1rem;
}
.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar__hotzone {
right: 5rem;
bottom: 0;
left: 5rem;
height: 1.1rem;
}
.nodedc-toolbar__button {
display: grid;
width: var(--nodedc-toolbar-item-size);
height: var(--nodedc-toolbar-item-size);
flex: 0 0 var(--nodedc-toolbar-item-size);
place-items: center;
border: 1px solid var(--nodedc-toolbar-border);
border-radius: var(--nodedc-toolbar-radius);
outline: 0;
background: var(--nodedc-toolbar-bg);
color: var(--nodedc-toolbar-accent);
padding: 0;
transition: width 120ms ease, height 120ms ease, flex-basis 120ms ease, background 120ms ease, color 120ms ease;
}
.nodedc-toolbar__button[data-active="true"] {
border-color: var(--nodedc-toolbar-accent);
background: var(--nodedc-toolbar-accent);
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-window.nodedc-share-access-window { .nodedc-window.nodedc-share-access-window {
width: min(35rem, calc(100vw - 2rem)); width: min(35rem, calc(100vw - 2rem));
} }

View File

@ -49,6 +49,8 @@ export interface ApplicationPanelProps extends Omit<HTMLAttributes<HTMLElement>,
expandLabel?: string; expandLabel?: string;
closeLabel?: string; closeLabel?: string;
onExpandedChange?: (expanded: boolean) => void; onExpandedChange?: (expanded: boolean) => void;
/** Persistent custom controls rendered before utility icon actions. */
headerTools?: ReactNode;
/** Persistent utility actions rendered before expand/close in the panel header. */ /** Persistent utility actions rendered before expand/close in the panel header. */
utilityActions?: ApplicationPanelUtilityAction[]; utilityActions?: ApplicationPanelUtilityAction[];
onClose: () => void; onClose: () => void;
@ -70,6 +72,7 @@ export function ApplicationPanel({
expandLabel = "Развернуть окно", expandLabel = "Развернуть окно",
closeLabel = "Закрыть окно", closeLabel = "Закрыть окно",
onExpandedChange, onExpandedChange,
headerTools,
utilityActions = [], utilityActions = [],
onClose, onClose,
children, children,
@ -85,6 +88,7 @@ export function ApplicationPanel({
{description ? <p>{description}</p> : null} {description ? <p>{description}</p> : null}
</div> </div>
<div className="nodedc-application-panel__actions"> <div className="nodedc-application-panel__actions">
{headerTools ? <div className="nodedc-application-panel__tools">{headerTools}</div> : null}
{utilityActions.map((action) => ( {utilityActions.map((action) => (
<button <button
key={action.label} key={action.label}

View File

@ -0,0 +1,108 @@
import { useState, type CSSProperties } from "react";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
export type ToolbarPlacement = "left" | "right" | "bottom";
export interface ToolbarItem<T extends string = string> {
id: T;
label: string;
icon: IconName;
active?: boolean;
disabled?: boolean;
onSelect: (id: T) => void;
}
export interface ToolbarProps<T extends string = string> {
items: Array<ToolbarItem<T>>;
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
accent?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
label?: string;
className?: string;
}
export function Toolbar<T extends string = string>({
items,
placement = "left",
background = "#111115",
border = "#111117",
outline = "#1c1c1c",
accent = "#ff2f92",
minSize = 25,
maxSize = 87,
lensCount = 5,
autoHide = false,
label = "Панель инструментов",
className,
}: ToolbarProps<T>) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [visible, setVisible] = useState(!autoHide);
const vertical = placement !== "bottom";
const safeMin = Math.max(18, Math.min(42, minSize));
const safeMax = Math.max(safeMin, Math.min(88, maxSize));
const safeLens = Math.max(1, Math.min(13, lensCount % 2 === 0 ? lensCount + 1 : lensCount));
const radius = Math.max(9, Math.round(safeMin * 0.42));
const style = {
"--nodedc-toolbar-bg": background,
"--nodedc-toolbar-border": border,
"--nodedc-toolbar-outline": outline,
"--nodedc-toolbar-accent": accent,
"--nodedc-toolbar-min-size": `${safeMin}px`,
"--nodedc-toolbar-max-size": `${safeMax}px`,
"--nodedc-toolbar-radius": `${radius}px`,
} as CSSProperties;
return (
<div
className={cn("nodedc-toolbar-wrap", className)}
data-placement={placement}
data-visible={visible ? "true" : undefined}
data-autohide={autoHide ? "true" : undefined}
style={style}
onPointerEnter={() => setVisible(true)}
onPointerLeave={() => {
setHoveredIndex(null);
if (autoHide) setVisible(false);
}}
>
{autoHide ? <div className="nodedc-toolbar__hotzone" aria-hidden="true" onPointerEnter={() => setVisible(true)} /> : null}
<div className="nodedc-toolbar" role="toolbar" aria-label={label}>
{items.map((item, index) => {
const distance = hoveredIndex === null ? Number.POSITIVE_INFINITY : Math.abs(index - hoveredIndex);
const lensRadius = Math.floor(safeLens / 2);
const influence = distance > lensRadius ? 0 : 1 - distance / Math.max(1, lensRadius + 1);
const size = safeMin + (safeMax - safeMin) * influence;
return (
<button
key={item.id}
type="button"
className="nodedc-toolbar__button"
data-active={item.active ? "true" : undefined}
aria-label={item.label}
aria-pressed={item.active}
disabled={item.disabled}
style={{
"--nodedc-toolbar-item-size": `${Math.round(size)}px`,
"--nodedc-toolbar-item-shift": `${Math.round((size - safeMin) / (vertical ? 2 : 2))}px`,
} as CSSProperties}
onPointerEnter={() => setHoveredIndex(index)}
onFocus={() => setHoveredIndex(index)}
onBlur={() => setHoveredIndex(null)}
onClick={() => item.onSelect(item.id)}
>
<Icon name={item.icon} size={Math.max(12, Math.min(18, Math.round(safeMin * 0.62)))} />
</button>
);
})}
</div>
</div>
);
}

View File

@ -2,12 +2,14 @@ import {
useEffect, useEffect,
useId, useId,
useRef, useRef,
useState,
type HTMLAttributes, type HTMLAttributes,
type PointerEvent, type PointerEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { cn } from "./cn.js"; import { cn } from "./cn.js";
import { Icon } from "./Icon.js";
const focusableSelector = [ const focusableSelector = [
"a[href]", "a[href]",
@ -34,6 +36,7 @@ export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title
closeOnEscape?: boolean; closeOnEscape?: boolean;
lockBodyScroll?: boolean; lockBodyScroll?: boolean;
trapFocus?: boolean; trapFocus?: boolean;
draggable?: boolean;
onClose: () => void; onClose: () => void;
} }
@ -50,13 +53,17 @@ export function Window({
closeOnEscape = true, closeOnEscape = true,
lockBodyScroll, lockBodyScroll,
trapFocus, trapFocus,
draggable = false,
onClose, onClose,
className, className,
style,
...props ...props
}: WindowProps) { }: WindowProps) {
const titleId = useId(); const titleId = useId();
const descriptionId = useId(); const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center"; const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
const shouldTrapFocus = trapFocus ?? placement === "center"; const shouldTrapFocus = trapFocus ?? placement === "center";
@ -104,6 +111,34 @@ export function Window({
}; };
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]); }, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
useEffect(() => {
if (!open) setDragPosition(null);
}, [open]);
useEffect(() => {
if (!draggable || !open) return;
const handlePointerMove = (event: globalThis.PointerEvent) => {
const drag = dragRef.current;
const dialog = dialogRef.current;
if (!drag || !dialog || event.pointerId !== drag.pointerId) return;
const bounds = dialog.getBoundingClientRect();
const left = Math.max(8, Math.min(window.innerWidth - bounds.width - 8, event.clientX - drag.offsetX));
const top = Math.max(76, Math.min(window.innerHeight - bounds.height - 8, event.clientY - drag.offsetY));
setDragPosition({ left, top });
};
const handlePointerUp = (event: globalThis.PointerEvent) => {
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
}, [draggable, open]);
if (!open || typeof document === "undefined") return null; if (!open || typeof document === "undefined") return null;
const allowBackdropClose = closeOnBackdrop ?? placement === "center"; const allowBackdropClose = closeOnBackdrop ?? placement === "center";
@ -119,20 +154,31 @@ export function Window({
className={cn("nodedc-window nodedc-material-rim", className)} className={cn("nodedc-window nodedc-material-rim", className)}
data-size={size === "md" ? undefined : size} data-size={size === "md" ? undefined : size}
data-placement={placement} data-placement={placement}
data-draggable={draggable ? "true" : undefined}
role="dialog" role="dialog"
aria-modal={placement === "center" ? "true" : undefined} aria-modal={placement === "center" ? "true" : undefined}
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={subtitle ? descriptionId : undefined} aria-describedby={subtitle ? descriptionId : undefined}
tabIndex={-1} tabIndex={-1}
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
{...props} {...props}
> >
<header className="nodedc-window__head"> <header
className="nodedc-window__head"
onPointerDown={(event) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return;
const bounds = dialogRef.current?.getBoundingClientRect();
if (!bounds) return;
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top };
event.preventDefault();
}}
>
<div className="nodedc-window__titles"> <div className="nodedc-window__titles">
<h2 id={titleId} className="nodedc-window__title">{title}</h2> <h2 id={titleId} className="nodedc-window__title">{title}</h2>
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null} {subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
</div> </div>
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}> <button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
<span className="nodedc-close-mark" aria-hidden="true" /> <Icon name="close" size={16} strokeWidth={1.6} />
</button> </button>
</header> </header>
<div className="nodedc-window__body">{children}</div> <div className="nodedc-window__body">{children}</div>

View File

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

View File

@ -120,11 +120,12 @@
"domExports": ["createModalController"], "domExports": ["createModalController"],
"domContract": ["nodedc-overlay", "nodedc-window"], "domContract": ["nodedc-overlay", "nodedc-window"],
"summary": "Canonical controlled window for modal and right-side panel presentations.", "summary": "Canonical controlled window for modal and right-side panel presentations.",
"behavior": ["portal to body", "focus entry", "modal-only focus trap", "focus restore", "Escape close", "backdrop close policy", "modal-only body scroll lock"], "behavior": ["portal to body", "focus entry", "modal-only focus trap", "focus restore", "Escape close", "backdrop close policy", "modal-only body scroll lock", "optional bounded dragging for modeless inspectors"],
"rules": [ "rules": [
"Open state belongs to the application; focus/layer behavior belongs to the component.", "Open state belongs to the application; focus/layer behavior belongs to the component.",
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.", "The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock." "A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
"A draggable inspector moves by its header and remains clamped to the viewport."
] ]
}, },
{ {
@ -206,6 +207,21 @@
"A layout-level save action belongs to ApplicationPanel.utilityActions and is never duplicated inside individual content cards." "A layout-level save action belongs to ApplicationPanel.utilityActions and is never duplicated inside individual content cards."
] ]
}, },
{
"id": "toolbar",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["Toolbar"],
"domContract": ["nodedc-toolbar-wrap", "nodedc-toolbar", "nodedc-toolbar__button"],
"summary": "Engine/BIM-derived magnifying application toolbar with left, right and bottom placements.",
"variants": ["left", "right", "bottom"],
"behavior": ["controlled active item", "lens magnification", "optional autohide hotzone", "theme-independent geometry"],
"rules": [
"Engine defaults are background #111115, border #111117, outline #1c1c1c, min 25 px, max 87 px and five lens items.",
"Toolbar actions call application navigation; they do not duplicate route state.",
"Placement and material are controlled values and belong to the persisted application layout."
]
},
{ {
"id": "icon", "id": "icon",
"status": "baseline", "status": "baseline",
@ -216,7 +232,7 @@
"variants": ["14 px", "16 px", "18 px"], "variants": ["14 px", "16 px", "18 px"],
"rules": [ "rules": [
"Use semantic names from registry/icons.json.", "Use semantic names from registry/icons.json.",
"The containing component owns surface, hit target and state; the icon owns only the glyph and is optically scaled to 78% inside controls.", "The containing component owns surface, hit target and state; the icon owns only the glyph and uses the canonical 16 px default without an extra CSS scale.",
"Do not copy complete Lucide or Font Awesome catalogs into an application." "Do not copy complete Lucide or Font Awesome catalogs into an application."
] ]
}, },

140
server/catalog-server.mjs Normal file
View File

@ -0,0 +1,140 @@
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { basename, extname, join, normalize } from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("..", import.meta.url));
const distDir = join(root, "apps", "catalog", "dist");
const runtimeDir = join(root, "runtime-data");
const uploadDir = join(runtimeDir, "uploads");
const layoutPath = join(runtimeDir, "layout.json");
const port = Number(process.env.PORT || 3333);
await mkdir(uploadDir, { recursive: true });
const mimeTypes = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mp4": "video/mp4",
".png": "image/png",
".svg": "image/svg+xml",
".webm": "video/webm",
};
function json(response, statusCode, value) {
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
response.end(JSON.stringify(value));
}
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) throw new Error("payload_too_large");
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function safeUploadName(name) {
const extension = extname(name).toLowerCase() === ".mp4" ? ".mp4" : ".mp4";
const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video";
return `${Date.now()}-${stem}${extension}`;
}
async function serveFile(request, response, filePath) {
const info = await stat(filePath);
const contentType = mimeTypes[extname(filePath).toLowerCase()] || "application/octet-stream";
const range = request.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
if (range) {
const start = range[1] ? Number(range[1]) : 0;
const end = range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= info.size) {
response.writeHead(416, { "content-range": `bytes */${info.size}` });
response.end();
return;
}
response.writeHead(206, {
"content-type": contentType,
"content-length": end - start + 1,
"content-range": `bytes ${start}-${end}/${info.size}`,
"accept-ranges": "bytes",
});
if (request.method === "HEAD") return response.end();
createReadStream(filePath, { start, end }).pipe(response);
return;
}
response.writeHead(200, {
"content-type": contentType,
"content-length": info.size,
"accept-ranges": "bytes",
});
if (request.method === "HEAD") return response.end();
createReadStream(filePath).pipe(response);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
if (url.pathname === "/api/layout" && request.method === "GET") {
try {
json(response, 200, JSON.parse(await readFile(layoutPath, "utf8")));
} catch (error) {
if (error?.code === "ENOENT") return json(response, 200, null);
throw error;
}
return;
}
if (url.pathname === "/api/layout" && request.method === "PUT") {
const layout = await readJsonBody(request);
const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 };
const tempPath = `${layoutPath}.tmp`;
await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await rename(tempPath, layoutPath);
json(response, 200, next);
return;
}
if (url.pathname === "/api/layout/media" && request.method === "PUT") {
const originalName = decodeURIComponent(String(request.headers["x-file-name"] || "stage-video.mp4"));
const fileName = safeUploadName(originalName);
const targetPath = join(uploadDir, fileName);
await pipeline(request, createWriteStream(targetPath, { flags: "wx" }));
json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` });
return;
}
if (url.pathname.startsWith("/uploads/")) {
const fileName = basename(normalize(url.pathname.slice("/uploads/".length)));
await serveFile(request, response, join(uploadDir, fileName));
return;
}
const relativePath = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\//, "");
const candidate = join(distDir, normalize(relativePath));
try {
await serveFile(request, response, candidate);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
await serveFile(request, response, join(distDir, "index.html"));
}
} catch (error) {
json(response, error?.message === "payload_too_large" ? 413 : 500, { error: error?.message || "server_error" });
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`NODE.DC Design Guideline: http://127.0.0.1:${port}`);
console.log(`Layout store: ${layoutPath}`);
});