Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c53f73ee5 | ||
|
|
b10fd5d645 | ||
|
|
26a1bf72a2 | ||
|
|
5b882bc3d9 | ||
|
|
1bdfc6c240 | ||
|
|
70ce00f2d0 | ||
|
|
999864e5b0 | ||
|
|
3adeb33b1b | ||
|
|
be6463fd59 | ||
|
|
47d4f19d99 | ||
|
|
17e150b1c7 | ||
|
|
8a79dfe84d | ||
|
|
d51d8bb7f6 | ||
|
|
c8f4916423 | ||
|
|
6e7255ecdb | ||
|
|
c7e136cc14 | ||
|
|
51cb426c6b | ||
|
|
8f38c76f79 | ||
|
|
2fa1951f51 | ||
|
|
19f0d97e23 | ||
|
|
bbb50e06b4 | ||
|
|
d6c62da470 | ||
|
|
a3385e83c4 | ||
|
|
9fa81fde9a | ||
|
|
117bfe0c3a | ||
|
|
1c5246afe8 | ||
|
|
4116f5ba95 |
@@ -2,6 +2,10 @@ 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,
|
||||
LoadingRegion,
|
||||
StatusBadge,
|
||||
ProgressBar,
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
@@ -28,10 +32,13 @@ import {
|
||||
InspectorSelectField,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
ResourceRow,
|
||||
ResourceList,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
SplitPane,
|
||||
SortableItem,
|
||||
SortableScope,
|
||||
SettingsCard,
|
||||
@@ -76,6 +83,7 @@ import {
|
||||
type StoredLayout,
|
||||
} from "./designProfile.js";
|
||||
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
|
||||
import { EnvironmentCatalogDemo } from "./EnvironmentCatalogDemo.js";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
@@ -208,7 +216,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 +226,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 +235,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
alert: "Предупреждение",
|
||||
apps: "Приложения",
|
||||
building: "Компания",
|
||||
camera: "Камера",
|
||||
check: "Готово",
|
||||
"chevron-down": "Раскрыть",
|
||||
"chevron-left": "Назад",
|
||||
@@ -238,6 +247,8 @@ const iconLabels: Record<IconName, string> = {
|
||||
database: "База данных",
|
||||
download: "Скачать",
|
||||
edit: "Редактировать",
|
||||
eye: "Показать",
|
||||
"eye-off": "Скрыть",
|
||||
expand: "Развернуть",
|
||||
external: "Открыть снаружи",
|
||||
file: "Файл",
|
||||
@@ -253,6 +264,8 @@ const iconLabels: Record<IconName, string> = {
|
||||
minimize: "Свернуть",
|
||||
network: "Связи",
|
||||
panel: "Панель",
|
||||
plan: "План",
|
||||
play: "Воспроизвести",
|
||||
target: "Таргеты",
|
||||
plus: "Добавить",
|
||||
profile: "Профиль",
|
||||
@@ -262,6 +275,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
settings: "Настройки",
|
||||
shield: "Доступ",
|
||||
sliders: "Параметры",
|
||||
stop: "Остановить",
|
||||
trash: "Удалить",
|
||||
upload: "Загрузить",
|
||||
users: "Участники",
|
||||
@@ -359,6 +373,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 +1293,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 +1339,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 +1357,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 +1400,38 @@ 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 loading>Подключить</Button>
|
||||
<IconButton label="Обновить" loading><Icon name="refresh" /></IconButton>
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||
</Preview>
|
||||
<Preview title="Загрузка содержимого" note="в центре собственной области">
|
||||
<LoadingRegion loading label="Ожидаем содержимое" />
|
||||
</Preview>
|
||||
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /></Preview>
|
||||
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
||||
<ProgressBar label="Получение сведений" />
|
||||
<ProgressBar label="Завершено" value={1} />
|
||||
</Preview>
|
||||
<Preview title="Строки ресурсов" note="Mission Core / общий список">
|
||||
<ResourceList aria-label="Пример списка ресурсов">
|
||||
<li><ResourceRow icon={<Icon name="file" />} title="Сохранённый результат" metadata="Сегодня · доступен для просмотра" actions={<IconButton label="Просмотреть пример" onClick={() => setProjectName("Сохранённый результат")}><Icon name="eye" /></IconButton>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подключённая камера" description="Индикатор перед названием" statusPlacement="leading" status={<StatusBadge variant="indicator" tone="success" aria-label="Подключено" title="Подключено" />} actions={<IconButton label="Просмотреть камеру" onClick={() => setProjectName("Подключённая камера")}><Icon name="eye" /></IconButton>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
|
||||
</ResourceList>
|
||||
<SettingsCard title="Устройства не обнаружены" description="Подключите устройство, чтобы оно появилось в списке." />
|
||||
<SettingsCard align="center" role="status" title="Настройки применены" description="Проверьте подключение устройства." />
|
||||
</Preview>
|
||||
<Preview title="Главная и окружение" note="единые компоненты · сохранение в памяти каталога">
|
||||
<EnvironmentCatalogDemo />
|
||||
</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 +1440,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 +1493,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 ? (
|
||||
@@ -1526,7 +1615,7 @@ export function CatalogApp() {
|
||||
<span>CANONICAL GLASS</span>
|
||||
<h2>Модальное окно и Inspector</h2>
|
||||
<p>Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.</p>
|
||||
<div className="catalog-inline"><TextField label="Пример поля" defaultValue="NODE.DC" /><Button variant="primary" shape="pill">Применить</Button></div>
|
||||
<div className="catalog-inline"><TextField label="Пример поля" name="organization" autoComplete="organization" defaultValue="NODE.DC" description="Автозаполнение сохраняет материал поля и клавиатурный фокус текущей темы." /><Button variant="primary" shape="pill">Применить</Button></div>
|
||||
</GlassMaterialSurface>
|
||||
</section>
|
||||
<SettingsCard eyebrow="ENGINE / GLASS V4" title="Параметры материала" description="Общий token-контракт ui-core; изменения сразу видны на preview, модалках и Inspector.">
|
||||
@@ -1621,6 +1710,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>
|
||||
@@ -1925,6 +2016,7 @@ export function CatalogApp() {
|
||||
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
||||
header={
|
||||
<AppHeader
|
||||
brandMonochrome
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandHref="/"
|
||||
center={
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { EnvironmentSettings } from "@nodedc/ui-core";
|
||||
import { Button, EnvironmentSettingsWindow, LandingStage, UserProfileMenu } from "@nodedc/ui-react";
|
||||
|
||||
const initial: EnvironmentSettings = { revision: 0, pages: { home: {
|
||||
headerLabel: "Продукт", eyebrow: "NODE.DC / ГЛАВНАЯ", title: "Главная продукта",
|
||||
description: "Общая композиция страницы и настроек окружения.", primaryWorkspaceId: null, secondaryWorkspaceId: null,
|
||||
background: { enabled: false, imageDurationSeconds: 10, items: [] },
|
||||
} } };
|
||||
|
||||
export function EnvironmentCatalogDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings, setSettings] = useState(initial);
|
||||
const urls = useRef<string[]>([]);
|
||||
useEffect(() => () => urls.current.forEach(url => URL.revokeObjectURL(url)), []);
|
||||
return <>
|
||||
<div className="catalog-environment-actions">
|
||||
<UserProfileMenu displayName="DC" subtitle="Каталог компонентов" triggerLabel={null}
|
||||
actions={[{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setOpen(true) }]} />
|
||||
<Button variant="primary" tone="neutral" onClick={() => setOpen(true)}>Настройки окружения</Button>
|
||||
<Button variant="primary" tone="neutral" disabled>Неактивное действие</Button>
|
||||
</div>
|
||||
<div className="catalog-environment-stage"><LandingStage page={settings.pages.home} /></div>
|
||||
<EnvironmentSettingsWindow open={open} onClose={() => setOpen(false)} productName="Продукт"
|
||||
surfaces={[{ id: "home", home: true, description: "Главная страница продукта", actions: [] }]}
|
||||
settings={settings} state="ready" error={null}
|
||||
onSave={async draft => { const next = { ...draft, revision: draft.revision + 1 }; setSettings(next); return next; }}
|
||||
onUpload={async (_surface, _item, file) => {
|
||||
const url = URL.createObjectURL(file); urls.current.push(url);
|
||||
return { url, fileName: file.name, mediaKind: file.type.startsWith("video/") ? "video" : "image" };
|
||||
}} />
|
||||
</>;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
.catalog-environment-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.catalog-environment-stage { display: grid; width: 100%; min-height: 480px; }
|
||||
|
||||
:root {
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
@@ -1678,6 +1681,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 +1784,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;
|
||||
|
||||
@@ -74,3 +74,16 @@
|
||||
- не поддерживать отдельную тему путём fork компонента;
|
||||
- не использовать legacy Engine inspector как промежуточный канон;
|
||||
- не удалять production local component до проверки package replacement.
|
||||
# Mission Core and onboard Home (R17)
|
||||
|
||||
Mission Core and Mission Core Node consume `EnvironmentSettingsWindow`,
|
||||
`EnvironmentMediaPlaylistEditor`, `EnvironmentBackgroundMedia` and `LandingStage`
|
||||
from `@nodedc/ui-react`. The former Control Station files are thin API/product
|
||||
registry adapters or compatibility re-exports. They own no copied form markup or
|
||||
presentation CSS. Node supplies Home only; Core retains all existing pages and
|
||||
its wire/storage schema. Both use the existing `UserProfileMenu` avatar dropdown.
|
||||
The component contract and catalog example are in `ENVIRONMENT_SETTINGS.md`.
|
||||
|
||||
## Mission Core loading (2026-09-08)
|
||||
|
||||
Core и Node используют общий sensor-ui/X4 frontend с Button.loading, IconButton.loading и LoadingRegion из Design Guideline. SDK/network semantics остаются в consumer, геометрия ожидания принадлежит DG. Этот локальный кандидат передаётся через source manifest с hashes вместе с Node installer; public npm release не публиковался, исходники компонентов в приложение не копируются.
|
||||
|
||||
@@ -44,3 +44,7 @@ Modal остаётся portal-слоем. Его тело прокручивае
|
||||
- предметное содержимое content window.
|
||||
|
||||
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
||||
|
||||
На узком экране скрывается именно действие `data-action="expand"`.
|
||||
Порядок utility actions не является признаком типа действия: плюс и обновление
|
||||
остаются доступны, даже когда стоят первыми в шапке. Исправлено при QA Node 0.3.
|
||||
|
||||
+75
-5
@@ -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,9 +27,25 @@ Button используется для всех текстовых действ
|
||||
|
||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||
|
||||
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
||||
|
||||
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||
|
||||
`loading` в `Button` и `IconButton` помещает индикатор по центру нажатой кнопки, сохраняет её размеры и accessible name, выставляет `aria-busy` и блокирует повторное нажатие. Подробный контракт — [состояния загрузки](LOADING_STATES.md).
|
||||
|
||||
## LoadingRegion
|
||||
|
||||
`LoadingRegion loading={pending} label="Получаем данные"` центрирует ожидание внутри области содержимого, сохраняя её детей смонтированными. Приложение резервирует размер визуализатора и снимает loading после первого пригодного содержимого, ошибки или timeout. Командный spinner принадлежит кнопке и сюда не дублируется.
|
||||
|
||||
## ActivityIndicator
|
||||
|
||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется внутри `LoadingRegion` или явного inline-статуса, `compact` — внутри `Button.loading`/`IconButton.loading` либо фиксированного слота строки ресурса. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||
|
||||
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||
|
||||
## Field
|
||||
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля. Автозаполнение браузера сохраняет материал поля, цвет текста и каретки текущей темы; синяя системная заливка не используется. Клавиатурный фокус остаётся видимым.
|
||||
|
||||
Приложение не должно вручную собирать label и input, если подходит Field. Ошибка и validation state будут расширением этого контракта, а не локальным классом приложения.
|
||||
|
||||
@@ -39,7 +57,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 +89,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`.
|
||||
|
||||
@@ -89,6 +110,8 @@ Window — единая механика открытия modal и правой
|
||||
|
||||
Содержание, сохранение и запросы принадлежат приложению.
|
||||
|
||||
Modeless Inspector закрывается независимо от результата автосохранения. Приложение сначала закрывает controlled `Window`, затем завершает сохранение асинхронно; сбой сообщает через компактный `ToastStack` с tone `error`, сохраняя draft текущего просмотра. Нельзя удерживать окно открытым, выводить внутри него browser-default `p`/heading или увеличивать текст ошибки до title-размера.
|
||||
|
||||
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
||||
|
||||
## WorkspaceWindow
|
||||
@@ -139,6 +162,16 @@ Enter при отсутствии совпадения разрешает оди
|
||||
|
||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||
|
||||
## SplitPane
|
||||
|
||||
`SplitPane` — контролируемая раскладка двух синхронных представлений с общей регулируемой границей. Приложение передаёт содержимое обеих панелей, процент primary-панели и callback изменения; дизайн-система владеет pointer capture, ограничениями размеров, focus-state и доступностью `role="separator"`.
|
||||
|
||||
Локальные toolbar и overlay каждого viewport являются содержимым соответствующей панели. Их нельзя позиционировать от общего workspace или вычислять по cursor/event-эвристике вложенного renderer. Если renderer хранит свою раскладку, приложение передаёт ему тот же controlled процент из `SplitPane`, чтобы renderer и локальный UI использовали одну геометрию.
|
||||
|
||||
Вертикальная граница меняется мышью или клавишами `←/→`, горизонтальная — `↑/↓`; `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 +186,8 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
||||
|
||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||
|
||||
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
||||
|
||||
## AppHeader
|
||||
|
||||
Трёхосевая верхняя панель:
|
||||
@@ -217,6 +252,11 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
`SettingsCard` фиксирует нейтральную структуру админской группы: eyebrow/title/description/actions/body. `Switch` покрывает компактное включение/видимость внутри таких групп. Данные секции, сохранение и права остаются в consumer.
|
||||
|
||||
Для компактных сообщений о результате и пустых состояний `SettingsCard align="center"`
|
||||
центрирует содержимое по горизонтали и вертикали. Пустой body не создаёт
|
||||
асимметричный нижний отступ. Обычные группы настроек сохраняют `align="start"`;
|
||||
вариант не задаёт высоту и не меняет токены темы.
|
||||
|
||||
## AdminNavigationPanel
|
||||
|
||||
Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity.
|
||||
@@ -265,7 +305,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
|
||||
|
||||
@@ -287,3 +327,33 @@ Inspector владеет:
|
||||
Для Engine Environment Settings desktop-контракт точный: окно `390 px`, рабочая колонка `330 px`, строка `154 + 14 + 162 px`, высота контрола `46 px`, section header `50 px` с радиусом `12 px`. Accent-filled section headers и полноширинные range/checker/select сохраняют геометрию исходника.
|
||||
|
||||
Engine продолжает владеть определениями полей, типами нод и сохранением значений. В живом catalog эти collapsible sections находятся в `Guideline → Контролы`; отдельной product-вкладки Inspector нет.
|
||||
|
||||
## ResourceRow и ResourceList
|
||||
|
||||
Общие компактные строки ресурсов, выделенные из Mission Core AI Inference.
|
||||
Контракт, состояния и происхождение: [RESOURCE_ROW.md](RESOURCE_ROW.md).
|
||||
|
||||
Светлый одноцветный знак шапки явно помечается `AppHeader.brandMonochrome` и
|
||||
`HeaderWorkspace.monochrome`: общая тема обеспечивает контраст на светлом фоне.
|
||||
По умолчанию флаг выключен; цветные изображения и аватары не меняются.
|
||||
|
||||
## ProgressBar
|
||||
|
||||
Линейная полоса выполнения: `label`, измеренная доля `value` 0…1 или
|
||||
неопределённая продолжительность без `value`, доступный `valueText`.
|
||||
В `ResourceRow` передаётся через `progress` и заменяет статус между
|
||||
наименованием и кнопками. Процент принадлежит приложению, анимация его не выдумывает.
|
||||
См. [контракт](PROGRESS_BAR.md).
|
||||
|
||||
StatusBadge `variant="indicator"` отображает только одну лампу состояния.
|
||||
Передавайте `aria-label` и `title`; дополнительный текст и значки скрываются.
|
||||
|
||||
## EnvironmentSettingsWindow и EnvironmentMediaPlaylistEditor
|
||||
|
||||
Единый редактор окружения перенесён из принятой композиции Mission Core. Принимает страницы, quick-action варианты, документ, состояние, upload/save adapters и название продукта. Использует тот же FeatureSettingsWindow, поля, переключатели, медиаконтент и перестановку; новое приложение передаёт данные, а не копирует форму. Для бортового приложения передаётся только home. Контракты и пример: [ENVIRONMENT_SETTINGS.md](ENVIRONMENT_SETTINGS.md).
|
||||
|
||||
## LandingStage и EnvironmentBackgroundMedia
|
||||
|
||||
Общая главная: надзаголовок, заголовок, описание, быстрые действия, фон и необязательные status/footer slots. Изображения меняются по таймеру, видео — после завершения; ошибочные источники пропускаются. Пустой фон однотонный, без декоративного круга. Состояние страниц и хранение принадлежат приложению.
|
||||
|
||||
Для primary Button `tone="neutral"` фиксирует белый enabled и серый disabled, независимо от темы и акцента; keyboard focus остаётся видимым. MediaSourceField принимает `disabled` и блокирует все операции выбора источника, пока родитель сохраняет документ или загружает файл.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Shared environment and home
|
||||
|
||||
The owner-approved Mission Core settings anatomy is now canonical, not a screenshot to reproduce. Consumers use UserProfileMenu for the avatar dropdown, EnvironmentSettingsWindow for the settings composition, EnvironmentMediaPlaylistEditor for the list and LandingStage/EnvironmentBackgroundMedia for the home. All geometry and responsive styles live in ui-core.
|
||||
|
||||
The neutral primary Button tone comes from shared tokens, stays white when enabled and gray when disabled across dark/light and accent changes. It does not alter other applications' default primary theme.
|
||||
|
||||
## Consumer contract
|
||||
|
||||
Pass productName, a nonempty stable surfaces list, optional initialSurfaceId, a revision/pages document and save/upload adapters. Each surface references one present page and declares its available quick actions. Workspace IDs are opaque application-owned identifiers. The library owns only draft/form/list/playback behavior; it never imports Mission Core's registry or calls product APIs. Shared data shapes/list functions are exported by ui-core so nonvisual application contracts do not depend on React.
|
||||
|
||||
Settings uses the existing FeatureSettingsWindow/Window focus and Escape behavior. A busy parent disables editing and save; failed saves retain the draft. Each playlist has up to 24 items. Images use the configured delay; a video advances on ended, with one video looping. Failed media is skipped. List display reverses playback order to keep newly added media at the top, as in the accepted source UI.
|
||||
|
||||
The application validates URLs/media bytes, persists uploads and document revisions, handles authentication and rejects stale saves. An upload response provides URL, fileName and mediaKind. A file URL belongs to the storage adapter; an external source must use HTTP(S). No media defaults to an invented graphic. The product may supply status/footer slots without forking the stage.
|
||||
|
||||
## Adoption and validation
|
||||
|
||||
Mission Core passes all existing page definitions and its current operator-environment API. Mission Core Node passes home only and authenticated local presentation storage. Device control never depends on this document. The catalog EnvironmentCatalogDemo exercises the same exported components with an explicitly in-memory save/upload adapter; it is not a persistence service.
|
||||
|
||||
Validate the source migration, TypeScript builds, playlist functions, neutral enabled/disabled DOM contract, dark/light and multiple accents, modal Escape, media order/timing and both consumer persistence adapters. Long-running or physical device acceptance remains application-owned.
|
||||
+6
-1
@@ -29,6 +29,12 @@
|
||||
|
||||
Допустимы density variants, если они имеют устойчивое назначение (`default`, `compact`) и тестируются как часть API.
|
||||
|
||||
## Типографика и операционные сообщения
|
||||
|
||||
Продуктовый интерфейс использует только типографические токены. Browser-default размеры у `h1`–`h6`, `p`, `strong` и form controls не являются допустимым стилем. `--nodedc-font-size-title` разрешён только для заголовка страницы или окна; названия конфигураций, статусы, ошибки, подписи полей и сообщения внутри рабочей сцены используют `md`, `sm`, `xs` или готовую типографику канонического компонента.
|
||||
|
||||
Ошибка сохранения или запроса не рендерится свободным текстовым блоком поверх рабочей сцены. Приложение использует семантический error-state канонического поля либо `ToastStack`, который владеет размером текста, цветом, положением и закрытием. Ошибка автосохранения modeless-инспектора не блокирует закрытие окна: draft остаётся в текущем просмотре, окно закрывается сразу, а сбой показывается неблокирующим error toast.
|
||||
|
||||
## Deprecated
|
||||
|
||||
Перед удалением export:
|
||||
@@ -53,4 +59,3 @@
|
||||
## Владение
|
||||
|
||||
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
||||
|
||||
|
||||
+6
-2
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Состояния загрузки
|
||||
|
||||
Общий контракт для Mission Core, Mission Core Node и других NODE.DC consumers. Кольцо рисует ActivityIndicator; место и семантику определяют Button.loading, IconButton.loading или LoadingRegion. Локальные absolute-спиннеры и свободные ActivityIndicator под группой действий запрещены.
|
||||
|
||||
| Причина ожидания | Компонент и место | Завершение |
|
||||
|---|---|---|
|
||||
| Нажата команда | loading только у инициирующей Button/IconButton, по центру внутри её прежних границ | Подтверждение результата, ошибка или timeout команды |
|
||||
| Начальная загрузка содержимого | LoadingRegion вокруг конкретной области, по центру её границ | Первое пригодное содержимое, ошибка или timeout |
|
||||
| Подключение видео | LoadingRegion вокруг смонтированного video/canvas, включая расширенный вид | Первый декодированный кадр; одного ICE connected недостаточно |
|
||||
| Фоновое обновление с доступным содержимым | loading у явной кнопки обновления; без overlay при автоматическом polling | Ответ запроса или ошибка |
|
||||
| Измеряемая многошаговая подготовка | Существующий ProgressBar/ResourceRow.progress и статусы этапов | Результат этапа/операции |
|
||||
| Ошибка, offline, отсутствие свежих кадров | Конечный статус и разрешённое действие повторения | Не оставлять бесконечный spinner |
|
||||
|
||||
Приложение хранит pending по device ID, session и конкретному action key. Блокировка соседних несовместимых действий не делает их загружающимися. Для одной команды показывается один индикатор; последующее получение содержимого — отдельный этап. Элемент управления сохраняет размеры, надпись для screen reader и свой порядок в layout; spinner не добавляет строку и не сдвигает соседей. Повторная активация pending-кнопки блокируется native disabled.
|
||||
|
||||
LoadingRegion не размонтирует video/canvas и не присваивает область страницы целиком. Визуализатор задаёт собственную стабильную высоту/aspect ratio; индикатор центрируется относительно этой области и автоматически следует её normal/expanded размеру. Компонент не блокирует управление сам: consumer отключает только конфликтующие действия. Закрытие просмотра остаётся доступным.
|
||||
|
||||
Владелец операции задаёт начало и конец ожидания. Компоненты не запускают запросы, не выдумывают проценты, не повторяют операции и не скрывают ошибки по таймеру. Reduced motion сохраняет статическое кольцо. Button сохраняет accessible name и aria-busy; LoadingRegion имеет один status с label, декоративное кольцо скрыто от accessibility tree.
|
||||
|
||||
```tsx
|
||||
<Button loading={pending === 'save'} onClick={save}>Сохранить</Button>
|
||||
<IconButton label="Обновить" loading={pending === 'refresh'} onClick={refresh}><Icon name="refresh" /></IconButton>
|
||||
<LoadingRegion loading={connecting || awaitingFirstFrame} label="Ожидаем изображение">
|
||||
<video autoPlay muted playsInline />
|
||||
</LoadingRegion>
|
||||
```
|
||||
|
||||
Living catalog показывает action, icon-action и content loading. Изменение согласовано владельцем08.09.2026 для обоих Mission Core consumers. Локальный кандидат включается в hash-bound Ubuntu source artifact; внешний package release в рамках этой задачи не публикуется.
|
||||
|
||||
## Проверка кандидата 08.09.2026
|
||||
|
||||
N09: typecheck, registry validation, четыре loading contract tests и production catalog build прошли. В браузере проверены light/dark и два accent colors: loading не меняет размер Button/IconButton, кольцо сохраняет нейтральный цвет, content status центрируется вместе с подписью. В Mission Core проверены pending read-only action, video first frame, normal/expanded/Escape и terminal error: mounted video сохраняется, после кадра или ошибки лишних индикаторов нет. Тот же shared frontend включён в установленный Node0.8.18; отдельная визуальная проверка native Node shell остаётся задачей consumer acceptance.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Компактная операционная типографика
|
||||
|
||||
Уточнение владельца 06.09.2026 для Mission Core / Node, обязательное для
|
||||
общих компонентов NODE.DC. Повторяющиеся сообщения и подписи не оформляются
|
||||
крупными заголовками. Этот документ зарегистрирован в registry/registry.json.
|
||||
|
||||
- Заголовок страницы или окна: только предусмотренный шаблоном title-токен.
|
||||
- Заголовок секции, SettingsCard, короткое сообщение пустого списка: md
|
||||
(0.8125rem / 13 px при стандартном корневом размере), без увеличения через h2.
|
||||
- Описание, пояснение, сведения о состоянии: sm (0.75rem / 12 px).
|
||||
- Технические метаданные: xs либо типографика ResourceRow.
|
||||
- Browser-default h1–h6/p/strong и локальные крупные размеры запрещены.
|
||||
- Размер не умножается из-за вложенности карточек, ошибки или отсутствия данных.
|
||||
|
||||
SettingsCard и nodedc-empty-state закрепляют эти размеры в ui-core. Текст
|
||||
«Устройства не обнаружены» не является заголовком нового раздела. В живом
|
||||
каталоге рядом со строками ресурсов есть пример пустого списка.
|
||||
|
||||
В завершённом этапе настройки окружения status-слот ResourceRow содержит
|
||||
только Icon check с доступным названием. Галочка не оборачивается в StatusBadge,
|
||||
Checker, круг, pill или кнопку. Это знак результата, а не интерактивный контрол.
|
||||
Лампочка состояния устройства отдельно использует StatusBadge indicator.
|
||||
@@ -0,0 +1,15 @@
|
||||
# ProgressBar
|
||||
|
||||
Владелец Mission Core 05.09.2026 явно запросил горизонтальную заполняющуюся
|
||||
полосу вместо кружка и точки в строке подготовки устройства. Общий компонент
|
||||
добавлен в Design Guideline до подключения в Node/Core.
|
||||
|
||||
`value` — завершённая доля 0…1; NaN/Infinity трактуются как неизвестная величина,
|
||||
выход за границы ограничивается. Без value используется движущийся сегмент,
|
||||
aria-valuenow отсутствует. `label` обязателен; `valueText` описывает текущий этап.
|
||||
Нет интерактивности и focus. Темы используют существующие семантические токены.
|
||||
При reduced motion движение выключено, видимый сегмент сохраняется.
|
||||
|
||||
ResourceRow.progress занимает свободное место между текстом и actions, скрывает
|
||||
status, на узком экране переносится ниже текста. Готовность не вычисляется
|
||||
компонентом. Каталог содержит завершённый, определённый и неизвестный прогресс.
|
||||
@@ -0,0 +1,34 @@
|
||||
# ResourceRow и ResourceList
|
||||
|
||||
Владелец 2026-09-05 запросил перенос существующих длинных строк Mission Core
|
||||
AI Inference в Node с повторным использованием Design Guideline. Источник:
|
||||
ObservatoryWorkspace / observatory-evidence-card. Это выделение существующего
|
||||
оформления в общий компонент, без лабораторной модели данных.
|
||||
|
||||
`ResourceList` содержит обычные `li`; внутри — `ResourceRow` с обязательным
|
||||
`title`, опциональными `icon`, `description`, `metadata`, `status`, `actions`.
|
||||
Слоты действий принимают канонические Button/IconButton. Клик по всей строке
|
||||
не назначается: самостоятельные действия имеют свои доступные подписи и focus.
|
||||
|
||||
Геометрия источника сохранена: минимум 4.25rem, отступы .7/.85rem, значок
|
||||
2.15rem, компактный радиус и существующие токены поверхности/типографики.
|
||||
Длинное название переносится; метаданные сокращаются визуально, полный текст
|
||||
можно передать через title. На узком экране статус и действия переходят ниже.
|
||||
|
||||
Состояния поставляет consumer: нормальное, пустой список, загрузка, ошибка,
|
||||
недоступное действие. Pending отмечается aria-busy и ActivityIndicator;
|
||||
disabled и keyboard/focus остаются у общего контрола. Строка не объявляет
|
||||
объект доступным или подключённым самостоятельно.
|
||||
|
||||
Пример находится в живом каталоге, раздел «Контролы → Строки ресурсов».
|
||||
|
||||
Для длительных операций с линейной индикацией используйте `progress`
|
||||
(ProgressBar: label, value, valueText). Он заменяет status, оставляет действия
|
||||
справа и занимает промежуток после названия.
|
||||
|
||||
`statusPlacement="leading"` размещает компактный индикатор после иконки и
|
||||
перед всем текстовым блоком, с выравниванием по вертикальному центру строки.
|
||||
По умолчанию остаётся `trailing` перед действиями. Статус не дублируется;
|
||||
во время `progress` он скрыт в любом положении. Для одной лампы используйте
|
||||
StatusBadge variant="indicator" с aria-label и title. Это согласованное
|
||||
06.09.2026 расположение индикатора устройств Mission Core / Node.
|
||||
+9
-2
@@ -11,10 +11,11 @@
|
||||
"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 && npm run test:environment-settings",
|
||||
"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",
|
||||
"test:environment-settings": "node --test scripts/environment-settings.test.mjs",
|
||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
||||
"test:data-product-consumer": "node --test server/foundry-data-product-consumer.test.mjs",
|
||||
@@ -31,9 +32,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"
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
:root {
|
||||
--nodedc-neutral-action-bg: rgba(247, 248, 244, 0.96);
|
||||
--nodedc-neutral-action-hover: #fff;
|
||||
--nodedc-neutral-action-color: rgba(8, 8, 10, 0.96);
|
||||
--nodedc-neutral-action-disabled-bg: #757575;
|
||||
--nodedc-neutral-action-disabled-color: #242424;
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-nodedc-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings { revision: number; pages: Record<string, EnvironmentPage> }
|
||||
export interface UploadedEnvironmentMedia { url: string; fileName: string; mediaKind: EnvironmentMediaKind }
|
||||
export interface EnvironmentAction { id: string; label: string; description?: string }
|
||||
export interface EnvironmentSurface { id: string; home?: boolean; description?: string; actions: readonly EnvironmentAction[] }
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
|
||||
export function cloneEnvironmentSettings(settings: EnvironmentSettings): EnvironmentSettings {
|
||||
return { ...settings, pages: Object.fromEntries(Object.entries(settings.pages).map(([id, page]) => [id, {
|
||||
...page, background: { ...page.background, items: page.background.items.map(item => ({ ...item })) },
|
||||
}])) };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from "./floating.js";
|
||||
export * from "./glass.js";
|
||||
export * from "./theme.js";
|
||||
export * from "./toolbar.js";
|
||||
export * from "./environment.js";
|
||||
|
||||
+652
-6
@@ -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);
|
||||
}
|
||||
@@ -104,6 +108,7 @@
|
||||
}
|
||||
|
||||
.nodedc-button {
|
||||
position: relative;
|
||||
--nodedc-button-bg: var(--nodedc-glass-control-bg);
|
||||
--nodedc-button-color: var(--nodedc-text-primary);
|
||||
display: inline-flex;
|
||||
@@ -138,6 +143,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%;
|
||||
}
|
||||
@@ -174,6 +186,26 @@
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 84%, white);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"] {
|
||||
--nodedc-button-bg: var(--nodedc-neutral-action-bg);
|
||||
--nodedc-button-color: var(--nodedc-neutral-action-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:hover:not(:disabled) {
|
||||
background: var(--nodedc-neutral-action-hover);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:disabled {
|
||||
background: var(--nodedc-neutral-action-disabled-bg);
|
||||
color: var(--nodedc-neutral-action-disabled-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--nodedc-neutral-action-color);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="ghost"] {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
@@ -205,13 +237,89 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.nodedc-button__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: inherit;
|
||||
gap: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-button[data-loading="true"] > .nodedc-button__content,
|
||||
.nodedc-icon-button[data-loading="true"] > :not(.nodedc-action-loading) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nodedc-action-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-button[data-loading="true"],
|
||||
.nodedc-icon-button[data-loading="true"] {
|
||||
opacity: 1;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.nodedc-loading-region {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-loading-region[aria-busy="true"] {
|
||||
min-block-size: calc(var(--nodedc-control-height) * 2);
|
||||
}
|
||||
|
||||
.nodedc-loading-region__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--nodedc-space-2);
|
||||
padding: var(--nodedc-space-3);
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-button__icon > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
width: var(--nodedc-icon-button-size);
|
||||
height: var(--nodedc-icon-button-size);
|
||||
@@ -231,6 +339,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);
|
||||
}
|
||||
@@ -310,6 +423,20 @@
|
||||
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
/* Browsers enforce their autofill background with an internal !important rule.
|
||||
Paint the canonical field material above it; keep text, caret and keyboard
|
||||
focus in the same theme as manually entered values. */
|
||||
.nodedc-field__control:is(:autofill, :-webkit-autofill) {
|
||||
-webkit-text-fill-color: var(--nodedc-text-primary);
|
||||
caret-color: var(--nodedc-text-primary);
|
||||
box-shadow: inset 0 0 0 100vmax var(--nodedc-field-bg);
|
||||
}
|
||||
|
||||
.nodedc-field__control:is(:autofill, :-webkit-autofill):focus-visible {
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22),
|
||||
inset 0 0 0 100vmax var(--nodedc-field-bg);
|
||||
}
|
||||
|
||||
textarea.nodedc-field__control {
|
||||
min-height: 6rem;
|
||||
padding-block: 0.85rem;
|
||||
@@ -545,13 +672,13 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-settings-card__titles h2 {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -568,6 +695,21 @@ textarea.nodedc-field__control {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] {
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] > .nodedc-settings-card__head {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] > .nodedc-settings-card__body {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.nodedc-switch {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
@@ -769,6 +911,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 +1148,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 +2341,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 +2991,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);
|
||||
@@ -3398,6 +3671,7 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-empty-state {
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
place-items: center;
|
||||
@@ -3409,7 +3683,7 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-empty-state strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-lg);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -3509,7 +3783,7 @@ textarea.nodedc-field__control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__action:first-child {
|
||||
.nodedc-application-panel__action[data-action="expand"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3585,6 +3859,7 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-activity-indicator,
|
||||
.nodedc-dropdown-surface,
|
||||
.nodedc-overlay,
|
||||
.nodedc-window,
|
||||
@@ -3605,3 +3880,374 @@ textarea.nodedc-field__control {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ResourceRow: admitted extraction of Mission Core AI Inference list geometry. */
|
||||
.nodedc-resource-list { display: grid; gap: .4rem; margin: 0; padding: 0; list-style: none; }
|
||||
.nodedc-resource-row { display: flex; align-items: center; gap: .8rem; min-height: 4.25rem; padding: .7rem .85rem; border-radius: var(--nodedc-radius-control-compact); background: var(--nodedc-glass-control-bg); }
|
||||
.nodedc-resource-row__icon { display: grid; flex: 0 0 2.15rem; height: 2.15rem; place-items: center; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-panel-icon-bg); color: var(--nodedc-text-secondary); }
|
||||
.nodedc-resource-row__copy { display: grid; flex: 1; min-width: 0; gap: .16rem; }
|
||||
.nodedc-resource-row__copy strong { font-size: var(--nodedc-font-size-sm); line-height: 1.3; overflow-wrap: anywhere; }
|
||||
.nodedc-resource-row__copy > span, .nodedc-resource-row__copy > small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nodedc-resource-row__copy > span { color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); }
|
||||
.nodedc-resource-row__copy > small { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.nodedc-resource-row__actions { display: flex; align-items: center; gap: .85rem; }
|
||||
.nodedc-resource-row__status { font-size: var(--nodedc-font-size-sm); }
|
||||
.nodedc-resource-row--leading-status > .nodedc-resource-row__status { display: flex; align-items: center; flex: 0 0 auto; }
|
||||
@media (max-width: 540px) {
|
||||
.nodedc-resource-row { flex-wrap: wrap; }
|
||||
.nodedc-resource-row__copy { flex-basis: calc(100% - 3rem); }
|
||||
.nodedc-resource-row--leading-status > .nodedc-resource-row__copy { flex: 1 1 0; }
|
||||
.nodedc-resource-row__actions { margin-left: auto; }
|
||||
}
|
||||
|
||||
[data-nodedc-theme="light"] :is(.nodedc-header__brand, .nodedc-header__workspace)[data-monochrome="true"] img { filter: brightness(0); }
|
||||
|
||||
/* Owner-admitted linear progress in the shared resource row. */
|
||||
.nodedc-progress-bar { min-width: 3rem; height: var(--nodedc-space-2); overflow: hidden; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-glass-control-bg); }
|
||||
.nodedc-progress-bar__fill { display: block; width: 100%; height: 100%; background: var(--nodedc-text-secondary); border-radius: inherit; transform: scaleX(var(--nodedc-progress-fraction)); transform-origin: left; transition: transform var(--nodedc-duration-normal) var(--nodedc-ease-standard); }
|
||||
.nodedc-progress-bar[data-indeterminate] > .nodedc-progress-bar__fill { width: 35%; animation: nodedc-progress-travel 1.5s ease-in-out infinite alternate; }
|
||||
@keyframes nodedc-progress-travel { from { transform: translateX(-100%); } to { transform: translateX(285%); } }
|
||||
.nodedc-resource-row--progress > .nodedc-resource-row__copy { flex: 0 1 35%; }
|
||||
.nodedc-resource-row--progress > .nodedc-progress-bar { flex: 1; }
|
||||
@media (max-width: 40rem) {
|
||||
.nodedc-resource-row--progress > .nodedc-resource-row__copy { flex-basis: calc(100% - 3rem); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-progress-bar__fill { transition: none; }
|
||||
.nodedc-progress-bar[data-indeterminate] > .nodedc-progress-bar__fill { animation: none; width: 100%; transform: scaleX(0.5); }
|
||||
}
|
||||
|
||||
.nodedc-status[data-variant="indicator"] { min-height: 0; width: .42rem; height: .42rem; padding: 0; gap: 0; background: transparent; }
|
||||
.nodedc-status[data-variant="indicator"]::before { content: ""; display: block; width: .42rem; height: .42rem; flex: 0 0 .42rem; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-text-muted); }
|
||||
.nodedc-status[data-variant="indicator"][data-tone="success"]::before { background: rgb(var(--nodedc-success-rgb)); }
|
||||
.nodedc-status[data-variant="indicator"][data-tone="warning"]::before { background: rgb(var(--nodedc-warning-rgb)); }
|
||||
.nodedc-status[data-variant="indicator"][data-tone="danger"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
||||
.nodedc-status[data-variant="indicator"][data-tone="accent"]::before { background: rgb(var(--nodedc-accent-rgb)); }
|
||||
|
||||
/* Shared product landing and environment settings. */
|
||||
.nodedc-landing-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-canvas-soft);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__media,
|
||||
.nodedc-landing-stage__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__media img,
|
||||
.nodedc-landing-stage__media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__shade {
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage:not([data-has-media="true"]) .nodedc-landing-stage__shade {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: clamp(2rem, 5vw, 6rem);
|
||||
width: min(39rem, 50%);
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
margin: 0.75rem 0 1rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.072em;
|
||||
line-height: 0.87;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2.4rem;
|
||||
right: 2.4rem;
|
||||
display: grid;
|
||||
width: min(24rem, 30vw);
|
||||
gap: 0.6rem;
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(10 10 12 / 0.58);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__footer {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 2.4rem;
|
||||
bottom: 1.8rem;
|
||||
left: 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__copy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__editor {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface > span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface .nodedc-select-anchor,
|
||||
.nodedc-environment-settings__surface .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions .nodedc-select-anchor,
|
||||
.nodedc-environment-settings__quick-actions .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head p,
|
||||
.nodedc-environment-media-playlist__empty,
|
||||
.nodedc-environment-media-playlist__timing > span,
|
||||
.nodedc-environment-media-playlist__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__items {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
padding-top: 1.65rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__timing {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-environment-settings__copy,
|
||||
.nodedc-environment-settings__quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item-actions {
|
||||
justify-self: end;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.nodedc-landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.nodedc-landing-stage__status {
|
||||
width: 20rem;
|
||||
max-width: 36vw;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 7vw, 5.6rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__copy {
|
||||
top: 42%;
|
||||
right: 1.2rem;
|
||||
left: 1.2rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
bottom: 3.3rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__footer {
|
||||
right: 1rem;
|
||||
bottom: 1.1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__footer span:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__copy p {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__status {
|
||||
top: 1.2rem;
|
||||
right: 1.2rem;
|
||||
}
|
||||
}
|
||||
.nodedc-landing-stage__eyebrow { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); font-weight: var(--nodedc-font-weight-strong); letter-spacing: .12em; }
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export interface AppHeaderProps {
|
||||
brand: ReactNode;
|
||||
brandHref?: string;
|
||||
brandLabel?: string;
|
||||
/** Opt in only for a light, single-color mark, never a full-color image. */
|
||||
brandMonochrome?: boolean;
|
||||
left?: ReactNode;
|
||||
center?: ReactNode;
|
||||
right?: ReactNode;
|
||||
@@ -13,14 +15,15 @@ export function AppHeader({
|
||||
brand,
|
||||
brandHref,
|
||||
brandLabel = "NODE.DC",
|
||||
brandMonochrome = false,
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
}: AppHeaderProps) {
|
||||
const brandNode = brandHref ? (
|
||||
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
|
||||
<a className="nodedc-header__brand" data-monochrome={brandMonochrome || undefined} href={brandHref} aria-label={brandLabel}>{brand}</a>
|
||||
) : (
|
||||
<span className="nodedc-header__brand" aria-label={brandLabel}>{brand}</span>
|
||||
<span className="nodedc-header__brand" data-monochrome={brandMonochrome || undefined} aria-label={brandLabel}>{brand}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -100,11 +103,12 @@ export interface HeaderWorkspaceProps {
|
||||
label: string;
|
||||
imageUrl?: string;
|
||||
kind?: "mark" | "avatar";
|
||||
monochrome?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark", monochrome = false }: HeaderWorkspaceProps) {
|
||||
return (
|
||||
<span className="nodedc-header__workspace" data-kind={kind} title={label}>
|
||||
<span className="nodedc-header__workspace" data-kind={kind} data-monochrome={monochrome || undefined} title={label}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn.js";
|
||||
import { ActivityIndicator } from "./ActivityIndicator.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> {
|
||||
variant?: ButtonVariant;
|
||||
/** Neutral primary actions remain white/gray independently of the product accent. */
|
||||
tone?: "theme" | "neutral";
|
||||
size?: ButtonSize;
|
||||
width?: "auto" | "full";
|
||||
shape?: ButtonShape;
|
||||
accent?: RgbTuple;
|
||||
icon?: ReactNode;
|
||||
/** Pending state belongs to this action; dimensions and accessible name stay unchanged. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
|
||||
variant = "secondary",
|
||||
tone = "theme",
|
||||
size = "default",
|
||||
width = "auto",
|
||||
shape = "default",
|
||||
@@ -26,6 +32,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
children,
|
||||
style,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
const accentStyle = accent ? createAccentVariables(accent) : undefined;
|
||||
@@ -35,14 +43,21 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
type={type}
|
||||
className={cn("nodedc-button", className)}
|
||||
data-variant={variant}
|
||||
data-tone={tone === "theme" ? undefined : tone}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
data-width={width === "auto" ? undefined : width}
|
||||
data-shape={shape === "default" ? undefined : shape}
|
||||
style={accentStyle ? { ...accentStyle, ...style } : style}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || props["aria-busy"]}
|
||||
data-loading={loading || undefined}
|
||||
>
|
||||
<span className="nodedc-button__content">
|
||||
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
|
||||
{children}
|
||||
</span>
|
||||
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -50,6 +65,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string;
|
||||
shape?: "circle" | "rounded";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
|
||||
@@ -58,6 +74,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
return (
|
||||
@@ -69,8 +87,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || props["aria-busy"]}
|
||||
data-loading={loading || undefined}
|
||||
>
|
||||
{children}
|
||||
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "@nodedc/ui-core";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="nodedc-landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "./index.js";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "@nodedc/ui-core";
|
||||
|
||||
export interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: string;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: string,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
if (!mounted.current) return;
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
if (!mounted.current) return;
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
if (mounted.current) setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodedc-environment-media-playlist">
|
||||
<div className="nodedc-environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="nodedc-environment-media-playlist__items"
|
||||
onReorder={(items) => !disabled && onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="nodedc-environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
disabled={disabled}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
hint="Файл сохраняется в приложении. Ссылка должна вести прямо на изображение или видео по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="nodedc-environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="nodedc-environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="nodedc-environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="nodedc-environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "./index.js";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurface,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "@nodedc/ui-core";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor.js";
|
||||
|
||||
export interface EnvironmentSettingsWindowProps {
|
||||
productName: string;
|
||||
surfaces: readonly EnvironmentSurface[];
|
||||
initialSurfaceId?: string;
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: string,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: string,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: string,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
productName,
|
||||
surfaces,
|
||||
initialSurfaceId,
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState(initialSurfaceId ?? surfaces[0]?.id ?? "home");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = surfaces.map(surface => ({ value: surface.id, label: draft.pages[surface.id].headerLabel, description: surface.description }));
|
||||
const selectedSurface = surfaces.find(surface => surface.id === surfaceId) ?? surfaces[0];
|
||||
const quickActionWorkspaces = selectedSurface?.actions ?? [];
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "loading" || state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
const duplicateActions = Object.entries(draft.pages).find(([, page]) => (
|
||||
page.primaryWorkspaceId && page.primaryWorkspaceId === page.secondaryWorkspaceId
|
||||
));
|
||||
if (duplicateActions) {
|
||||
setSurfaceId(duplicateActions[0]);
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as string);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title={`Настройки ${productName}`}
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: productName,
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: productName.toUpperCase(),
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="neutral"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="nodedc-environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
disabled={busy}
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="nodedc-environment-settings__editor">
|
||||
<div className="nodedc-environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-environment-settings__copy">
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label={selectedSurface?.home ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
disabled={busy}
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
key={surfaceId}
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { EnvironmentPage } from "@nodedc/ui-core";
|
||||
import { Button } from "./Button.js";
|
||||
import { Icon, type IconName } from "./Icon.js";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia.js";
|
||||
export interface LandingStageProps {
|
||||
page: EnvironmentPage;
|
||||
pageId?: string;
|
||||
actions?: readonly { id: string; label: string; icon?: IconName; onSelect: () => void }[];
|
||||
status?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
export function LandingStage({ page, pageId = "home", actions = [], status, footer }: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="nodedc-landing-stage"
|
||||
data-page={pageId}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="nodedc-landing-stage__shade" aria-hidden="true" />
|
||||
<div className="nodedc-landing-stage__copy">
|
||||
<span className="nodedc-landing-stage__eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{actions.length ? (
|
||||
<div className="nodedc-landing-stage__actions">
|
||||
{actions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
tone="neutral"
|
||||
icon={workspace.icon ? <Icon name={workspace.icon} /> : undefined}
|
||||
onClick={() => workspace.onSelect()}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status ? <div className="nodedc-landing-stage__status">{status}</div> : null}
|
||||
{footer ? <footer className="nodedc-landing-stage__footer">{footer}</footer> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { ActivityIndicator } from "./ActivityIndicator.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface LoadingRegionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
loading: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Keeps content mounted and centers pending feedback inside its own bounds. */
|
||||
export function LoadingRegion({ loading, label, children, className, ...props }: LoadingRegionProps) {
|
||||
return (
|
||||
<div {...props} className={cn("nodedc-loading-region", className)} aria-busy={loading}>
|
||||
{children}
|
||||
{loading ? (
|
||||
<div className="nodedc-loading-region__status" role="status" aria-label={label}>
|
||||
<ActivityIndicator />
|
||||
<span aria-hidden="true">{label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface MediaSourceFieldProps {
|
||||
url: string;
|
||||
fileName?: string | null;
|
||||
uploading?: boolean;
|
||||
disabled?: boolean;
|
||||
previewSrc?: string | null;
|
||||
previewKind?: MediaPreviewKind | null;
|
||||
accept?: string;
|
||||
@@ -43,6 +44,7 @@ export function MediaSourceField({
|
||||
url,
|
||||
fileName,
|
||||
uploading = false,
|
||||
disabled = false,
|
||||
previewSrc,
|
||||
previewKind,
|
||||
accept = "image/*,video/*",
|
||||
@@ -73,11 +75,12 @@ export function MediaSourceField({
|
||||
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
|
||||
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
|
||||
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
|
||||
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
|
||||
<input id={inputId} type="file" accept={accept} disabled={disabled || uploading} onChange={handleFileChange} />
|
||||
</div>
|
||||
<input
|
||||
className="nodedc-media-url"
|
||||
type="url"
|
||||
disabled={disabled || uploading}
|
||||
value={url}
|
||||
hidden={source !== "url"}
|
||||
data-nodedc-media-source-panel="url"
|
||||
@@ -89,6 +92,7 @@ export function MediaSourceField({
|
||||
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "file" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="file"
|
||||
@@ -98,6 +102,7 @@ export function MediaSourceField({
|
||||
>HD</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "url" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="url"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface ProgressBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||
label: string;
|
||||
/** Completed fraction from 0 to 1. Omit when the amount is unknown. */
|
||||
value?: number;
|
||||
valueText?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ label, value, valueText, className, style, ...props }: ProgressBarProps) {
|
||||
const fraction = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined;
|
||||
return <div {...props} className={cn("nodedc-progress-bar", className)} role="progressbar"
|
||||
aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
||||
aria-valuenow={fraction === undefined ? undefined : Math.round(fraction * 100)}
|
||||
aria-valuetext={valueText} data-indeterminate={fraction === undefined || undefined}
|
||||
style={{ ...style, "--nodedc-progress-fraction": fraction ?? 0 } as CSSProperties}>
|
||||
<span className="nodedc-progress-bar__fill" />
|
||||
</div>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface ResourceRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: ReactNode;
|
||||
icon?: ReactNode;
|
||||
description?: ReactNode;
|
||||
metadata?: ReactNode;
|
||||
status?: ReactNode;
|
||||
statusPlacement?: "leading" | "trailing";
|
||||
actions?: ReactNode;
|
||||
progress?: Pick<ProgressBarProps, "label" | "value" | "valueText">;
|
||||
}
|
||||
|
||||
/** Compact resource presentation extracted from Mission Core AI Inference.
|
||||
* Actions stay canonical controls; the row itself is not a nested button. */
|
||||
export function ResourceRow({ title, icon, description, metadata, status, statusPlacement = "trailing", actions, progress, className, ...props }: ResourceRowProps) {
|
||||
const leadingStatus = !progress && !!status && statusPlacement === "leading";
|
||||
return <div className={cn("nodedc-resource-row", progress && "nodedc-resource-row--progress", leadingStatus && "nodedc-resource-row--leading-status", className)} {...props}>
|
||||
{icon ? <span className="nodedc-resource-row__icon" aria-hidden="true">{icon}</span> : null}
|
||||
{leadingStatus ? <div className="nodedc-resource-row__status">{status}</div> : null}
|
||||
<div className="nodedc-resource-row__copy">
|
||||
<strong>{title}</strong>
|
||||
{description ? <span>{description}</span> : null}
|
||||
{metadata ? <small>{metadata}</small> : null}
|
||||
</div>
|
||||
{progress ? <ProgressBar {...progress} /> : null}
|
||||
{!progress && status && statusPlacement === "trailing" ? <div className="nodedc-resource-row__status">{status}</div> : null}
|
||||
{actions ? <div className="nodedc-resource-row__actions">{actions}</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ResourceList({ className, children, ...props }: HTMLAttributes<HTMLUListElement>) {
|
||||
return <ul className={cn("nodedc-resource-list", className)} {...props}>{children}</ul>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,11 +6,12 @@ export interface SettingsCardProps extends Omit<HTMLAttributes<HTMLElement>, "ti
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
align?: "start" | "center";
|
||||
}
|
||||
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, className, ...props }: SettingsCardProps) {
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, align = "start", className, ...props }: SettingsCardProps) {
|
||||
return (
|
||||
<section className={cn("nodedc-settings-card", className)} {...props}>
|
||||
<section className={cn("nodedc-settings-card", className)} data-align={align === "center" ? "center" : undefined} {...props}>
|
||||
<header className="nodedc-settings-card__head">
|
||||
<div className="nodedc-settings-card__titles">
|
||||
{eyebrow ? <span>{eyebrow}</span> : null}
|
||||
@@ -19,7 +20,7 @@ export function SettingsCard({ eyebrow, title, description, actions, children, c
|
||||
</div>
|
||||
{actions ? <div className="nodedc-settings-card__actions">{actions}</div> : null}
|
||||
</header>
|
||||
<div className="nodedc-settings-card__body">{children}</div>
|
||||
{align === "center" && (children === undefined || children === null || children === false) ? null : <div className="nodedc-settings-card__body">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,13 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
||||
|
||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: StatusTone;
|
||||
variant?: "badge" | "indicator";
|
||||
}
|
||||
|
||||
export function StatusBadge({ tone = "neutral", className, children, ...props }: StatusBadgeProps) {
|
||||
export function StatusBadge({ tone = "neutral", variant = "badge", className, children, ...props }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
|
||||
{children}
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} {...props}>
|
||||
{variant === "indicator" ? null : children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./ActivityIndicator.js";
|
||||
export * from "./LoadingRegion.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationSidePanel.js";
|
||||
@@ -15,13 +17,21 @@ export * from "./Inspector.js";
|
||||
export * from "./Icon.js";
|
||||
export * from "./MediaSourceField.js";
|
||||
export * from "./RangeControl.js";
|
||||
export * from "./ResourceRow.js";
|
||||
export * from "./SegmentedControl.js";
|
||||
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";
|
||||
export * from "./Window.js";
|
||||
export * from "./WorkspaceWindow.js";
|
||||
|
||||
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
||||
export * from "./EnvironmentSettingsWindow.js";
|
||||
export * from "./EnvironmentMediaPlaylistEditor.js";
|
||||
export * from "./EnvironmentBackgroundMedia.js";
|
||||
export * from "./LandingStage.js";
|
||||
|
||||
+230
-22
@@ -1,6 +1,53 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "progress-bar",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"ProgressBar",
|
||||
"ProgressBarProps"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-progress-bar"
|
||||
],
|
||||
"summary": "Accessible determinate or indeterminate linear progress, including the central ResourceRow slot.",
|
||||
"variants": [
|
||||
"determinate",
|
||||
"indeterminate"
|
||||
],
|
||||
"rules": [
|
||||
"Consumer supplies measured progress; elapsed time must not invent completion.",
|
||||
"ResourceRow progress replaces status and occupies space between copy and actions.",
|
||||
"Reduced motion preserves a visible non-animated indicator."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-row",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"ResourceRow",
|
||||
"ResourceList"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-resource-row",
|
||||
"nodedc-resource-list"
|
||||
],
|
||||
"summary": "Mission Core AI Inference compact rows extracted for shared resource lists.",
|
||||
"behavior": [
|
||||
"wrapping title",
|
||||
"optional metadata and status",
|
||||
"optional leading status between icon and copy, vertically centered",
|
||||
"canonical action controls",
|
||||
"responsive action placement"
|
||||
],
|
||||
"rules": [
|
||||
"The consumer supplies identity and states; the row owns presentation only.",
|
||||
"Use ordinary list items and canonical buttons; do not nest actions inside a clickable row."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "glass-surface",
|
||||
"status": "baseline",
|
||||
@@ -10,8 +57,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.",
|
||||
@@ -22,15 +71,70 @@
|
||||
"id": "button",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["Button", "IconButton"],
|
||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||
"exports": [
|
||||
"Button",
|
||||
"IconButton"
|
||||
],
|
||||
"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"],
|
||||
"shapes": ["default", "pill", "rounded"],
|
||||
"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."
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message.",
|
||||
"loading displays one centered indicator inside the initiating action, disables repeat activation and preserves dimensions and accessible name. Never place its spinner beside or below the action group.",
|
||||
"Primary tone=neutral is white when enabled and gray when disabled in every theme/accent; use it for accent-independent operator actions without local CSS overrides."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 Button.loading or IconButton.loading for actions and LoadingRegion for content. Standalone ActivityIndicator is limited to an explicit inline status or resource-row slot.",
|
||||
"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": "loading-region",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["LoadingRegion", "LoadingRegionProps"],
|
||||
"domContract": ["nodedc-loading-region", "nodedc-loading-region__status"],
|
||||
"summary": "Centered pending feedback within the content region that owns the request, without unmounting content.",
|
||||
"rules": [
|
||||
"The consumer owns loading and its terminal result; no timer or implicit network operation exists in this component.",
|
||||
"Keep content mounted and reserve its dimensions while pending. Center the indicator within this region in normal and expanded layouts.",
|
||||
"A command belongs to its initiating Button.loading; do not duplicate that command in a region spinner.",
|
||||
"Remove loading on first usable content, failure or timeout. A live media connection alone is not usable content.",
|
||||
"Use only for blocking initial content or explicit recovery. Background refresh with usable content does not cover it."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -42,7 +146,8 @@
|
||||
"summary": "Textual form controls with consistent labels, hints, geometry and focus surface.",
|
||||
"rules": [
|
||||
"Labels and validation copy are part of the field contract.",
|
||||
"Keyboard focus changes the surface and remains visible without a browser-blue outline."
|
||||
"Keyboard focus changes the surface and remains visible without a browser-blue outline.",
|
||||
"Browser autofill retains the canonical field material, text and caret; keyboard focus remains visible."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -64,17 +169,20 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"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"],
|
||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "color-field",
|
||||
@@ -113,11 +221,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."
|
||||
]
|
||||
@@ -136,7 +245,9 @@
|
||||
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
||||
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
|
||||
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
|
||||
"A draggable inspector moves by its header and remains clamped to the viewport."
|
||||
"A draggable inspector moves by its header and remains clamped to the viewport.",
|
||||
"Closing a modeless inspector is never gated by autosave; preserve the draft, close immediately and report persistence failure through ToastStack.",
|
||||
"Operational copy inside Window uses component typography tokens; browser-default paragraph or heading sizes are forbidden."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -163,6 +274,23 @@
|
||||
"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.",
|
||||
"Viewport-local toolbars belong to their SplitPane panel, and embedded renderers receive the same controlled percentage instead of exposing cursor or pointer-position heuristics."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "share-access-modal",
|
||||
"status": "baseline",
|
||||
@@ -198,7 +326,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",
|
||||
@@ -210,6 +342,7 @@
|
||||
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
|
||||
"rules": [
|
||||
"The logo occupies the same visual position and geometry across applications.",
|
||||
"Light single-color assets may opt into brandMonochrome / monochrome for theme contrast; full-color images are unchanged by default.",
|
||||
"Product routes and profile content are application data passed into stable slots.",
|
||||
"The canonical application header is fixed and preserves the Launcher three-axis logo / navigation / profile positions.",
|
||||
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
|
||||
@@ -328,12 +461,28 @@
|
||||
"id": "media-source-field",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["MediaSourceField"],
|
||||
"exports": [
|
||||
"MediaSourceField"
|
||||
],
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createMediaSourceController"],
|
||||
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"],
|
||||
"domExports": [
|
||||
"createMediaSourceController"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-media-field",
|
||||
"nodedc-media-control",
|
||||
"nodedc-media-source-button",
|
||||
"nodedc-media-preview"
|
||||
],
|
||||
"summary": "Launcher/CMS media control for video, image or logo file/URL sources, filename, preview and adapter-provided storage state.",
|
||||
"behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
|
||||
"behavior": [
|
||||
"controlled file/url source",
|
||||
"native accessible file input",
|
||||
"URL input",
|
||||
"image/video preview",
|
||||
"path, hint and error slots",
|
||||
"controlled disabled state locks file, URL and source switching during a parent save/upload"
|
||||
],
|
||||
"rules": [
|
||||
"The component owns geometry, source switching and accessibility.",
|
||||
"The consumer owns media library, storage upload, validation and the persisted URL.",
|
||||
@@ -350,6 +499,8 @@
|
||||
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
||||
"rules": [
|
||||
"The card owns grouping geometry but not product form schemas.",
|
||||
"Use align=center for compact result or empty messages: center the content on both axes and omit an empty body. The default start layout remains unchanged.",
|
||||
"Section and empty-state titles use the compact md token; descriptions use sm. Page/window title sizes and browser heading defaults are forbidden inside cards.",
|
||||
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
|
||||
]
|
||||
},
|
||||
@@ -358,6 +509,7 @@
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["StatusBadge"],
|
||||
"variants": ["badge", "indicator"],
|
||||
"domContract": ["nodedc-status"],
|
||||
"summary": "Semantic status pill whose tone is independent from the application accent."
|
||||
},
|
||||
@@ -369,9 +521,11 @@
|
||||
"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.",
|
||||
"Errors and status copy use the compact Toast typography; consumers do not substitute browser-default paragraphs or title-sized text.",
|
||||
"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."
|
||||
]
|
||||
@@ -409,6 +563,60 @@
|
||||
"The panel variant reuses AdminNavigationPanel pill, icon and theme tokens while retaining the same controlled accordion state and domain-owned content.",
|
||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "environment-settings",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"EnvironmentSettingsWindow",
|
||||
"EnvironmentMediaPlaylistEditor"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-environment-settings",
|
||||
"nodedc-environment-media-playlist"
|
||||
],
|
||||
"summary": "Shared avatar Settings → Environment composition for product/home copy, quick actions and image/video playlists.",
|
||||
"behavior": [
|
||||
"product-configured page list",
|
||||
"draft/reset/save",
|
||||
"upload adapter",
|
||||
"ordered media playlist",
|
||||
"image timing",
|
||||
"parent save/upload disabled state"
|
||||
],
|
||||
"rules": [
|
||||
"Reuse FeatureSettingsWindow and existing form primitives; never fork an application-specific settings form.",
|
||||
"Consumers own page/action IDs, storage, revision conflicts, authentication and upload URLs.",
|
||||
"A single-home application passes one surface; multi-section applications pass all admitted pages.",
|
||||
"Closing the window preserves host authority and never sends device commands."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "landing-stage",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"LandingStage",
|
||||
"EnvironmentBackgroundMedia"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-landing-stage",
|
||||
"nodedc-landing-stage__media"
|
||||
],
|
||||
"summary": "Shared product landing stage with editable copy, quick actions and ordered image/video background.",
|
||||
"behavior": [
|
||||
"image timer",
|
||||
"video end advance",
|
||||
"skip failed media",
|
||||
"flat empty background",
|
||||
"product status/footer slots"
|
||||
],
|
||||
"rules": [
|
||||
"No decorative radial gradient or invented graphic appears without configured media.",
|
||||
"Applications supply content and actions, not duplicate stage markup.",
|
||||
"Background playback and scene navigation never own device acquisition."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+5
-3
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
"documentation": {
|
||||
"architecture": "../docs/ARCHITECTURE.md",
|
||||
"components": "../docs/COMPONENTS.md",
|
||||
"loading": "../docs/LOADING_STATES.md",
|
||||
"theming": "../docs/THEMING.md",
|
||||
"operationalTypography": "../docs/OPERATIONAL_TYPOGRAPHY.md",
|
||||
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
||||
"baseline": "../docs/SOURCE_BASELINE.md",
|
||||
"governance": "../docs/GOVERNANCE.md",
|
||||
@@ -53,7 +55,8 @@
|
||||
"moduleFoundry": "../docs/MODULE_STUDIO.md",
|
||||
"mapTemplate": "../docs/MAP_TEMPLATE.md",
|
||||
"icons": "../docs/ICONS.md",
|
||||
"consumption": "../docs/CONSUMPTION.md"
|
||||
"consumption": "../docs/CONSUMPTION.md",
|
||||
"environmentSettings": "../docs/ENVIRONMENT_SETTINGS.md"
|
||||
},
|
||||
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
|
||||
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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, Button, IconButton, LoadingRegion } 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, /<Button loading>/);
|
||||
assert.match(catalog, /<LoadingRegion loading/);
|
||||
});
|
||||
|
||||
|
||||
test("pending actions preserve their name and content while disabling only the pending control", () => {
|
||||
for (const [Component, props] of [[Button, {}], [IconButton, {label: "Обновить"}]]) {
|
||||
const busy = renderToStaticMarkup(createElement(Component, {...props, loading: true, disabled: false}, "Обновить"));
|
||||
const idle = renderToStaticMarkup(createElement(Component, props, "Обновить"));
|
||||
assert.match(busy, /disabled=""/);
|
||||
assert.match(busy, /aria-busy="true"/);
|
||||
assert.match(busy, /Обновить/);
|
||||
assert.equal((busy.match(/class="nodedc-activity-indicator"/g) || []).length, 1);
|
||||
assert.doesNotMatch(idle, /nodedc-action-loading|disabled=""/);
|
||||
}
|
||||
});
|
||||
|
||||
test("content remains mounted throughout loading and the status disappears on completion", () => {
|
||||
const view = createElement("video", {"data-source": "synthetic"});
|
||||
const pending = renderToStaticMarkup(createElement(LoadingRegion, {loading:true, label:"Ожидаем изображение"}, view));
|
||||
const ready = renderToStaticMarkup(createElement(LoadingRegion, {loading:false, label:"Ожидаем изображение"}, view));
|
||||
assert.match(pending, /<video data-source="synthetic"/);
|
||||
assert.match(ready, /<video data-source="synthetic"/);
|
||||
assert.equal((pending.match(/role="status"/g) || []).length, 1);
|
||||
assert.doesNotMatch(ready, /role="status"|nodedc-activity-indicator/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { appendEnvironmentMediaItem, removeEnvironmentMediaItem, cloneEnvironmentSettings } from "../packages/ui-core/dist/index.js";
|
||||
import { LandingStage, Button, MediaSourceField } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
const image = { id: "media-first", source: "url", url: "https://example.invalid/background.png", mediaKind: "image", fileName: null };
|
||||
const video = { ...image, id: "media-second", url: "https://example.invalid/background.mp4", mediaKind: "video" };
|
||||
const page = { headerLabel: "Product", eyebrow: "Board", title: "Home", description: "Local operator home", primaryWorkspaceId: null, secondaryWorkspaceId: null, background: { enabled: false, imageDurationSeconds: 10, items: [] } };
|
||||
|
||||
test("media edits preserve playback order and leave saved settings untouched", () => {
|
||||
const settings = { revision: 9, pages: { home: { ...page, background: appendEnvironmentMediaItem(page.background, image) } } };
|
||||
const draft = cloneEnvironmentSettings(settings);
|
||||
draft.pages.home.background = appendEnvironmentMediaItem(draft.pages.home.background, video);
|
||||
draft.pages.home.background.items[0].url = "https://example.invalid/edited.png";
|
||||
assert.equal(settings.pages.home.background.items[0].url, image.url);
|
||||
assert.deepEqual(draft.pages.home.background.items.map(item => item.id), [image.id, video.id]);
|
||||
const removed = removeEnvironmentMediaItem(draft.pages.home.background, image.id);
|
||||
assert.deepEqual(removed.items, [video]);
|
||||
assert.equal(removeEnvironmentMediaItem(removed, video.id).enabled, false);
|
||||
assert.equal(appendEnvironmentMediaItem(removed, video), removed);
|
||||
});
|
||||
|
||||
test("shared home shows real configured media, and an empty home creates no media", () => {
|
||||
const empty = renderToStaticMarkup(createElement(LandingStage, { page }));
|
||||
assert.match(empty, /<h1>Home<\/h1>/);
|
||||
assert.doesNotMatch(empty, /<(?:video|img)\b/);
|
||||
const configured = { ...page, background: { ...page.background, enabled: true, items: [video] } };
|
||||
const html = renderToStaticMarkup(createElement(LandingStage, { page: configured }));
|
||||
assert.match(html, /<video[^>]+src="https:\/\/example.invalid\/background.mp4"/);
|
||||
assert.match(html, /loop=""/);
|
||||
assert.match(html, /muted=""/);
|
||||
});
|
||||
|
||||
test("neutral primary retains native disabled semantics and theme default remains opt-in", () => {
|
||||
const disabled = renderToStaticMarkup(createElement(Button, { variant: "primary", tone: "neutral", disabled: true }, "Start"));
|
||||
assert.match(disabled, /data-tone="neutral"/);
|
||||
assert.match(disabled, /disabled=""/);
|
||||
const themed = renderToStaticMarkup(createElement(Button, { variant: "primary" }, "Start"));
|
||||
assert.doesNotMatch(themed, /data-tone/);
|
||||
const field = renderToStaticMarkup(createElement(MediaSourceField, { source: "url", url: "https://example.invalid/a.png", disabled: true, onSourceChange() {}, onUrlChange() {}, onFileChange() {} }));
|
||||
assert.match(field, /<input[^>]*disabled=""/);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {createElement} from "react";
|
||||
import {renderToStaticMarkup} from "react-dom/server";
|
||||
import {ProgressBar,ResourceRow} from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("progress exposes bounded measured values and unknown state without false percentages",()=>{
|
||||
for(const [value, expected] of [[-1,0],[.5,50],[2,100]]) {
|
||||
const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Этапы",value}));
|
||||
assert.match(html,new RegExp(`aria-valuenow="${expected}"`));
|
||||
}
|
||||
for(const value of [undefined,NaN,Infinity]) {
|
||||
const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Ожидание",value}));
|
||||
assert.doesNotMatch(html,/aria-valuenow/);assert.match(html,/data-indeterminate="true"/);
|
||||
}
|
||||
});
|
||||
test("row keeps progress between name and actions and suppresses duplicate status",()=>{
|
||||
const html=renderToStaticMarkup(createElement(ResourceRow,{title:"Камера",status:"Old status",actions:"Actions",progress:{label:"Загрузка",value:.2}}));
|
||||
assert.ok(html.indexOf("Камера")<html.indexOf('role="progressbar"'));
|
||||
assert.ok(html.indexOf('role="progressbar"')<html.indexOf("Actions"));
|
||||
assert.doesNotMatch(html,/Old status/);
|
||||
});
|
||||
@@ -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\(\)/);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user