Compare commits

26 changed files with 1024 additions and 64 deletions
+71 -4
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
import {
ActivityIndicator,
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
@@ -32,6 +33,7 @@ import {
Select,
ShareAccessModal,
ShareLinkModal,
SplitPane,
SortableItem,
SortableScope,
SettingsCard,
@@ -208,7 +210,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{
title: "Состояние и доступ",
note: "Вся платформа",
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
},
{
title: "Сущности",
@@ -218,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"],
},
];
@@ -227,6 +229,7 @@ const iconLabels: Record<IconName, string> = {
alert: "Предупреждение",
apps: "Приложения",
building: "Компания",
camera: "Камера",
check: "Готово",
"chevron-down": "Раскрыть",
"chevron-left": "Назад",
@@ -238,6 +241,8 @@ const iconLabels: Record<IconName, string> = {
database: "База данных",
download: "Скачать",
edit: "Редактировать",
eye: "Показать",
"eye-off": "Скрыть",
expand: "Развернуть",
external: "Открыть снаружи",
file: "Файл",
@@ -253,6 +258,8 @@ const iconLabels: Record<IconName, string> = {
minimize: "Свернуть",
network: "Связи",
panel: "Панель",
plan: "План",
play: "Воспроизвести",
target: "Таргеты",
plus: "Добавить",
profile: "Профиль",
@@ -262,6 +269,7 @@ const iconLabels: Record<IconName, string> = {
settings: "Настройки",
shield: "Доступ",
sliders: "Параметры",
stop: "Остановить",
trash: "Удалить",
upload: "Загрузить",
users: "Участники",
@@ -359,6 +367,7 @@ export function CatalogApp() {
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ 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);
@@ -1278,7 +1287,7 @@ export function CatalogApp() {
<>
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
</>
),
},
@@ -1324,7 +1333,7 @@ export function CatalogApp() {
case "controls":
return (
<div className="catalog-grid">
<Preview title="Selection" note="integrated / split">
<Preview title="Selection" note="integrated / split / inline">
<div className="catalog-form">
<FieldFrame label="Hub / integrated select">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
@@ -1342,6 +1351,19 @@ export function CatalogApp() {
onChange={setConnectionType}
/>
</FieldFrame>
<FieldFrame label="Toolbar / inline select">
<Select
variant="inline"
label="Скорость воспроизведения"
value="1"
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
onChange={() => undefined}
/>
</FieldFrame>
<Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
@@ -1372,6 +1394,20 @@ export function CatalogApp() {
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
</div>
</Preview>
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
<div className="catalog-inline catalog-inline--wrap">
<span className="catalog-inline">
<ActivityIndicator label="Загружаем данные" />
<span>Загружаем данные</span>
</span>
<Button
aria-busy="true"
disabled
icon={<ActivityIndicator size="compact" />}
>Подключаем</Button>
</div>
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
</Preview>
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
<div className="catalog-window-actions-demo">
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
@@ -1380,6 +1416,21 @@ export function CatalogApp() {
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
</Preview>
<Preview title="Вертикальная рейка" note="glass pill / круг `46 px`" className="catalog-preview--compact">
<GlassSurface
className="catalog-icon-rail-demo"
tone="strong"
radius="pill"
padding="sm"
materialRim={false}
role="toolbar"
aria-label="Режимы просмотра"
>
<IconButton label="Камера" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="3D"><span aria-hidden="true">3D</span></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
</GlassSurface>
</Preview>
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
{workspaceWindowDemoOpen ? (
@@ -1418,6 +1469,20 @@ export function CatalogApp() {
</div>
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
</Preview>
<Preview title="Split pane" note="pointer / keyboard / controlled" className="catalog-preview--wide catalog-split-pane-preview">
<div className="catalog-split-pane-demo">
<SplitPane
primarySize={splitPaneDemoSize}
onPrimarySizeChange={setSplitPaneDemoSize}
minPrimarySize={25}
minSecondarySize={25}
separatorLabel="Изменить ширину синхронных представлений"
primary={<div className="catalog-split-pane-demo__panel"><Icon name="video" /><strong>Видео</strong></div>}
secondary={<div className="catalog-split-pane-demo__panel"><Icon name="grid" /><strong>Пространственная сцена</strong></div>}
/>
</div>
<p className="catalog-preview__explanation">Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.</p>
</Preview>
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
<div className="catalog-side-panel-demo">
{sidePanelDemoOpen ? (
@@ -1621,6 +1686,8 @@ export function CatalogApp() {
<div className="catalog-icon-catalog">
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
<div className="catalog-window-actions-demo">
<IconButton label="Камера включена" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
+35
View File
@@ -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;
+29 -4
View File
@@ -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,16 @@ Button используется для всех текстовых действ
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
## ActivityIndicator
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
## Field
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
@@ -39,7 +51,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
## RangeControl
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: фокус включает редактирование без замены DOM-узла и без pointer-state mutation, браузер ставит каретку в фактическое место клика, а drag/keyboard-selection и замена выделенного текста остаются полностью нативными. Поле не получает собственной подложки, select-all или второго focus-ring. Цвет текста и каретки автоматически выбирается между theme text и `on-accent` по фактической границе заливки под областью значения, поэтому светлая заливка получает тёмный контраст без application-local цвета. Enter/blur применяют значение с exact-bound/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
## ColorField
@@ -69,10 +83,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`.
@@ -139,6 +154,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.
@@ -153,6 +176,8 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
## AppHeader
Трёхосевая верхняя панель:
@@ -265,7 +290,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
+6 -2
View File
@@ -19,12 +19,16 @@
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle` |
| Состояние и доступ | `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`.
+8 -2
View File
@@ -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: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:control-density && 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",
@@ -31,9 +31,15 @@
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
"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:control-density": "node --test scripts/control-density-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"
+2
View File
@@ -10,6 +10,7 @@
--nodedc-control-height: 2.875rem;
--nodedc-control-height-compact: 2.375rem;
--nodedc-control-height-dense: 1.875rem;
--nodedc-icon-button-size: 2.875rem;
--nodedc-header-row-height: 3rem;
--nodedc-header-pill-height: 3.45rem;
@@ -61,6 +62,7 @@
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nodedc-font-size-xs: 0.6875rem;
--nodedc-font-size-dense: 0.45rem;
--nodedc-font-size-sm: 0.75rem;
--nodedc-font-size-md: 0.8125rem;
--nodedc-font-size-lg: 1rem;
+174 -2
View File
@@ -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);
}
@@ -138,6 +142,13 @@
font-size: var(--nodedc-font-size-sm);
}
.nodedc-button[data-size="dense"] {
min-height: var(--nodedc-control-height-dense);
border-radius: var(--nodedc-radius-control-compact);
padding-inline: 0.65rem;
font-size: var(--nodedc-font-size-dense);
}
.nodedc-button[data-width="full"] {
width: 100%;
}
@@ -211,6 +222,30 @@
transform: scale(var(--nodedc-icon-glyph-scale));
}
.nodedc-activity-indicator {
display: inline-block;
width: 1.125rem;
height: 1.125rem;
flex: 0 0 auto;
box-sizing: border-box;
border: 2px solid currentColor;
border-inline-end-color: transparent;
border-radius: var(--nodedc-radius-circle);
color: inherit;
opacity: 0.78;
animation: nodedc-activity-indicator-spin 780ms linear infinite;
}
.nodedc-activity-indicator[data-size="compact"] {
width: 0.875rem;
height: 0.875rem;
border-width: 1.5px;
}
@keyframes nodedc-activity-indicator-spin {
to { transform: rotate(360deg); }
}
.nodedc-icon-button {
display: inline-grid;
width: var(--nodedc-icon-button-size);
@@ -231,6 +266,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);
}
@@ -769,6 +809,10 @@ textarea.nodedc-field__control {
caret-color: currentColor;
}
.nodedc-range__editor[data-active][data-contrast="fill"] {
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-range[data-editing] > .nodedc-range__value,
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
opacity: 0;
@@ -1002,6 +1046,19 @@ textarea.nodedc-field__control {
color: var(--nodedc-glass-control-active-text);
}
.nodedc-segmented[data-size="dense"] {
min-height: var(--nodedc-control-height-dense);
gap: 0.1rem;
padding: 0.16rem;
}
.nodedc-segmented[data-size="dense"] .nodedc-segmented__item {
min-height: calc(var(--nodedc-control-height-dense) - 0.32rem);
gap: 0.25rem;
padding: 0.1rem 0.68rem;
font-size: var(--nodedc-font-size-dense);
}
.nodedc-header-shell {
position: relative;
z-index: var(--nodedc-layer-header);
@@ -2182,6 +2239,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);
}
@@ -2805,6 +2889,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);
@@ -3585,6 +3756,7 @@ textarea.nodedc-field__control {
}
@media (prefers-reduced-motion: reduce) {
.nodedc-activity-indicator,
.nodedc-dropdown-surface,
.nodedc-overlay,
.nodedc-window,
@@ -0,0 +1,32 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn.js";
export type ActivityIndicatorSize = "default" | "compact";
export interface ActivityIndicatorProps extends Omit<
HTMLAttributes<HTMLSpanElement>,
"aria-hidden" | "aria-label" | "children" | "role"
> {
size?: ActivityIndicatorSize;
label?: string;
}
export function ActivityIndicator({
size = "default",
label,
className,
...props
}: ActivityIndicatorProps) {
const accessibleLabel = label?.trim() || undefined;
return (
<span
{...props}
className={cn("nodedc-activity-indicator", className)}
data-size={size === "default" ? undefined : size}
role={accessibleLabel ? "status" : undefined}
aria-label={accessibleLabel}
aria-hidden={accessibleLabel ? undefined : "true"}
/>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact";
export type ButtonSize = "default" | "compact" | "dense";
export type ButtonShape = "default" | "pill" | "rounded";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+1 -1
View File
@@ -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<HTMLDivElement> {
+16 -2
View File
@@ -3,6 +3,7 @@ import {
AlertTriangle,
Boxes,
Building2,
Camera,
Check,
ChevronDown,
ChevronLeft,
@@ -12,6 +13,8 @@ import {
Copy,
Database,
Download,
Eye,
EyeOff,
ExternalLink,
FileText,
FolderOpen,
@@ -24,11 +27,13 @@ import {
LocateFixed,
LockKeyhole,
MailPlus,
Map,
Maximize2,
Minimize2,
Network,
PanelTop,
Pencil,
Play,
Plus,
RefreshCw,
Save,
@@ -36,6 +41,7 @@ import {
Settings,
ShieldCheck,
SlidersHorizontal,
Square,
Trash2,
UploadCloud,
UserCircle,
@@ -51,6 +57,7 @@ const icons = {
alert: AlertTriangle,
apps: Boxes,
building: Building2,
camera: Camera,
check: Check,
"chevron-down": ChevronDown,
"chevron-left": ChevronLeft,
@@ -62,6 +69,8 @@ const icons = {
database: Database,
download: Download,
edit: Pencil,
eye: Eye,
"eye-off": EyeOff,
expand: Maximize2,
external: ExternalLink,
file: FileText,
@@ -78,6 +87,8 @@ const icons = {
minimize: Minimize2,
network: Network,
panel: PanelTop,
plan: Map,
play: Play,
plus: Plus,
profile: UserCircle,
refresh: RefreshCw,
@@ -86,6 +97,7 @@ const icons = {
settings: Settings,
shield: ShieldCheck,
sliders: SlidersHorizontal,
stop: Square,
trash: Trash2,
upload: UploadCloud,
users: UsersRound,
@@ -99,13 +111,15 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
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 (
<IconComponent
size={size}
strokeWidth={strokeWidth}
strokeWidth={filledTransport ? 0 : strokeWidth}
fill={fill ?? (filledTransport ? "currentColor" : "none")}
aria-hidden={label ? undefined : "true"}
aria-label={label}
role={label ? "img" : undefined}
+38 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "./cn.js";
import { Dropdown } from "./Dropdown.js";
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
value: number;
min: number;
max: number;
exactValueBounds?: {
min?: number;
max?: number;
};
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
min: number,
max: number,
step: RangeControlProps["step"],
exactValueBounds: RangeControlProps["exactValueBounds"],
) => {
const clamped = clampRangeValue(value, min, max);
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY;
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
const clamped = clampRangeValue(value, exactMin, exactMax);
if (step === "any") return clamped;
const numericStep = step === undefined ? 1 : Number(step);
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
};
const editableNumber = (value: number) => (
@@ -48,6 +55,7 @@ export function RangeControl({
value,
min,
max,
exactValueBounds,
step,
formatValue = String,
onChange,
@@ -56,11 +64,13 @@ export function RangeControl({
tabIndex,
...props
}: RangeControlProps) {
const rootRef = useRef<HTMLDivElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<HTMLInputElement>(null);
const cancelNextBlurRef = useRef(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(() => editableNumber(value));
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const displayValue = formatValue(value);
@@ -77,10 +87,30 @@ export function RangeControl({
if (!editing) setDraft(editableNumber(value));
}, [editing, value]);
useLayoutEffect(() => {
const root = rootRef.current;
const editor = editorRef.current;
if (!root || !editor) return undefined;
const updateContrast = () => {
const rootBounds = root.getBoundingClientRect();
const editorBounds = editor.getBoundingClientRect();
const paddingRight = Number.parseFloat(getComputedStyle(editor).paddingRight) || 0;
const fillRight = rootBounds.left + rootBounds.width * progress / 100;
const textRight = editorBounds.right - paddingRight;
setEditorContrast(fillRight >= textRight ? "fill" : "base");
};
updateContrast();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateContrast);
observer?.observe(root);
return () => observer?.disconnect();
}, [editorCharacters, progress]);
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
if (commit) {
const parsed = Number(draft.trim().replace(",", "."));
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
if (Number.isFinite(parsed)) {
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
}
}
setEditing(false);
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
@@ -103,7 +133,7 @@ export function RangeControl({
};
return (
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<input
ref={rangeRef}
{...props}
@@ -129,22 +159,14 @@ export function RangeControl({
className="nodedc-range__editor"
type="text"
inputMode="decimal"
value={editing ? draft : editableNumber(value)}
value={draft}
aria-label={`${label}: точное значение`}
disabled={disabled}
spellCheck={false}
data-active={editing || undefined}
onPointerDown={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
data-contrast={editorContrast}
onFocus={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
if (!editing) setEditing(true);
}}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleEditorKeyDown}
+10 -2
View File
@@ -8,10 +8,13 @@ export interface SegmentedItem<T extends string> {
disabled?: boolean;
}
export type SegmentedControlSize = "default" | "dense";
export interface SegmentedControlProps<T extends string> {
value: T;
items: Array<SegmentedItem<T>>;
label: string;
size?: SegmentedControlSize;
className?: string;
onChange: (value: T) => void;
}
@@ -20,11 +23,17 @@ export function SegmentedControl<T extends string>({
value,
items,
label,
size = "default",
className,
onChange,
}: SegmentedControlProps<T>) {
return (
<div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
<div
className={cn("nodedc-segmented", className)}
role="tablist"
aria-label={label}
data-size={size === "default" ? undefined : size}
>
{items.map((item) => (
<button
key={item.value}
@@ -43,4 +52,3 @@ export function SegmentedControl<T extends string>({
</div>
);
}
+21 -1
View File
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
disabled?: boolean;
}
export type SelectVariant = "integrated" | "split";
export type SelectVariant = "integrated" | "split" | "inline";
export interface SelectProps<T extends string> {
value: T;
@@ -102,6 +102,26 @@ export function Select<T extends string>({
);
}
if (resolvedVariant === "inline") {
return (
<button
ref={setTriggerRef}
type="button"
className={cn("nodedc-select-inline", triggerClassName)}
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
<span>{selected?.label ?? "—"}</span>
</button>
);
}
return (
<button
ref={setTriggerRef}
+156
View File
@@ -0,0 +1,156 @@
import {
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "./cn.js";
export type SplitPaneOrientation = "vertical" | "horizontal";
export interface SplitPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
primary: ReactNode;
secondary: ReactNode;
primarySize: number;
onPrimarySizeChange: (primarySize: number) => void;
orientation?: SplitPaneOrientation;
minPrimarySize?: number;
minSecondarySize?: number;
step?: number;
resizable?: boolean;
separatorLabel: string;
}
const clamp = (value: number, minimum: number, maximum: number) => (
Math.min(Math.max(value, minimum), maximum)
);
const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => {
const primary = clamp(minPrimarySize, 0, 100);
const secondary = clamp(minSecondarySize, 0, 100);
if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary };
const scale = 100 / (primary + secondary);
return { minimum: primary * scale, maximum: 100 - secondary * scale };
};
export function SplitPane({
primary,
secondary,
primarySize,
onPrimarySizeChange,
orientation = "vertical",
minPrimarySize = 20,
minSecondarySize = 20,
step = 2,
resizable = true,
separatorLabel,
className,
style,
...props
}: SplitPaneProps) {
const rootRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null);
const [dragging, setDragging] = useState(false);
const limits = normalizedLimits(minPrimarySize, minSecondarySize);
const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum);
const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2;
const emitPointerSize = (event: PointerEvent<HTMLElement>) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const available = orientation === "vertical" ? rect.width : rect.height;
if (available <= 0) return;
const offset = orientation === "vertical"
? event.clientX - rect.left
: event.clientY - rect.top;
onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum));
};
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
const multiplier = event.shiftKey ? 5 : 1;
const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
let next: number | null = null;
if (event.key === decrement) next = size - keyboardIncrement * multiplier;
if (event.key === increment) next = size + keyboardIncrement * multiplier;
if (event.key === "Home") next = limits.minimum;
if (event.key === "End") next = limits.maximum;
if (next === null) return;
event.preventDefault();
onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum));
};
const splitStyle = {
...style,
"--nodedc-split-pane-primary": `${size}%`,
} as CSSProperties;
return (
<div
ref={rootRef}
className={cn("nodedc-split-pane", className)}
data-orientation={orientation}
data-dragging={dragging ? "true" : undefined}
style={splitStyle}
{...props}
>
<div className="nodedc-split-pane__panel" data-pane="primary">
{primary}
</div>
<div className="nodedc-split-pane__panel" data-pane="secondary">
{secondary}
</div>
{resizable ? (
<div
className="nodedc-split-pane__separator"
role="separator"
tabIndex={0}
aria-label={separatorLabel}
aria-orientation={orientation}
aria-valuemin={Math.round(limits.minimum)}
aria-valuemax={Math.round(limits.maximum)}
aria-valuenow={Math.round(size)}
aria-valuetext={`${Math.round(size)}% / ${Math.round(100 - size)}%`}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.focus();
activePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
emitPointerSize(event);
event.preventDefault();
}}
onPointerMove={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
emitPointerSize(event);
event.preventDefault();
}}
onPointerUp={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDragging(false);
}}
onPointerCancel={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
onLostPointerCapture={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
/>
) : null}
</div>
);
}
+23 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, type HTMLAttributes } from "react";
import { useEffect, useRef, type HTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
@@ -21,6 +21,8 @@ const toastIcons: Record<ToastTone, IconName> = {
loading: "refresh",
};
const DEFAULT_TOAST_DURATION_MS = 10_000;
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
item: ToastItem;
onDismiss?: (id: string) => void;
@@ -48,21 +50,30 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
);
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
useEffect(() => {
const timers = items.flatMap((item) => {
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
return typeof duration === "number" && duration > 0
? [window.setTimeout(() => onDismiss(item.id), duration)]
: [];
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [items, onDismiss]);
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
const duration = item.durationMs === undefined
? item.tone === "loading" ? null : DEFAULT_TOAST_DURATION_MS
: item.durationMs;
if (typeof duration !== "number" || duration <= 0) return undefined;
const timer = window.setTimeout(() => dismissRef.current(item.id), duration);
return () => window.clearTimeout(timer);
}, [item.durationMs, item.id, item.tone]);
return <ToastCard item={item} onDismiss={onDismiss} />;
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
if (typeof document === "undefined" || items.length === 0) return null;
return createPortal(
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
</div>,
document.body,
);
+2
View File
@@ -1,4 +1,5 @@
export * from "./AppHeader.js";
export * from "./ActivityIndicator.js";
export * from "./AdminNavigationPanel.js";
export * from "./ApplicationShell.js";
export * from "./ApplicationSidePanel.js";
@@ -20,6 +21,7 @@ export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./SplitPane.js";
export * from "./Toolbar.js";
export * from "./Toast.js";
export * from "./UserProfileMenu.js";
+50 -5
View File
@@ -10,8 +10,10 @@
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
"variants": ["default", "strong", "soft", "map"],
"radiusVariants": ["card", "panel", "modal", "pill"],
"rules": [
"Geometry is invariant across application themes.",
"Compact rails of canonical round actions use the pill radius; consumers do not recreate it with a local border radius.",
"A material rim is a glass highlight, not a hard product-colored border.",
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
@@ -26,13 +28,31 @@
"domContract": ["nodedc-button", "nodedc-icon-button"],
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
"sizes": ["default", "compact", "dense"],
"shapes": ["default", "pill", "rounded"],
"rules": [
"Icon-only actions are circular by default.",
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
"Filled accent actions derive foreground contrast from the actual accent.",
"Destructive actions remain neutral until confirmation unless danger is the principal message."
]
},
{
"id": "activity-indicator",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
"domContract": ["nodedc-activity-indicator"],
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
"variants": ["default", "compact"],
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
"rules": [
"Use compact inside a Button icon slot and default for standalone inline progress.",
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
"The component never owns operation state, timing or completion.",
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
]
},
{
"id": "field",
"status": "baseline",
@@ -65,13 +85,16 @@
"exports": ["RangeControl"],
"domContract": ["nodedc-range"],
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
"rules": [
"The accent fill follows the active application theme.",
"The native range remains the accessible drag input while its chrome is visually replaced.",
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
"Exact editing stays inside the existing value area and never expands the control.",
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
"The browser places the caret at the clicked character, preserves native drag or keyboard selection, and replaces the selected substring on typing without pointer-state interception.",
"Editor foreground and caret switch automatically between theme text and on-accent tokens according to the actual fill edge beneath the value area.",
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
"Editor keyboard events do not bubble into enclosing Window shortcuts."
]
@@ -113,11 +136,12 @@
"domPackage": "@nodedc/ui-dom",
"domExports": ["createSelectController"],
"summary": "Theme-independent selection control using the canonical dropdown layer.",
"variants": ["integrated", "split"],
"variants": ["integrated", "split", "inline"],
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
"rules": [
"Integrated is the single-pill Hub/Launcher form.",
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
]
@@ -163,6 +187,22 @@
"summary": "Async-safe confirmation window for destructive and consequential operations.",
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
},
{
"id": "split-pane",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["SplitPane", "SplitPaneProps", "SplitPaneOrientation"],
"domContract": ["nodedc-split-pane", "nodedc-split-pane__panel", "nodedc-split-pane__separator"],
"summary": "Controlled two-panel layout with a pointer- and keyboard-resizable accessible separator.",
"anatomy": ["primary panel", "structural separator", "secondary panel"],
"variants": ["vertical", "horizontal"],
"behavior": ["controlled percentage", "optional resizable separator", "pointer capture drag", "Arrow/Home/End keyboard sizing", "ARIA separator range", "ratio preservation across host resize"],
"rules": [
"The application owns the controlled primary percentage and panel content; the component owns separator interaction and accessibility.",
"Minimum primary and secondary percentages clamp both pointer and keyboard changes.",
"The separator is a necessary structural divider, never a decorative perimeter border."
]
},
{
"id": "share-access-modal",
"status": "baseline",
@@ -198,7 +238,11 @@
"package": "@nodedc/ui-react",
"exports": ["SegmentedControl"],
"domContract": ["nodedc-segmented"],
"summary": "Pill navigation used in the shared top header and compact mode switches."
"summary": "Pill navigation used in the shared top header and compact mode switches.",
"sizes": ["default", "dense"],
"rules": [
"Dense is reserved for high-density in-viewer layer and mode toolbars; application headers retain default geometry."
]
},
{
"id": "app-header",
@@ -369,9 +413,10 @@
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
"variants": ["success", "error", "warning", "info", "loading"],
"behavior": ["controlled items", "optional auto-dismiss", "manual dismiss", "portal viewport", "polite live region"],
"behavior": ["controlled items", "independent 10 second default auto-dismiss per terminal item", "manual dismiss", "portal viewport", "polite live region"],
"rules": [
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
"Adding or updating another notification never restarts the lifetime of an existing item; each item owns its timer by stable id.",
"Loading notifications remain until the operation updates or dismisses them.",
"Status notifications never replace a blocking confirmation modal."
]
+5 -3
View File
@@ -6,6 +6,7 @@
"defaultSize": 18,
"supportedSizes": [16, 18, 20],
"strokeWidth": 1.8,
"filledNames": ["play", "stop"],
"groups": [
{
"id": "window-layer",
@@ -29,7 +30,7 @@
"id": "state-access",
"label": "Состояние и доступ",
"referenceSources": ["launcher", "seo", "task-manager"],
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
},
{
"id": "entities",
@@ -41,13 +42,14 @@
"id": "content",
"label": "Контент",
"referenceSources": ["seo", "bim-viewer", "engine"],
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"]
}
],
"rules": [
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
"A new icon requires a confirmed product use, a registry entry and a catalog specimen."
"A new icon requires a confirmed product use, a registry entry and a catalog specimen.",
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
]
}
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
test("ActivityIndicator separates decorative and announced progress", () => {
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
assert.match(decorative, /class="nodedc-activity-indicator"/);
assert.match(decorative, /data-size="compact"/);
assert.match(decorative, /aria-hidden="true"/);
assert.doesNotMatch(decorative, /role="status"/);
assert.match(announced, /role="status"/);
assert.match(announced, /aria-label="Подключаем устройство"/);
assert.doesNotMatch(announced, /aria-hidden/);
assert.doesNotMatch(announced, /data-size=/);
});
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
const [styles, registrySource, docs, catalog] = await Promise.all([
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const entry = registry.components.find((component) => component.id === "activity-indicator");
assert.deepEqual(entry?.variants, ["default", "compact"]);
assert.ok(entry?.exports.includes("ActivityIndicator"));
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
assert.match(docs, /## ActivityIndicator/);
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
});
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Button, SegmentedControl } from "../packages/ui-react/dist/index.js";
test("dense viewer controls remain a public Button and SegmentedControl contract", async () => {
const button = renderToStaticMarkup(createElement(Button, { size: "dense" }, "Source points"));
const segmented = renderToStaticMarkup(createElement(SegmentedControl, {
size: "dense",
value: "source",
items: [{ label: "Source points", value: "source" }],
label: "Viewer layer",
onChange: () => {},
}));
const [tokens, styles, registrySource, docs] = await Promise.all([
readFile(new URL("../packages/tokens/tokens.css", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const buttonEntry = registry.components.find((component) => component.id === "button");
const segmentedEntry = registry.components.find((component) => component.id === "segmented-control");
assert.match(button, /data-size="dense"/);
assert.match(segmented, /data-size="dense"/);
assert.ok(buttonEntry?.sizes.includes("dense"));
assert.ok(segmentedEntry?.sizes.includes("dense"));
assert.match(tokens, /--nodedc-control-height-dense: 1\.875rem/);
assert.match(tokens, /--nodedc-font-size-dense: 0\.45rem/);
assert.match(styles, /\.nodedc-button\[data-size="dense"\]/);
assert.match(styles, /\.nodedc-segmented\[data-size="dense"\]/);
assert.match(docs, /size="dense"/);
assert.match(docs, /viewer/);
});
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Icon } from "../packages/ui-react/dist/index.js";
test("only registered playback glyphs are filled", async () => {
const registry = JSON.parse(await readFile(
new URL("../registry/icons.json", import.meta.url),
"utf8",
));
const names = registry.groups.flatMap((group) => group.names);
const filledNames = new Set(registry.filledNames);
assert.deepEqual([...filledNames], ["play", "stop"]);
assert.equal(new Set(names).size, names.length);
for (const name of names) {
const markup = renderToStaticMarkup(createElement(Icon, { name }));
const rootTag = markup.slice(0, markup.indexOf(">") + 1);
if (filledNames.has(name)) {
assert.match(rootTag, /fill="currentColor"/, `${name} must be filled`);
assert.match(rootTag, /stroke-width="0"/, `${name} must not retain an outline`);
} else {
assert.match(rootTag, /fill="none"/, `${name} must remain outline-only`);
assert.doesNotMatch(rootTag, /fill="currentColor"/, `${name} must not be filled`);
}
}
});
+12 -1
View File
@@ -30,7 +30,17 @@ test("exact editing is a stable native caret field without remount, forced selec
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
]);
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
assert.match(component, /exactValueBounds\?: \{/);
assert.match(component, /exactValueBounds\?\.min \?\? Number\.NEGATIVE_INFINITY/);
assert.match(component, /exactValueBounds\?\.max \?\? Number\.POSITIVE_INFINITY/);
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step, exactValueBounds\)/);
assert.match(component, /value=\{draft\}/);
assert.match(component, /data-contrast=\{editorContrast\}/);
assert.match(component, /fillRight >= textRight \? "fill" : "base"/);
assert.match(component, /new ResizeObserver\(updateContrast\)/);
const editorSource = component.match(/className="nodedc-range__editor"[\s\S]*?onBlur=\{\(\) => \{[\s\S]*?\n \}\}\n \/>/)?.[0] ?? "";
assert.notEqual(editorSource, "");
assert.doesNotMatch(editorSource, /onPointerDown=/);
assert.match(component, /event\.key === "Enter"/);
assert.match(component, /event\.key === "Escape"/);
assert.match(component, /event\.stopPropagation\(\)/);
@@ -43,6 +53,7 @@ test("exact editing is a stable native caret field without remount, forced selec
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
assert.match(styles, /\.nodedc-range__editor\[data-active\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
assert.match(windowComponent, /onCloseRef\.current = onClose/);
assert.match(windowComponent, /onCloseRef\.current\(\)/);
+137
View File
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
function lineNumber(lines, value) {
return lines.findIndex((line) => line.includes(value));
}
function assertLine(lines, value, message) {
const index = lineNumber(lines, value);
assert.ok(index >= 0, `${message} (expected near line 1+, missing: ${value})`);
return index + 1;
}
function extractBlock(lines, key) {
const start = lines.findIndex((line) => line.trim() === `${key} = """`);
assert.ok(start >= 0, `missing ${key} block start`);
let end = start + 1;
while (end < lines.length && lines[end] !== "\"\"\"") {
end += 1;
}
assert.ok(end < lines.length, `unterminated ${key} block`);
return lines.slice(start + 1, end).join("\n").replace(/\s+/g, " ").trim();
}
test("global spark agent configuration matches governance contract", async () => {
const configLines = (await readFile(new URL("../.codex/config.toml", import.meta.url), "utf8")).split(/\r?\n/);
const agentsLine = assertLine(configLines, "[agents]", "global config should declare [agents]");
const enabledLine = assertLine(configLines, 'enabled = true', "global config should enable agents");
const threadLine = assertLine(configLines, "max_concurrent_threads_per_session = 2", "global config should cap threads at 2");
const defaultModelLine = assertLine(configLines, 'default_subagent_model = "gpt-5.6-terra"', "global config should set default subagent model");
const effortLine = assertLine(configLines, 'default_subagent_reasoning_effort = "low"', "global config should set default low reasoning for unpinned subagents");
const interruptLine = assertLine(configLines, "interrupt_message = true", "global config should keep interruption messages enabled");
assert.equal(configLines[agentsLine - 1].trim(), "[agents]", "[agents] block should be present");
assert.equal(configLines[enabledLine - 1].trim(), 'enabled = true');
assert.equal(configLines[threadLine - 1].trim(), "max_concurrent_threads_per_session = 2");
assert.equal(configLines[defaultModelLine - 1].trim(), 'default_subagent_model = "gpt-5.6-terra"');
assert.equal(configLines[effortLine - 1].trim(), 'default_subagent_reasoning_effort = "low"');
assert.equal(configLines[interruptLine - 1].trim(), "interrupt_message = true");
});
test("spark_explorer contract", async () => {
const explorerLines = (await readFile(new URL("../.codex/agents/spark-explorer.toml", import.meta.url), "utf8")).split(/\r?\n/);
const instructions = extractBlock(explorerLines, "developer_instructions");
const nameLine = assertLine(explorerLines, 'name = "spark_explorer"', "explorer name should be spark_explorer");
const modelLine = assertLine(explorerLines, 'model = "gpt-5.3-codex-spark"', "explorer should pin model gpt-5.3-codex-spark");
const effortLine = assertLine(explorerLines, 'model_reasoning_effort = "medium"', "explorer should use medium reasoning");
const sandboxLine = assertLine(explorerLines, 'sandbox_mode = "read-only"', "explorer should use read-only sandbox mode");
assert.equal(explorerLines[nameLine - 1].trim(), 'name = "spark_explorer"');
assert.equal(explorerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
assert.equal(explorerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
assert.equal(explorerLines[sandboxLine - 1].trim(), 'sandbox_mode = "read-only"');
assert.ok(
instructions.includes("Never edit, create, move, or delete files."),
"explorer instructions must prohibit edits",
);
assert.ok(
instructions.includes("Never spawn another agent."),
"explorer instructions must prohibit nested agents",
);
});
test("spark_worker contract", async () => {
const workerLines = (await readFile(new URL("../.codex/agents/spark-worker.toml", import.meta.url), "utf8")).split(/\r?\n/);
const instructions = extractBlock(workerLines, "developer_instructions");
const nameLine = assertLine(workerLines, 'name = "spark_worker"', "worker name should be spark_worker");
const modelLine = assertLine(workerLines, 'model = "gpt-5.3-codex-spark"', "worker should pin model gpt-5.3-codex-spark");
const effortLine = assertLine(workerLines, 'model_reasoning_effort = "medium"', "worker should use medium reasoning");
const sandboxLine = assertLine(workerLines, 'sandbox_mode = "workspace-write"', "worker should use workspace-write sandbox mode");
assert.equal(workerLines[nameLine - 1].trim(), 'name = "spark_worker"');
assert.equal(workerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
assert.equal(workerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
assert.equal(workerLines[sandboxLine - 1].trim(), 'sandbox_mode = "workspace-write"');
assert.ok(
instructions.includes("exact allowed file"),
"worker instructions must require exact allowed path allowlist",
);
assert.ok(instructions.includes("Never commit, push, deploy, install dependencies, or change external systems."), "worker must not commit, push, deploy, or install");
assert.ok(instructions.includes("Never spawn another agent."), "worker must prohibit nested agents");
assert.ok(instructions.includes("One corrective retry is allowed after a failed check; then stop and return the failure evidence."), "worker must limit corrective retries");
});
test("AGENTS and governance contract documents", async () => {
const agentsLines = (await readFile(new URL("../AGENTS.md", import.meta.url), "utf8")).split(/\r?\n/);
const governanceLines = (await readFile(new URL("../docs/CODEX_SUBAGENT_GOVERNANCE.md", import.meta.url), "utf8")).split(/\r?\n/);
assert.ok(
agentsLines.some((line) => line.includes("`docs/CODEX_SUBAGENT_GOVERNANCE.md`")),
"AGENTS.md must point to governance document",
);
assert.ok(
agentsLines.some((line) => line.toLowerCase().includes("at most two subagents")),
"AGENTS.md should state max two subagents",
);
assert.ok(
agentsLines.some((line) => line.toLowerCase().includes("one write-capable")),
"AGENTS.md should state max one writer subagent",
);
assert.ok(
governanceLines.some((line) => line.includes("## Mandatory task packet")),
"governance should define mandatory task packet",
);
assert.ok(
governanceLines.some((line) => line.includes("1. Objective: one concrete outcome.")),
"governance should include mandatory objective field",
);
assert.ok(
governanceLines.some((line) => line.includes("2. Allowed scope: exact files, directories, or read-only tools.")),
"governance should include allowed scope field",
);
assert.ok(
governanceLines.some((line) => line.includes("3. Forbidden actions: especially external writes, commits, pushes, and deploys.")),
"governance should include forbidden actions field",
);
assert.ok(
governanceLines.some((line) => line.includes("4. Acceptance criteria: observable evidence of completion.")),
"governance should include acceptance criteria field",
);
assert.ok(
governanceLines.some((line) => line.includes("5. Verification: exact checks the worker may run.")),
"governance should include verification field",
);
assert.ok(
governanceLines.some((line) => line.includes("6. Output: a short structured summary, not raw logs.")),
"governance should include output field",
);
assert.ok(
governanceLines.some((line) => line.includes("Reviews the diff and performs final verification itself.")),
"governance should retain final primary review and verification ownership",
);
});
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SplitPane } from "../packages/ui-react/dist/index.js";
test("SplitPane exposes one controlled accessible separator", () => {
const markup = renderToStaticMarkup(createElement(SplitPane, {
primary: createElement("div", null, "Видео"),
secondary: createElement("div", null, "3D"),
primarySize: 64,
onPrimarySizeChange: () => {},
minPrimarySize: 25,
minSecondarySize: 30,
separatorLabel: "Изменить ширину представлений",
}));
assert.match(markup, /class="nodedc-split-pane"/);
assert.match(markup, /role="separator"/);
assert.match(markup, /aria-orientation="vertical"/);
assert.match(markup, /aria-valuemin="25"/);
assert.match(markup, /aria-valuemax="70"/);
assert.match(markup, /aria-valuenow="64"/);
assert.match(markup, /aria-valuetext="64% \/ 36%"/);
});
test("SplitPane can keep panel ownership stable without an inactive separator", () => {
const markup = renderToStaticMarkup(createElement(SplitPane, {
primary: createElement("div", null, "Видео"),
secondary: createElement("div", null, "3D"),
primarySize: 100,
onPrimarySizeChange: () => {},
separatorLabel: "Изменить ширину представлений",
resizable: false,
}));
assert.match(markup, /data-pane="primary"/);
assert.match(markup, /data-pane="secondary"/);
assert.doesNotMatch(markup, /role="separator"/);
});
test("SplitPane is registered, documented, cataloged and keyboard-addressable", async () => {
const [component, styles, registrySource, docs, catalog] = await Promise.all([
readFile(new URL("../packages/ui-react/src/SplitPane.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const entry = registry.components.find((item) => item.id === "split-pane");
assert.ok(entry?.exports.includes("SplitPane"));
assert.deepEqual(entry?.variants, ["vertical", "horizontal"]);
assert.match(component, /event\.key === "Home"/);
assert.match(component, /event\.key === "End"/);
assert.match(component, /setPointerCapture/);
assert.match(component, /activePointerIdRef\.current !== event\.pointerId/);
assert.match(component, /role="separator"/);
assert.match(styles, /\.nodedc-split-pane__separator:focus-visible::before/);
assert.match(docs, /## SplitPane/);
assert.match(catalog, /<SplitPane/);
});
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(
new URL("../packages/ui-react/src/Toast.tsx", import.meta.url),
"utf8",
);
test("toast items own independent ten-second terminal lifetimes", () => {
assert.match(source, /const DEFAULT_TOAST_DURATION_MS = 10_000/);
assert.match(source, /function TimedToastCard/);
assert.match(source, /item\.tone === "loading" \? null : DEFAULT_TOAST_DURATION_MS/);
assert.match(source, /window\.setTimeout\(\(\) => dismissRef\.current\(item\.id\), duration\)/);
assert.match(source, /\[item\.durationMs, item\.id, item\.tone\]/);
assert.match(source, /items\.map\(\(item\) => <TimedToastCard key=\{item\.id\}/);
assert.doesNotMatch(source, /items\.flatMap/);
});