From 6e7255ecdbdf0a70039c522b69ffa842bb6a0aa6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 24 Aug 2026 22:31:35 +0300 Subject: [PATCH] feat(ui): stabilize laboratory viewer primitives --- apps/catalog/src/CatalogApp.tsx | 54 +++++++++- apps/catalog/src/styles.css | 35 ++++++ docs/COMPONENTS.md | 19 +++- docs/ICONS.md | 6 +- package.json | 7 +- packages/ui-core/styles.css | 127 +++++++++++++++++++++- packages/ui-react/src/Glass.tsx | 2 +- packages/ui-react/src/Icon.tsx | 14 ++- packages/ui-react/src/Select.tsx | 22 +++- packages/ui-react/src/SplitPane.tsx | 156 +++++++++++++++++++++++++++ packages/ui-react/src/Toast.tsx | 35 +++--- packages/ui-react/src/index.ts | 1 + registry/components.json | 25 ++++- registry/icons.json | 6 +- scripts/icon-contract.test.mjs | 30 ++++++ scripts/split-pane-contract.test.mjs | 63 +++++++++++ scripts/toast-contract.test.mjs | 18 ++++ 17 files changed, 590 insertions(+), 30 deletions(-) create mode 100644 packages/ui-react/src/SplitPane.tsx create mode 100644 scripts/icon-contract.test.mjs create mode 100644 scripts/split-pane-contract.test.mjs create mode 100644 scripts/toast-contract.test.mjs diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 28271bf..5b4e54a 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -33,6 +33,7 @@ import { Select, ShareAccessModal, ShareLinkModal, + SplitPane, SortableItem, SortableScope, SettingsCard, @@ -219,7 +220,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [ { title: "Контент", note: "SEO / BIM / Engine", - icons: ["image", "video", "file", "folder", "clipboard", "settings"], + icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"], }, ]; @@ -228,6 +229,7 @@ const iconLabels: Record = { alert: "Предупреждение", apps: "Приложения", building: "Компания", + camera: "Камера", check: "Готово", "chevron-down": "Раскрыть", "chevron-left": "Назад", @@ -256,6 +258,8 @@ const iconLabels: Record = { minimize: "Свернуть", network: "Связи", panel: "Панель", + plan: "План", + play: "Воспроизвести", target: "Таргеты", plus: "Добавить", profile: "Профиль", @@ -265,6 +269,7 @@ const iconLabels: Record = { settings: "Настройки", shield: "Доступ", sliders: "Параметры", + stop: "Остановить", trash: "Удалить", upload: "Загрузить", users: "Участники", @@ -362,6 +367,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 [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50); const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true); const [createModalOpen, setCreateModalOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); @@ -1327,7 +1333,7 @@ export function CatalogApp() { case "controls": return (
- +
undefined} + /> + ( @@ -1397,6 +1416,21 @@ export function CatalogApp() {
+ + + + + + +
{workspaceWindowDemoOpen ? ( @@ -1435,6 +1469,20 @@ export function CatalogApp() {

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

+ +
+ Видео
} + secondary={
Пространственная сцена
} + /> +
+

Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.

+
{sidePanelDemoOpen ? ( @@ -1638,6 +1686,8 @@ export function CatalogApp() {
+ + diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 74d4a07..b876690 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -1678,6 +1678,33 @@ textarea { transform: translate(-50%, -50%); } +.catalog-split-pane-preview .catalog-preview__body { + align-content: stretch; +} + +.catalog-split-pane-demo { + min-height: 18rem; + overflow: hidden; + border-radius: var(--nodedc-radius-card); + background: var(--nodedc-nested-surface); +} + +.catalog-split-pane-demo__panel { + display: grid; + width: 100%; + height: 100%; + place-content: center; + justify-items: center; + gap: 0.5rem; + background: color-mix(in srgb, var(--nodedc-canvas) 82%, transparent); + color: var(--nodedc-text-secondary); +} + +.catalog-split-pane-demo__panel strong { + color: var(--nodedc-text-primary); + font-size: 0.82rem; +} + .catalog-side-panel-preview .catalog-preview__body { align-content: stretch; } @@ -1754,6 +1781,14 @@ textarea { flex-wrap: wrap; } +.catalog-icon-rail-demo { + display: flex; + width: max-content; + flex-direction: column; + align-items: center; + gap: var(--nodedc-space-2); +} + .catalog-form { display: grid; gap: 0.75rem; diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index c099be9..eb95a11 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -10,6 +10,8 @@ - `strong` — dropdown, modal и поверхность над сложным фоном; - `soft` — вложенная или вторичная область. +`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS. + Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim. `GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную. @@ -25,6 +27,8 @@ Button используется для всех текстовых действ Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом. +Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются. + ## ActivityIndicator `ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции. @@ -75,10 +79,11 @@ Dropdown владеет floating-layer поведением: ## Select -Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы: +Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы: - `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края; -- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`. +- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`; +- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню. Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`. @@ -145,6 +150,14 @@ Enter при отсутствии совпадения разрешает оди ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать. +## SplitPane + +`SplitPane` — контролируемая раскладка двух синхронных представлений с общей регулируемой границей. Приложение передаёт содержимое обеих панелей, процент primary-панели и callback изменения; дизайн-система владеет pointer capture, ограничениями размеров, focus-state и доступностью `role="separator"`. + +Вертикальная граница меняется мышью или клавишами `←/→`, горизонтальная — `↑/↓`; `Home/End` переходят к разрешённым границам, а `Shift` ускоряет клавиатурный шаг. Доля сохраняется при resize контейнера. Минимальные проценты обеих панелей применяются одинаково к pointer и keyboard interaction. + +Разделитель является структурной границей между двумя рабочими представлениями и не создаёт декоративную рамку вокруг контента. При `resizable={false}` панели сохраняют ту же стабильную DOM-композицию, а separator полностью отсутствует в визуальном слое и accessibility tree; это позволяет приложению сворачивать одну из панелей без remount тяжёлого renderer. + ## ShareAccessModal Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks. @@ -271,7 +284,7 @@ Sortable-строки ограничены вертикальной осью: г ## ToastStack -`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. Toast не используется вместо modal confirmation. +`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Обычный terminal toast по умолчанию живёт `10 000 ms`; каждый стабильный `id` получает собственный таймер, поэтому добавление соседнего уведомления не продлевает уже показанное. Loading не закрывается автоматически и обновляется тем же id после завершения операции; после перехода в terminal tone для него запускается обычный независимый таймер. Toast не используется вместо modal confirmation. ## Environment Controls: Inspector и ControlRow diff --git a/docs/ICONS.md b/docs/ICONS.md index 6890ef6..97f30c5 100644 --- a/docs/ICONS.md +++ b/docs/ICONS.md @@ -21,10 +21,14 @@ | Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` | | Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` | | Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` | -| Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` | +| Контент | `camera`, `plan`, `play`, `stop`, `image`, `video`, `file`, `folder`, `clipboard`, `settings` | Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`. ## Правило расширения Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён. + +`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора. + +`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`. diff --git a/package.json b/package.json index 7b950d5..4ce5079 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react", - "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "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", @@ -33,9 +33,12 @@ "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:spark-governance": "node --test scripts/spark-governance-contract.test.mjs", "test:activity-indicator": "node --test scripts/activity-indicator-contract.test.mjs", + "test:icon-contract": "node --test scripts/icon-contract.test.mjs", + "test:toast-contract": "node --test scripts/toast-contract.test.mjs", "test:floating-position": "node --test scripts/floating-position-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs", - "test:range-control": "node --test scripts/range-control-contract.test.mjs" + "test:range-control": "node --test scripts/range-control-contract.test.mjs", + "test:split-pane": "node --test scripts/split-pane-contract.test.mjs" }, "engines": { "node": ">=20" diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 49b20b1..17862cd 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -15,13 +15,13 @@ box-sizing: border-box; } -:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) { +:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) { border: 0; outline: 0; font: inherit; } -:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible { +:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible { background-color: var(--nodedc-focus-surface); box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22); } @@ -73,6 +73,10 @@ border-radius: var(--nodedc-radius-modal); } +.nodedc-glass[data-radius="pill"] { + border-radius: var(--nodedc-radius-circle); +} + .nodedc-glass[data-padding="sm"] { padding: var(--nodedc-space-3); } @@ -255,6 +259,11 @@ color: var(--nodedc-text-primary); } +.nodedc-icon-button[aria-pressed="true"] { + background: var(--nodedc-glass-control-active); + color: var(--nodedc-glass-control-active-text); +} + .nodedc-icon-button:active:not(:disabled) { transform: translateY(1px); } @@ -2206,6 +2215,33 @@ textarea.nodedc-field__control { transform: translateY(2px) rotate(225deg); } +.nodedc-select-inline { + display: inline-flex; + min-height: var(--nodedc-control-height-compact); + align-items: center; + justify-content: center; + gap: var(--nodedc-space-2); + border-radius: var(--nodedc-radius-circle); + background: transparent; + color: var(--nodedc-text-secondary); + padding: 0 var(--nodedc-space-3); + font-size: var(--nodedc-font-size-sm); + font-weight: var(--nodedc-font-weight-strong); + line-height: 1; + cursor: pointer; +} + +.nodedc-select-inline:hover:not(:disabled), +.nodedc-select-inline[aria-expanded="true"] { + background: var(--nodedc-glass-control-hover); + color: var(--nodedc-text-primary); +} + +.nodedc-select-inline:disabled { + opacity: 0.42; + cursor: not-allowed; +} + .nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron { transform: translateY(2px) rotate(225deg); } @@ -2829,6 +2865,93 @@ textarea.nodedc-field__control { color: var(--nodedc-text-primary); } +.nodedc-split-pane { + --nodedc-split-pane-primary: 50%; + position: relative; + display: grid; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.nodedc-split-pane[data-orientation="vertical"] { + grid-template-columns: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr); +} + +.nodedc-split-pane[data-orientation="horizontal"] { + grid-template-rows: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr); +} + +.nodedc-split-pane__panel { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.nodedc-split-pane__separator { + position: absolute; + z-index: 5; + border: 0; + outline: 0; + background: transparent; + padding: 0; + touch-action: none; +} + +.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator { + top: 0; + bottom: 0; + left: var(--nodedc-split-pane-primary); + width: 0.9rem; + cursor: col-resize; + transform: translateX(-50%); +} + +.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator { + right: 0; + bottom: calc(100% - var(--nodedc-split-pane-primary)); + left: 0; + height: 0.9rem; + cursor: row-resize; + transform: translateY(50%); +} + +.nodedc-split-pane__separator::before { + position: absolute; + background: var(--nodedc-glass-outline); + content: ""; + transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard), box-shadow var(--nodedc-duration-fast) var(--nodedc-ease-standard); +} + +.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator::before { + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); +} + +.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator::before { + right: 0; + bottom: 50%; + left: 0; + height: 1px; + transform: translateY(50%); +} + +.nodedc-split-pane__separator:hover::before, +.nodedc-split-pane__separator:focus-visible::before, +.nodedc-split-pane[data-dragging="true"] > .nodedc-split-pane__separator::before { + background: rgb(var(--nodedc-accent-rgb)); + box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.16); +} + +.nodedc-split-pane[data-dragging="true"] { + user-select: none; +} + .nodedc-toolbar-wrap { position: fixed; z-index: calc(var(--nodedc-layer-header) + 20); diff --git a/packages/ui-react/src/Glass.tsx b/packages/ui-react/src/Glass.tsx index 83579ba..cee0d3c 100644 --- a/packages/ui-react/src/Glass.tsx +++ b/packages/ui-react/src/Glass.tsx @@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react"; import { cn } from "./cn.js"; export type GlassTone = "default" | "strong" | "soft"; -export type GlassRadius = "card" | "panel" | "modal"; +export type GlassRadius = "card" | "panel" | "modal" | "pill"; export type GlassPadding = "none" | "sm" | "md" | "lg"; export interface GlassSurfaceProps extends HTMLAttributes { diff --git a/packages/ui-react/src/Icon.tsx b/packages/ui-react/src/Icon.tsx index be9b2a2..e361dff 100644 --- a/packages/ui-react/src/Icon.tsx +++ b/packages/ui-react/src/Icon.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, Boxes, Building2, + Camera, Check, ChevronDown, ChevronLeft, @@ -26,11 +27,13 @@ import { LocateFixed, LockKeyhole, MailPlus, + Map, Maximize2, Minimize2, Network, PanelTop, Pencil, + Play, Plus, RefreshCw, Save, @@ -38,6 +41,7 @@ import { Settings, ShieldCheck, SlidersHorizontal, + Square, Trash2, UploadCloud, UserCircle, @@ -53,6 +57,7 @@ const icons = { alert: AlertTriangle, apps: Boxes, building: Building2, + camera: Camera, check: Check, "chevron-down": ChevronDown, "chevron-left": ChevronLeft, @@ -82,6 +87,8 @@ const icons = { minimize: Minimize2, network: Network, panel: PanelTop, + plan: Map, + play: Play, plus: Plus, profile: UserCircle, refresh: RefreshCw, @@ -90,6 +97,7 @@ const icons = { settings: Settings, shield: ShieldCheck, sliders: SlidersHorizontal, + stop: Square, trash: Trash2, upload: UploadCloud, users: UsersRound, @@ -103,13 +111,15 @@ export interface IconProps extends Omit { label?: string; } -export function Icon({ name, label, size = 16, strokeWidth = 1.6, ...props }: IconProps) { +export function Icon({ name, label, size = 16, strokeWidth = 1.6, fill, ...props }: IconProps) { const IconComponent = icons[name]; + const filledTransport = name === "play" || name === "stop"; return ( { disabled?: boolean; } -export type SelectVariant = "integrated" | "split"; +export type SelectVariant = "integrated" | "split" | "inline"; export interface SelectProps { value: T; @@ -102,6 +102,26 @@ export function Select({ ); } + if (resolvedVariant === "inline") { + return ( + + ); + } + return (