diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 53582ec..ea1d001 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -6,6 +6,7 @@ import { AppHeader, ApplicationPanel, ApplicationShell, + ApplicationSidePanel, Button, Checker, ColorField, @@ -318,6 +319,9 @@ export function CatalogApp() { const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const mapTemplatePreviewRef = useRef(null); const applicationMapPreviewRef = useRef(null); + const [mapSettingsPanelHost, setMapSettingsPanelHost] = useState(null); + const [mapHeaderActionsHost, setMapHeaderActionsHost] = useState(null); + const [mapSettingsPanelOpen, setMapSettingsPanelOpen] = useState(false); const [createModuleOpen, setCreateModuleOpen] = useState(false); const [createModuleName, setCreateModuleName] = useState(""); const [createModuleSlug, setCreateModuleSlug] = useState(""); @@ -355,6 +359,7 @@ export function CatalogApp() { const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true); const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false); const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState({ x: 24, y: 24, width: 340, height: 230 }); + const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true); const [createModalOpen, setCreateModalOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [shareAccessOpen, setShareAccessOpen] = useState(false); @@ -1413,6 +1418,23 @@ export function CatalogApp() {

Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.

+ +
+ {sidePanelDemoOpen ? ( + setSidePanelDemoOpen(false)} + > + + + ) : ( + + )} +
+

Панель занимает отдельную end-колонку ApplicationShell, сдвигает рабочую область, наследует тему и не входит в z-index stack окон карты.

+

Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.

@@ -1794,6 +1816,9 @@ export function CatalogApp() { expanded initialLayout={mapDesignLayout} features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))} + settingsPanelHost={mapSettingsPanelHost} + headerActionsHost={mapHeaderActionsHost} + onSettingsPanelOpenChange={setMapSettingsPanelOpen} />
@@ -1807,7 +1832,12 @@ export function CatalogApp() { title={template.title} description={template.description} > - [feature.id, feature.required ? true : feature.defaultVisible]))} /> + [feature.id, feature.required ? true : feature.defaultVisible]))} + settingsPanelHost={mapSettingsPanelHost} + headerActionsHost={mapHeaderActionsHost} + onSettingsPanelOpenChange={setMapSettingsPanelOpen} + />
Только утверждённые features и slots — без свободного canvas. @@ -1860,6 +1890,9 @@ export function CatalogApp() { initialLayout={resolvedMapLayout} features={page.features} expanded + settingsPanelHost={mapSettingsPanelHost} + headerActionsHost={mapHeaderActionsHost} + onSettingsPanelOpenChange={setMapSettingsPanelOpen} /> {applicationMode === "edit" ? ( @@ -1888,6 +1921,8 @@ export function CatalogApp() { navigationOpen={guidelineOpen} contentOpen={workspace.contentOpen} contentExpanded={panelExpanded} + endPanelOpen={mapSettingsPanelOpen} + endPanel={
} header={ } @@ -2035,6 +2070,7 @@ export function CatalogApp() { description={`${activePageTemplate.category} · contract ${activePageTemplate.schemaVersion} · template ${activePageTemplate.version}`} expanded={panelExpanded} onExpandedChange={workspace.setContentExpanded} + headerTools={activePageTemplate.id === "map" ?
: undefined} utilityActions={activePageTemplate.id === "map" ? [{ label: mapTemplateSaveState === "saving" ? "Сохраняем Map-фрагмент в Design Profile" : mapTemplateSaveState === "error" ? "Повторить сохранение Map-фрагмента" : "Сохранить дизайн Map в Design Profile", icon: "save", @@ -2053,6 +2089,7 @@ export function CatalogApp() { description={activeApplicationPage ? `${activeApplication.metadata.name} · ${activeApplicationPage.template.id}@${activeApplicationPage.template.version}` : `/${activeApplication.metadata.slug} · manifest ${activeApplication.schemaVersion}`} expanded={panelExpanded} onExpandedChange={workspace.setContentExpanded} + headerTools={activeApplicationPage?.template.id === "map" ?
: undefined} utilityActions={[{ label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить", icon: "save", diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 22b5c06..b6e0aa4 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -3080,8 +3080,6 @@ export const CesiumMapRenderer = forwardRef).kind === "nodedc-hgeozone" && typeof (pickedId as Partial).entityId === "string" ) { - gridController?.setSelection(null); - onGridSectorSelectRef.current?.(null); onSelectRef.current?.((pickedId as HGeoZonePickId).entityId); return; } diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 77e77a5..abc5983 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -1,5 +1,6 @@ import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; -import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react"; +import { createPortal } from "react-dom"; +import { ApplicationSidePanel, Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react"; import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react"; import type { CameraSpiralState, @@ -461,7 +462,7 @@ export type MapSubjectWindowState = { zIndex: number; }; -type MapWorkspaceWindowId = "settings" | "layers" | "sector" | "subject-card" | `binding:${string}`; +type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`; export type MapSubjectState = { bindingId: string; @@ -641,20 +642,6 @@ function defaultSubjectWindowState(index: number): MapSubjectWindowState { }; } -const defaultLayersWindowRect: WorkspaceWindowRect = { - x: 24, - y: 72, - width: 336, - height: 500, -}; - -const defaultSettingsWindowRect: WorkspaceWindowRect = { - x: 930, - y: 72, - width: 420, - height: 530, -}; - const defaultSectorWindowRect: WorkspaceWindowRect = { x: 24, y: 72, @@ -718,7 +705,10 @@ export const MapFixturePreview = forwardRef(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) { + settingsPanelHost?: HTMLElement | null; + headerActionsHost?: HTMLElement | null; + onSettingsPanelOpenChange?: (open: boolean) => void; +}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId, settingsPanelHost, headerActionsHost, onSettingsPanelOpenChange }, ref) { const workspaceRef = useRef(null); const [selectedId, setSelectedId] = useState(); const [selectedGridSector, setSelectedGridSector] = useState(null); @@ -730,16 +720,10 @@ export const MapFixturePreview = forwardRef>({}); const [inspectorOpen, setInspectorOpen] = useState(false); - const [settingsWindowRect, setSettingsWindowRect] = useState(defaultSettingsWindowRect); - const [settingsWindowMaximized, setSettingsWindowMaximized] = useState(false); - const [settingsWindowZIndex, setSettingsWindowZIndex] = useState(13); const [inspectorOpenSections, setInspectorOpenSections] = useState(() => ( initialLayout?.inspectorOpenSections ?? ["map-base"] )); const [layersOpen, setLayersOpen] = useState(false); - const [layersWindowRect, setLayersWindowRect] = useState(defaultLayersWindowRect); - const [layersWindowMaximized, setLayersWindowMaximized] = useState(false); - const [layersWindowZIndex, setLayersWindowZIndex] = useState(12); const [sectorWindowRect, setSectorWindowRect] = useState(defaultSectorWindowRect); const [sectorWindowMaximized, setSectorWindowMaximized] = useState(false); const [sectorWindowZIndex, setSectorWindowZIndex] = useState(142); @@ -1385,15 +1369,11 @@ export const MapFixturePreview = forwardRef state.window.zIndex), ) + 1; - if (windowId === "settings") setSettingsWindowZIndex(nextZIndex); - else if (windowId === "layers") setLayersWindowZIndex(nextZIndex); - else if (windowId === "sector") setSectorWindowZIndex(nextZIndex); + if (windowId === "sector") setSectorWindowZIndex(nextZIndex); else if (windowId === "subject-card") setSubjectCardZIndex(nextZIndex); else if (windowId.startsWith("binding:")) { const bindingId = windowId.slice("binding:".length); @@ -1403,7 +1383,7 @@ export const MapFixturePreview = forwardRef { setActiveWorkspaceWindowId((current) => current === windowId ? undefined : current); @@ -1422,24 +1402,15 @@ export const MapFixturePreview = forwardRef { - activateWorkspaceWindow("layers"); - }; + const closeSettingsPanel = useCallback(() => setInspectorOpen(false), []); - const toggleLayersWindow = () => { - if (layersOpen) { - setLayersOpen(false); - clearActiveWorkspaceWindow("layers"); - return; - } - setLayersOpen(true); - activateLayersWindow(); - }; + const toggleSettingsPanel = () => setInspectorOpen((current) => !current); - const openSettingsWindow = () => { - setInspectorOpen(true); - activateWorkspaceWindow("settings"); - }; + useEffect(() => { + onSettingsPanelOpenChange?.(inspectorOpen && Boolean(features.inspector)); + }, [features.inspector, inspectorOpen, onSettingsPanelOpenChange]); + + useEffect(() => () => onSettingsPanelOpenChange?.(false), [onSettingsPanelOpenChange]); const deactivateGridSector = () => { setSelectedGridSector(null); @@ -1718,6 +1689,7 @@ export const MapFixturePreview = forwardRef, content: <> Cesium World Imagery Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. @@ -1743,6 +1715,7 @@ export const MapFixturePreview = forwardRef, content: <> updateMapSettings({ atmosphereEnabled })} /> `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} /> @@ -1761,6 +1734,7 @@ export const MapFixturePreview = forwardRef, content: <> updateMapSettings({ buildingsVisible })} /> updateMapSettings({ buildingsColor })} /> @@ -1777,6 +1751,7 @@ export const MapFixturePreview = forwardRef, content: <> Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. {referenceLayer ? ( @@ -1831,6 +1806,7 @@ export const MapFixturePreview = forwardRef, content: <> Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту. {profile.styles.map((style) => { @@ -1850,6 +1826,7 @@ export const MapFixturePreview = forwardRef, content:
Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область. updateMapSettings({ gridVisible })} /> @@ -2108,6 +2085,7 @@ export const MapFixturePreview = forwardRef, content: <> {animationModeEnabled ? <> @@ -2186,6 +2164,7 @@ export const MapFixturePreview = forwardRef, content: <> Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю. @@ -2211,6 +2190,7 @@ export const MapFixturePreview = forwardRef, content: <> {selected?.title ?? "Нет выбора"} {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} @@ -2218,6 +2198,31 @@ export const MapFixturePreview = forwardRef + + {features.toolbar ? setToolbarOpen((value) => !value)}> : null} +
+ ); + + const settingsPanel = inspectorOpen && Boolean(features.inspector) ? ( + + + + ) : null; + return (
- Загрузка карты…
}> - - - -
- - - {features.toolbar ? setToolbarOpen((value) => !value)}> : null} - {features.assistant ? setAssistantOpen((value) => !value)}> : null} +
+ Загрузка карты…
}> + +
- {layersOpen ? ( - { - setLayersOpen(false); - clearActiveWorkspaceWindow("layers"); - }} - title="Слои карты" - active={activeWorkspaceWindowId === "layers"} - zIndex={layersWindowZIndex} - minWidth={320} - minHeight={360} - className="catalog-map-fixture__layers catalog-map-fixture__map-glass-window" - aria-label="Настройки слоёв карты" - > -
-
- Cesium World Imagery - официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]} -
- {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} - updateMapSettings({ terrainEnabled })} /> - updateMapSettings({ buildingsVisible })} /> - updateMapSettings({ gridVisible })} /> - Live Cache: {liveCacheSummary} - {transportDiagnostic ? {transportDiagnostic} : null} - {gatewayHealthAge ? {gatewayHealthAge} : null} - {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} - - -
-
+ {features.assistant ? ( +
+ setAssistantOpen((value) => !value)}> +
) : null} {selectedGridSector ? ( @@ -2497,6 +2464,39 @@ export const MapFixturePreview = forwardRef )} + ( + + )} + > +
+
+ Слои карты + Подложка, рельеф и визуальные слои +
+
+ Cesium World Imagery + официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]} +
+ {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} + updateMapSettings({ terrainEnabled })} /> + updateMapSettings({ buildingsVisible })} /> + updateMapSettings({ gridVisible })} /> + Live Cache: {liveCacheSummary} + {transportDiagnostic ? {transportDiagnostic} : null} + {gatewayHealthAge ? {gatewayHealthAge} : null} + {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} + + +
+
mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}> NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} - {inspectorOpen && Boolean(features.inspector) ? ( - activateWorkspaceWindow("settings")} - onClose={() => { - setInspectorOpen(false); - clearActiveWorkspaceWindow("settings"); - }} - title="Настройки карты" - subtitle="MAP / inspector" - active={activeWorkspaceWindowId === "settings"} - zIndex={settingsWindowZIndex} - minWidth={360} - minHeight={380} - className="catalog-map-fixture__map-settings-window catalog-map-fixture__map-glass-window" - aria-label="Настройки карты" - > - - - ) : null} + {settingsPanel + ? settingsPanelHost + ? createPortal(settingsPanel, settingsPanelHost) + :
{settingsPanel}
+ : null} + {headerActionsHost ? createPortal(headerActions, headerActionsHost) : null}
); }); diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 3f3ca43..76cecea 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -568,6 +568,11 @@ textarea { .catalog-map-fixture--expanded { min-height: 22.5rem; } +.catalog-map-fixture__renderer { + position: absolute; + inset: 0; +} + .catalog-map-fixture__loading { position: absolute; inset: 0; @@ -612,6 +617,36 @@ textarea { gap: 0.45rem; } +.catalog-map-header-actions-host, +.catalog-map-header-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.catalog-map-header-action.nodedc-icon-button { + width: 2.92rem; + height: 2.92rem; + flex-basis: 2.92rem; + border: 1px solid var(--nodedc-panel-action-border); + background: var(--nodedc-panel-action-bg); + color: var(--nodedc-text-secondary); + box-shadow: var(--nodedc-panel-action-shadow); +} + +.catalog-map-header-action.nodedc-icon-button:hover, +.catalog-map-header-action.nodedc-icon-button:focus-visible { + border-color: var(--nodedc-panel-action-border-hover); + background: var(--nodedc-glass-control-hover); + color: var(--nodedc-text-primary); +} + +.catalog-map-header-action.nodedc-icon-button[data-active="true"] { + border-color: transparent; + background: var(--nodedc-glass-control-active); + color: var(--nodedc-glass-control-active-text); +} + .catalog-map-fixture__actions .nodedc-icon-button, .catalog-map-fixture__toolbar .nodedc-icon-button { border: 0; @@ -633,14 +668,11 @@ textarea { color: var(--nodedc-glass-control-active-text); } -.catalog-map-fixture__layers, -.catalog-map-fixture__map-settings-window, .catalog-map-fixture__sector-window { --nodedc-radius-modal: 1.45rem; } -.catalog-map-fixture__map-glass-window, -.catalog-map-fixture__map-settings-window { +.catalog-map-fixture__map-glass-window { --nodedc-canvas: #111216; --nodedc-text-primary: rgba(13, 14, 17, 0.96); --nodedc-text-secondary: rgba(13, 14, 17, 0.72); @@ -667,10 +699,6 @@ textarea { color: #ffffff; } -.catalog-map-fixture__map-settings-window .nodedc-workspace-window__body { - padding: 0.3rem 0.75rem 0.8rem; -} - .catalog-map-fixture__sector-window .nodedc-workspace-window__body { padding: 0.25rem 0.72rem 0.7rem; } @@ -765,12 +793,17 @@ textarea { gap: 0.55rem; } -.catalog-map-fixture__layers .nodedc-workspace-window__body { - padding-top: 0.35rem; +.catalog-map-fixture__layers-menu .nodedc-checker { + min-height: 3rem; } -.catalog-map-fixture__layers .nodedc-checker { - min-height: 3rem; +.catalog-map-fixture__layers-menu { + --nodedc-text-primary: var(--nodedc-map-glass-text); + --nodedc-text-secondary: var(--nodedc-map-glass-text); + --nodedc-text-muted: var(--nodedc-map-glass-text-muted); + --nodedc-glass-control-bg: rgb(255 255 255 / 0.14); + --nodedc-glass-control-hover: rgb(255 255 255 / 0.22); + --nodedc-field-bg: rgb(255 255 255 / 0.14); } .catalog-map-fixture__provider { @@ -796,7 +829,7 @@ textarea { bottom: 1rem; left: 50%; display: flex; - width: 10.8rem; + width: 13.45rem; gap: 0.35rem; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-map-glass-bg); @@ -809,6 +842,7 @@ textarea { .catalog-map-fixture__toolbar[data-search-open] { width: min(42rem, calc(100% - 2rem)); + padding-right: 0; } .catalog-map-search { @@ -822,7 +856,7 @@ textarea { } .catalog-map-search[data-open] { - flex-basis: 28rem; + flex: 1 1 0; overflow: visible; opacity: 1; pointer-events: auto; @@ -837,7 +871,7 @@ textarea { border-radius: var(--nodedc-radius-circle); background: rgb(255 255 255 / 0.84); padding: 0 0.82rem; - color: var(--nodedc-map-glass-text); + color: var(--nodedc-map-field-text); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.34); } @@ -857,7 +891,7 @@ textarea { } .catalog-map-search__field input::placeholder { - color: var(--nodedc-map-glass-text-muted); + color: var(--nodedc-map-field-text-muted); } .catalog-map-search__field input::-webkit-search-cancel-button { @@ -938,6 +972,13 @@ textarea { padding: 0.48rem; } +.catalog-map-fixture__settings-panel-fallback { + position: absolute; + z-index: 500; + inset: 0 0 0 auto; + width: min(22rem, calc(100% - 1rem)); +} + .catalog-map-fixture__objects-menu-list, .catalog-map-fixture__objects-menu-head, .catalog-map-fixture__objects-menu-toggle { @@ -1630,6 +1671,32 @@ textarea { transform: translate(-50%, -50%); } +.catalog-side-panel-preview .catalog-preview__body { + align-content: stretch; +} + +.catalog-side-panel-demo { + display: grid; + min-height: 31rem; + justify-content: end; + align-items: stretch; + overflow: hidden; + border-radius: var(--nodedc-radius-card); + background: + radial-gradient(circle at 30% 24%, color-mix(in srgb, var(--catalog-accent) 20%, transparent), transparent 36%), + var(--nodedc-nested-surface); + padding-left: 1rem; +} + +.catalog-side-panel-demo > .nodedc-application-side-panel { + width: min(22rem, 100%); +} + +.catalog-side-panel-demo > .nodedc-button { + align-self: center; + justify-self: center; +} + .catalog-inspector-launcher .nodedc-button { width: 100%; } diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 2495ec6..932db24 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -95,8 +95,11 @@ Window — единая механика открытия modal и правой `WorkspaceWindow` — modeless-окно внутри рабочей сцены приложения. Оно рендерится непосредственно в переданном workspace, не использует portal и не может перекрыть шапку, навигацию или соседние панели за пределами этого workspace. -На Map Page `WorkspaceWindow` используется и для facet-групп, и для -provider-neutral карточки выбранного subject. Chevron внутри facet-строки +На Map Page `WorkspaceWindow` используется для facet-групп, активного сектора +и provider-neutral карточки выбранного subject. Настройки карты являются +application-level панелью `ApplicationSidePanel`, а слои открываются через +канонический toolbar `Dropdown`; они не участвуют в bounded z-index stack. +Chevron внутри facet-строки владеет только раскрытием дерева subjects; тело строки сохраняет действие фильтра. Смена совместимого subject не пересоздаёт карточку и не сбрасывает активную вкладку. Joined data-product aspects не создают отдельные окна или @@ -107,8 +110,10 @@ slot `reference-points`. Их три presentation profiles (`Метро`, `Во `Станции РЖД`) являются живыми каноническими Inspector sections: приложение может менять высоту, размер, label и LOD, не создавая provider-specific UI. -Центральный Map Toolbar содержит template-owned универсальный поиск. В -закрытом состоянии это три круглых действия; при открытии общий pill +Центральный Map Toolbar содержит Objects, Layers и template-owned +универсальный поиск. Слои используют тот же map-glass dropdown и геометрию, +что Objects, вместо отдельного плавающего окна. В закрытом состоянии toolbar +содержит четыре круглых действия; при открытии поиска общий pill симметрично расширяется, действия остаются слева, а справа появляется search-field и список результатов. Индекс строится по domain subjects, presentation label fields и разрешённым Data Product projections. Компонент не @@ -220,6 +225,26 @@ setup-команды, сохранение, API и права принадлеж `AdminNavigationPanel` поддерживает сортируемые route-items через общий Drag & Drop contract. Приложение передаёт новый порядок id, а панель использует каноническую шеститочечную ручку Engine. Порядок не хранится отдельно внутри navigation-компонента. +## ApplicationSidePanel + +`ApplicationSidePanel` — правая application-level пара к `AdminNavigationPanel`. +Она использует ту же ширину `332–352 px`, радиус, theme-aware surface, тень и +круглые действия, но появляется справа налево. `ApplicationShell` размещает её +в отдельной end-колонке и на desktop уменьшает доступную ширину раскрытого +content/stage на ширину панели плюс канонический gap `20 px`; overlay карты не +создаётся. + +Панель контролируется приложением. Она открывается и закрывается явным action +в header `ApplicationPanel`; Close всегда закрывает её, а взаимодействие с +картой не меняет состояние панели. Внутри настроек используется `Inspector +variant="panel"`: accordion сохраняет свой layout-state, а его заголовки +получают pill/icon geometry и цвета левой панели из theme tokens. + +Этот формат предназначен для настроек и detail-контента уровня Application. +Инструменты, сектор, facet-окна и карточки, ограниченные самой сценой, остаются +`WorkspaceWindow`. Floating dropdown для короткого списка слоёв не заменяется +полноразмерной side panel. + ## Drag & Drop `DragHandle`, `DragDropRoot`, `DraggableItem`, `DropZone`, `SortableScope`, `SortableItem` и `SortableList` образуют общий React-контракт переноса и сортировки. Он перенесён из рабочего Engine-паттерна: drag начинается только за шесть точек и только после движения на `6 px`, поэтому обычный клик по строке не конфликтует с навигацией. diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index e77723a..7184ad0 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -43,6 +43,11 @@ profile; renderer не содержит provider-specific условий. Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout. +Соседняя кнопка `Слои карты` открывает вверх dropdown той же ширины, +map-glass поверхности и row geometry, что `Объекты`. Terrain, 3D buildings, +planetary grid и cache policy больше не создают отдельное draggable-окно и не +занимают место в bounded window stack. + Клик по pin или его label открывает второе canonical `WorkspaceWindow` — карточку выбранного объекта. Её вкладки и поля задаёт versioned provider-neutral `subjectDetailProfile`, а не renderer и не provider payload. Базовый профиль @@ -74,7 +79,7 @@ MMAP/AIS: сохраняет `heading`, `pitch`, `roll` и смещение ка центра viewport; длительность перелёта для текущего канонического workflow — `0.45 s`. -Кнопка поиска разворачивает центральный Toolbar симметрично, оставляя три +Кнопка поиска разворачивает центральный Toolbar симметрично, оставляя четыре системных действия слева и открывая единое поле справа. Поиск не знает Gelios, OSM или Cesium: он строит локальный индекс по всем подключённым map subjects. Для primary binding индексируются стабильный `sourceId`, label fields @@ -118,10 +123,22 @@ graticule используется тот же глобальный адреса кнопка `Деактивировать сектор` выполняют одну операцию: снимают selection и очищают transient sector-фильтры. -Настройки карты, слои, окно сектора, binding-окна и карточка subject используют -один application-owned workspace stack. Pointer/focus поднимает выбранное окно -выше остальных независимо от его типа; ни одно из этих map-stage окон не -рендерится в глобальный viewport overlay. +Клик по точечному subject, его elevated spike, label или HGeoZone меняет +`map.selection` и открывает карточку, но не снимает активный grid sector. +Сектор деактивируется только явным background/grid selection либо одним из +двух действий деактивации выше. + +Окно сектора, binding-окна и карточка subject используют один +application-owned workspace stack. Pointer/focus поднимает выбранное окно выше +остальных независимо от его типа; ни одно из этих map-stage окон не рендерится +в глобальный viewport overlay. Настройки карты находятся уровнем выше в +`ApplicationSidePanel`: она выезжает справа налево, занимает отдельную колонку +`ApplicationShell` и сжимает центральный content вместе с картой. Settings и +Toolbar являются theme-aware actions в header `ApplicationPanel`: активное +состояние использует белую canonical surface, неактивное — panel surface с +обводкой. Панель закрывается повторным Settings action либо собственным Close; +клик по карте её не закрывает. Слои остаются коротким portal-dropdown нижнего +toolbar. ## Platform reference layers @@ -165,8 +182,9 @@ Layout получают зарегистрированные reference bindings их файл не переписывается до явного сохранения пользователем. Тот же Page Layout хранит `inspectorOpenSections`. В текущем single-open -Inspector это пустой список либо id ровно одной раскрытой секции. Поэтому -закрытие окна настроек и повторное открытие не сбрасывает аккордеон, а общая +`Inspector variant="panel"` это пустой список либо id ровно одной раскрытой +секции. Поэтому закрытие правой панели настроек и повторное открытие не +сбрасывает аккордеон, а общая кнопка Application Save сохраняет его вместе с камерой и остальным layout. Cesium adapter рендерит elevated targets и label plates как единый overlay над diff --git a/docs/THEMING.md b/docs/THEMING.md index 17edd76..5c33971 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -52,6 +52,15 @@ Semantic status не обязан совпадать с accent. Например Portal-компоненты рендерятся в `document.body`, поэтому приложение должно либо задавать тему на `document.documentElement`, либо передавать согласованные переменные на body. Локальная тема глубоко внутри React subtree не сможет автоматически охватить portal без отдельного theme portal root. +Map-glass overlays являются отдельной, независимой от application theme +поверхностью поверх imagery. Их светлые translucent controls используют +`--nodedc-map-glass-text`, а непрозрачное светлое поле поиска обязано брать +тёмный foreground из `--nodedc-map-field-text` и placeholder из +`--nodedc-map-field-text-muted`. Светлый field не наследует белый текст +map-glass родителя. `ApplicationSidePanel`, напротив, находится вне imagery и +всегда наследует обычные theme-aware application panel/text tokens; при смене +dark/light она меняется синхронно с `AdminNavigationPanel`. + ## Совместимость Существующие `--nodedc-*` имена сохранены намеренно. Это уменьшает стоимость будущего подключения Launcher, Engine, Task Manager и BIM Viewer. diff --git a/docs/WINDOWS_AND_LAYERS.md b/docs/WINDOWS_AND_LAYERS.md index 5be6aa6..d2cfe33 100644 --- a/docs/WINDOWS_AND_LAYERS.md +++ b/docs/WINDOWS_AND_LAYERS.md @@ -70,6 +70,20 @@ Side window использует ту же механику, но placement `end По умолчанию это modeless-слой: он не затемняет страницу, не перехватывает клики по основной области, не закрывается по backdrop, не блокирует body scroll и не удерживает Tab внутри панели. Escape и кнопка закрытия остаются доступны. Если конкретный workflow должен быть modal, это задаётся явно, а не получается случайно из placement. +### ApplicationSidePanel: отдельная push-колонка + +Когда settings/detail принадлежат всему Application и должны физически +уменьшать рабочую область, используется `ApplicationSidePanel` внутри +`ApplicationShell.endPanel`. На desktop панель имеет ту же ширину, surface, +радиус и тень, что `AdminNavigationPanel`, входит справа налево и сдвигает +content/stage на panel width + page gap. Она не является `Window`, не получает +backdrop и не входит в stack bounded-окон сцены. + +Открытие панели контролирует action в header владельца, закрытие — тот же +action или обязательный close action самой панели. Клик по рабочей области не +закрывает её неявно. На mobile отдельная колонка становится полноширинным +верхним panel-layer, потому что сохранять две узкие колонки там невозможно. + ## Workspace window `WorkspaceWindow` отличается от modal и side inspector границей слоя: это inline modeless-окно, ограниченное конкретной рабочей сценой приложения. @@ -89,10 +103,12 @@ Side window использует ту же механику, но placement `end Workspace window используется для вспомогательных камер, инструментов и сопоставляемых представлений внутри stage. Оно не заменяет modal `Window`, viewport-level Inspector или `ApplicationPanel`. Если Inspector принадлежит конкретной bounded-сцене и должен конкурировать по -z-order с её слоями, карточками и инструментами, он является содержимым -`WorkspaceWindow`, а не viewport-level side window. Map Page следует именно -этому варианту: настройки карты, слои, активный сектор, окна bindings и -карточка объекта входят в один контролируемый stack. +z-order с её карточками и инструментами, он является содержимым +`WorkspaceWindow`, а не application-level side panel. Текущий Map Page +разделяет уровни явно: активный сектор, окна bindings и карточка объекта входят +в один bounded stack; настройки карты живут в `ApplicationSidePanel`; короткие +настройки слоёв открываются из нижнего toolbar через `Dropdown` в том же +map-glass дизайне, что Objects. ## Управление состоянием diff --git a/packages/tokens/themes.css b/packages/tokens/themes.css index 80e0a9b..283c1ba 100644 --- a/packages/tokens/themes.css +++ b/packages/tokens/themes.css @@ -37,6 +37,8 @@ --nodedc-map-glass-active: rgba(255, 255, 255, 0.92); --nodedc-map-glass-text: rgba(255, 255, 255, 0.96); --nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72); + --nodedc-map-field-text: rgba(13, 14, 17, 0.96); + --nodedc-map-field-text-muted: rgba(13, 14, 17, 0.54); --nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity)); --nodedc-overlay-bg: rgba(0, 0, 0, 0.46); --nodedc-glass-rim: transparent; @@ -108,6 +110,8 @@ --nodedc-map-glass-active: rgba(255, 255, 255, 0.92); --nodedc-map-glass-text: rgba(255, 255, 255, 0.96); --nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72); + --nodedc-map-field-text: rgba(13, 14, 17, 0.96); + --nodedc-map-field-text-muted: rgba(13, 14, 17, 0.54); --nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity)); --nodedc-overlay-bg: rgba(24, 32, 29, 0.22); --nodedc-glass-rim: transparent; diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 0bf3400..2e19022 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -1376,7 +1376,8 @@ textarea.nodedc-field__control { } .nodedc-app-shell__navigation, -.nodedc-app-shell__content { +.nodedc-app-shell__content, +.nodedc-app-shell__end-panel { position: absolute; z-index: var(--nodedc-layer-panel); top: 0; @@ -1395,6 +1396,27 @@ textarea.nodedc-field__control { transition: width 500ms cubic-bezier(0.22, 1, 0.36, 1), right 500ms cubic-bezier(0.22, 1, 0.36, 1); } +.nodedc-app-shell__end-panel { + right: var(--nodedc-app-page-pad); + width: var(--nodedc-app-nav-width); + visibility: hidden; + pointer-events: none; +} + +.nodedc-app-shell__end-panel > * { + width: 100%; + height: 100%; +} + +.nodedc-app-shell[data-end-panel-open="true"] .nodedc-app-shell__end-panel { + visibility: visible; + pointer-events: auto; +} + +.nodedc-app-shell[data-end-panel-open="true"] .nodedc-app-shell__stage { + padding-right: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap)); +} + .nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage { padding-left: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap)); } @@ -1411,6 +1433,10 @@ textarea.nodedc-field__control { width: auto; } +.nodedc-app-shell[data-content-open="true"][data-content-expanded="true"][data-end-panel-open="true"] .nodedc-app-shell__content { + right: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap)); +} + .nodedc-app-shell[data-content-open="true"][data-content-expanded="true"] .nodedc-app-shell__stage { pointer-events: none; transform: translateX(calc(100vw + var(--nodedc-app-page-pad))); @@ -1559,6 +1585,34 @@ textarea.nodedc-field__control { grid-template-rows: auto minmax(0, 1fr) auto; } +.nodedc-application-side-panel { + animation-name: nodedc-application-side-panel-in; +} + +@keyframes nodedc-application-side-panel-in { + from { opacity: 0; transform: translateX(1.6rem); } + to { opacity: 1; transform: translateX(0); } +} + +.nodedc-application-side-panel__description { + display: block; + margin-top: 0.3rem; + color: var(--nodedc-text-muted); + font-size: 0.72rem; + line-height: 1.25; +} + +.nodedc-application-side-panel__body { + width: calc(100% + 2.2rem); + min-height: 0; + margin-inline: -1.1rem; + overflow: auto; + overscroll-behavior: contain; + padding-inline: 1.1rem; + scrollbar-width: thin; + scrollbar-color: var(--nodedc-scrollbar-thumb) transparent; +} + @keyframes nodedc-admin-panel-in { from { opacity: 0; transform: translateX(-1.6rem); } to { opacity: 1; transform: translateX(0); } @@ -3140,6 +3194,17 @@ textarea.nodedc-field__control { cursor: pointer; } +.nodedc-inspector__section-icon, +.nodedc-inspector__section-chevron { + display: none; +} + +.nodedc-inspector__section-body { + display: grid; + min-width: 0; + gap: 0.2rem; +} + .nodedc-inspector__section-trigger:not([data-open="true"]):hover { background: var(--nodedc-glass-control-hover); color: var(--nodedc-text-primary); @@ -3179,6 +3244,89 @@ textarea.nodedc-field__control { animation: nodedc-section-in var(--nodedc-duration-fast) ease; } +.nodedc-inspector[data-variant="panel"] { + width: 100%; + gap: 0.22rem; +} + +.nodedc-inspector[data-variant="panel"] :where(.nodedc-inspector__group, .nodedc-inspector__section) { + gap: 0.22rem; +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section { + gap: 0.35rem; +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger { + min-height: 3.55rem; + grid-template-columns: 2.92rem minmax(0, 1fr) 1.4rem; + align-items: center; + align-content: initial; + gap: 0.65rem; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-panel-item-bg); + color: var(--nodedc-text-secondary); + padding: 5px 0.72rem 5px 5px; + box-shadow: none; + opacity: 0.72; + transition: background 160ms ease, color 160ms ease, opacity 160ms ease, transform 160ms ease; +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger:hover:not(:disabled) { + background: var(--nodedc-panel-item-hover-bg); + color: var(--nodedc-text-primary); + opacity: 0.92; + transform: translateY(-1px); +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger:is([data-open="true"], [data-active="true"], [data-tone="accent"]) { + background: var(--nodedc-panel-item-active-bg); + color: var(--nodedc-text-primary); + opacity: 1; + box-shadow: inset 0 1px 0 color-mix(in srgb, white 12%, transparent), inset 0 -14px 26px color-mix(in srgb, var(--nodedc-canvas) 8%, transparent); +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger:is([data-open="true"], [data-active="true"], [data-tone="accent"]) .nodedc-inspector__section-description { + color: var(--nodedc-text-muted); +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-icon { + display: grid; + width: 2.92rem; + height: 2.92rem; + place-items: center; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-panel-icon-bg); + color: var(--nodedc-text-secondary); +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-icon { + background: var(--nodedc-panel-active-icon-bg); + color: var(--nodedc-panel-active-icon-color); +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-chevron { + display: grid; + place-items: center; + color: var(--nodedc-text-muted); + transition: transform 160ms ease; +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-chevron { + transform: rotate(90deg); +} + +.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-content { + padding: 0.45rem 0.15rem 0.85rem; +} + +.nodedc-inspector[data-variant="panel"] .nodedc-control-row { + width: 100%; + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + gap: var(--nodedc-space-2); +} + @keyframes nodedc-section-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: translateY(0); } @@ -3243,6 +3391,10 @@ textarea.nodedc-field__control { pointer-events: none; transform: translateX(calc(100vw + var(--nodedc-app-page-pad))); } + + .nodedc-app-shell[data-content-open="true"][data-end-panel-open="true"] .nodedc-app-shell__content { + right: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap)); + } } @media (max-width: 760px) { @@ -3286,13 +3438,19 @@ textarea.nodedc-field__control { } .nodedc-app-shell__navigation, - .nodedc-app-shell__content { + .nodedc-app-shell__content, + .nodedc-app-shell__end-panel { right: var(--nodedc-app-page-pad); bottom: var(--nodedc-app-page-pad); left: var(--nodedc-app-page-pad); width: auto; } + .nodedc-app-shell[data-end-panel-open="true"] .nodedc-app-shell__content { + opacity: 0; + pointer-events: none; + } + .nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage, .nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__stage { padding-left: var(--nodedc-app-page-pad); @@ -3306,7 +3464,8 @@ textarea.nodedc-field__control { } .nodedc-application-panel, - .nodedc-admin-panel { + .nodedc-admin-panel, + .nodedc-application-side-panel { width: 100%; max-width: none; } @@ -3400,6 +3559,7 @@ textarea.nodedc-field__control { .nodedc-window, .nodedc-workspace-window, .nodedc-application-panel, + .nodedc-application-side-panel, .nodedc-inspector__section-content { animation: none; } diff --git a/packages/ui-react/src/ApplicationShell.tsx b/packages/ui-react/src/ApplicationShell.tsx index 7665bd1..c9421e3 100644 --- a/packages/ui-react/src/ApplicationShell.tsx +++ b/packages/ui-react/src/ApplicationShell.tsx @@ -7,9 +7,11 @@ export interface ApplicationShellProps extends Omit {header} @@ -36,6 +41,7 @@ export function ApplicationShell({
{stage}
{navigationOpen && navigation ?
{navigation}
: null} {contentOpen && content ?
{content}
: null} + {endPanel ?
{endPanel}
: null}
); diff --git a/packages/ui-react/src/ApplicationSidePanel.tsx b/packages/ui-react/src/ApplicationSidePanel.tsx new file mode 100644 index 0000000..751c33c --- /dev/null +++ b/packages/ui-react/src/ApplicationSidePanel.tsx @@ -0,0 +1,55 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "./cn.js"; +import { Icon } from "./Icon.js"; + +export interface ApplicationSidePanelProps extends Omit, "title"> { + eyebrow?: ReactNode; + title: ReactNode; + description?: ReactNode; + closeLabel?: string; + footer?: ReactNode; + onClose: () => void; + children: ReactNode; +} + +/** + * Theme-aware trailing application panel paired with AdminNavigationPanel. + * + * The shell owns the separate end column; this component owns its matching + * surface, actions and right-to-left entrance animation. + */ +export function ApplicationSidePanel({ + eyebrow = "NODE.DC", + title, + description, + closeLabel = "Закрыть панель", + footer, + onClose, + children, + className, + ...props +}: ApplicationSidePanelProps) { + return ( + + ); +} diff --git a/packages/ui-react/src/Dropdown.tsx b/packages/ui-react/src/Dropdown.tsx index 4d6b0a8..1710893 100644 --- a/packages/ui-react/src/Dropdown.tsx +++ b/packages/ui-react/src/Dropdown.tsx @@ -33,6 +33,7 @@ export interface DropdownProps { className?: string; surfaceClassName?: string; surfaceRole?: "menu" | "listbox" | "dialog"; + onOpenChange?: (open: boolean) => void; } export function Dropdown({ @@ -46,6 +47,7 @@ export function Dropdown({ className, surfaceClassName, surfaceRole = "menu", + onOpenChange, }: DropdownProps) { const instanceId = useId(); const surfaceId = `${instanceId.replaceAll(":", "")}-surface`; @@ -55,6 +57,10 @@ export function Dropdown({ const surfaceRef = useRef(null); const [surfaceStyle, setSurfaceStyle] = useState({ visibility: "hidden" }); + useEffect(() => { + onOpenChange?.(isOpen); + }, [isOpen, onOpenChange]); + const close = useCallback(() => setIsOpen(false), []); const show = useCallback(() => { if (!disabled) setIsOpen(true); diff --git a/packages/ui-react/src/Inspector.tsx b/packages/ui-react/src/Inspector.tsx index c9e7aa4..5b1ffc6 100644 --- a/packages/ui-react/src/Inspector.tsx +++ b/packages/ui-react/src/Inspector.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from "react"; import { InspectorSelectPolicyContext } from "./InspectorContext.js"; import { Select, type SelectProps } from "./Select.js"; import { cn } from "./cn.js"; +import { Icon } from "./Icon.js"; export interface InspectorSectionSpec { id: string; @@ -11,6 +12,7 @@ export interface InspectorSectionSpec { content: ReactNode; disabled?: boolean; tone?: "default" | "accent"; + icon?: ReactNode; } export interface InspectorProps { @@ -19,6 +21,7 @@ export interface InspectorProps { openSections?: string[]; activeId?: string; singleOpen?: boolean; + variant?: "default" | "panel"; className?: string; onOpenSectionsChange?: (ids: string[]) => void; onActiveChange?: (id: string) => void; @@ -30,6 +33,7 @@ export function Inspector({ openSections, activeId, singleOpen = false, + variant = "default", className, onOpenSectionsChange, onActiveChange, @@ -63,7 +67,7 @@ export function Inspector({ return ( -
+
{groups.map((group, groupIndex) => (
{group.sections.map((section) => { @@ -80,10 +84,14 @@ export function Inspector({ disabled={section.disabled} onClick={() => toggle(section.id)} > - {section.label} - {section.description ? ( - {section.description} - ) : null} + + + {section.label} + {section.description ? ( + {section.description} + ) : null} + + {isOpen ?
{section.content}
: null} diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts index cf038f2..c9ad7cd 100644 --- a/packages/ui-react/src/index.ts +++ b/packages/ui-react/src/index.ts @@ -1,6 +1,7 @@ export * from "./AppHeader.js"; export * from "./AdminNavigationPanel.js"; export * from "./ApplicationShell.js"; +export * from "./ApplicationSidePanel.js"; export * from "./ApplicationWorkspace.js"; export * from "./Button.js"; export * from "./Checker.js"; diff --git a/registry/components.json b/registry/components.json index 6d9c098..5ea68e7 100644 --- a/registry/components.json +++ b/registry/components.json @@ -98,7 +98,7 @@ "domExports": ["createFloatingLayer"], "domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"], "summary": "Shared portal/fixed floating layer for action and selection menus.", - "behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "contained menu scroll without geometry growth", "offscreen trigger close", "one open dropdown per window"], + "behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "contained menu scroll without geometry growth", "offscreen trigger close", "one open dropdown per window", "optional open-state notification"], "rules": [ "Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.", "Action menus and selection menus share floating behavior even when their row content differs.", @@ -308,6 +308,22 @@ "Active route state uses the active surface and icon contrast from the theme." ] }, + { + "id": "application-side-panel", + "status": "baseline", + "package": "@nodedc/ui-react", + "exports": ["ApplicationSidePanel", "ApplicationSidePanelProps"], + "domContract": ["nodedc-application-side-panel", "nodedc-app-shell__end-panel"], + "summary": "Theme-aware trailing application panel paired with AdminNavigationPanel and hosted in a separate ApplicationShell end column.", + "anatomy": ["eyebrow and title", "optional description", "close action", "scrollable body", "optional footer"], + "behavior": ["right-to-left entrance", "separate push column", "controlled visibility", "theme token inheritance"], + "rules": [ + "The panel uses the same 332–352 px geometry, radius, surface, shadow and circular actions as AdminNavigationPanel.", + "ApplicationShell shrinks the expanded content/stage by one panel width plus the canonical page gap; the side panel never overlays the desktop work area.", + "The owning ApplicationPanel header action controls visibility; close always remains available and workspace interaction does not dismiss the panel implicitly.", + "Use this panel for application-level settings/detail. Bounded scene tools and subject cards remain WorkspaceWindow instances." + ] + }, { "id": "media-source-field", "status": "baseline", @@ -390,6 +406,7 @@ "Desktop Engine geometry is fixed at 390 px panel, 330 px content, 154/14/162 px control rows and 50 px section headers with a 12 px radius.", "Inspector supports controlled openSections/onOpenSectionsChange state so a consumer can persist the exact accordion layout without remount defaults.", "Inspector selection fields use InspectorSelectField only: a stacked visible label and the 276/8/46 px split control; integrated pills are prohibited.", + "The panel variant reuses AdminNavigationPanel pill, icon and theme tokens while retaining the same controlled accordion state and domain-owned content.", "Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts." ] } diff --git a/scripts/map-inspector-overlay-state.test.mjs b/scripts/map-inspector-overlay-state.test.mjs index 37cb704..b87524e 100644 --- a/scripts/map-inspector-overlay-state.test.mjs +++ b/scripts/map-inspector-overlay-state.test.mjs @@ -39,6 +39,34 @@ test("Map Inspector open section is controlled and included in the saved page la assert.match(manifestSchema, /"inspectorOpenSections"[\s\S]*?"maxItems": 1/); }); +test("Map settings use a canonical trailing push panel and ApplicationPanel header actions", async () => { + const [shell, panel, inspector, preview, catalog, styles, catalogStyles] = await Promise.all([ + readFile(new URL("../packages/ui-react/src/ApplicationShell.tsx", import.meta.url), "utf8"), + readFile(new URL("../packages/ui-react/src/ApplicationSidePanel.tsx", import.meta.url), "utf8"), + readFile(new URL("../packages/ui-react/src/Inspector.tsx", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"), + readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"), + readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8"), + ]); + + assert.match(shell, /endPanelOpen\?: boolean/); + assert.match(shell, /nodedc-app-shell__end-panel/); + assert.match(panel, /data-placement="end"/); + assert.doesNotMatch(panel, /pinned|onPinnedChange|Icon name="pin"/); + assert.match(inspector, /variant\?: "default" \| "panel"/); + assert.match(preview, /createPortal\(settingsPanel, settingsPanelHost\)/); + assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/); + assert.match(preview, /variant="panel"/); + assert.doesNotMatch(preview, /settingsPanelPinned|onPointerDownCapture/); + assert.match(catalog, /endPanelOpen=\{mapSettingsPanelOpen\}/); + assert.match(catalog, /headerTools=\{activePageTemplate\.id === "map"[\s\S]*?setMapHeaderActionsHost/); + assert.match(styles, /data-content-expanded="true"\]\[data-end-panel-open="true"\][\s\S]*?right: calc\(/); + assert.match(styles, /@keyframes nodedc-application-side-panel-in[\s\S]*?translateX\(1\.6rem\)/); + assert.match(catalogStyles, /\.catalog-map-header-action\.nodedc-icon-button[\s\S]*?--nodedc-panel-action-bg/); + assert.match(catalogStyles, /\.catalog-map-header-action\.nodedc-icon-button\[data-active="true"\][\s\S]*?--nodedc-glass-control-active/); +}); + test("canonical station profiles retain visible target borders while label text stays halo-free", async () => { const registry = JSON.parse(await readFile( new URL("../registry/map-presentation-profiles.json", import.meta.url), diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index 1f2b346..c94ccfc 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -37,16 +37,33 @@ test("joined detail aspects do not become independent map layers", async () => { assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/); }); -test("map windows share one bounded activation and z-index stack", async () => { +test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => { const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); - assert.match(preview, /type MapWorkspaceWindowId = "settings" \| "layers" \| "sector" \| "subject-card" \| `binding:\$\{string\}`/); + assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/); assert.match(preview, /const activateWorkspaceWindow = useCallback/); - assert.match(preview, /settingsWindowZIndex[\s\S]*layersWindowZIndex[\s\S]*sectorWindowZIndex[\s\S]*subjectCardZIndex/); - assert.match(preview, /title="Настройки карты"[\s\S]*active=\{activeWorkspaceWindowId === "settings"\}/); + assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/); + assert.match(preview, / { + const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); + const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? ""; + const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? ""; + + assert.match(pointBranch, /onSelectRef\.current\?\.\(pickedId\.id\)/); + assert.match(zoneBranch, /onSelectRef\.current\?\.\(\(pickedId as HGeoZonePickId\)\.entityId\)/); + assert.doesNotMatch(pointBranch, /setSelection\(null\)|onGridSectorSelectRef\.current\?\.\(null\)/); + assert.doesNotMatch(zoneBranch, /setSelection\(null\)|onGridSectorSelectRef\.current\?\.\(null\)/); + assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/); +}); + test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => { const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");