Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
|
|||||||
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
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 { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
StatusBadge,
|
||||||
|
ProgressBar,
|
||||||
AdminNavigationPanel,
|
AdminNavigationPanel,
|
||||||
AppHeader,
|
AppHeader,
|
||||||
ApplicationPanel,
|
ApplicationPanel,
|
||||||
@@ -28,10 +31,13 @@ import {
|
|||||||
InspectorSelectField,
|
InspectorSelectField,
|
||||||
MediaSourceField,
|
MediaSourceField,
|
||||||
RangeControl,
|
RangeControl,
|
||||||
|
ResourceRow,
|
||||||
|
ResourceList,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
ShareAccessModal,
|
ShareAccessModal,
|
||||||
ShareLinkModal,
|
ShareLinkModal,
|
||||||
|
SplitPane,
|
||||||
SortableItem,
|
SortableItem,
|
||||||
SortableScope,
|
SortableScope,
|
||||||
SettingsCard,
|
SettingsCard,
|
||||||
@@ -208,7 +214,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Состояние и доступ",
|
title: "Состояние и доступ",
|
||||||
note: "Вся платформа",
|
note: "Вся платформа",
|
||||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Сущности",
|
title: "Сущности",
|
||||||
@@ -218,7 +224,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Контент",
|
title: "Контент",
|
||||||
note: "SEO / BIM / Engine",
|
note: "SEO / BIM / Engine",
|
||||||
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
|
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -227,6 +233,7 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
alert: "Предупреждение",
|
alert: "Предупреждение",
|
||||||
apps: "Приложения",
|
apps: "Приложения",
|
||||||
building: "Компания",
|
building: "Компания",
|
||||||
|
camera: "Камера",
|
||||||
check: "Готово",
|
check: "Готово",
|
||||||
"chevron-down": "Раскрыть",
|
"chevron-down": "Раскрыть",
|
||||||
"chevron-left": "Назад",
|
"chevron-left": "Назад",
|
||||||
@@ -238,6 +245,8 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
database: "База данных",
|
database: "База данных",
|
||||||
download: "Скачать",
|
download: "Скачать",
|
||||||
edit: "Редактировать",
|
edit: "Редактировать",
|
||||||
|
eye: "Показать",
|
||||||
|
"eye-off": "Скрыть",
|
||||||
expand: "Развернуть",
|
expand: "Развернуть",
|
||||||
external: "Открыть снаружи",
|
external: "Открыть снаружи",
|
||||||
file: "Файл",
|
file: "Файл",
|
||||||
@@ -253,6 +262,8 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
minimize: "Свернуть",
|
minimize: "Свернуть",
|
||||||
network: "Связи",
|
network: "Связи",
|
||||||
panel: "Панель",
|
panel: "Панель",
|
||||||
|
plan: "План",
|
||||||
|
play: "Воспроизвести",
|
||||||
target: "Таргеты",
|
target: "Таргеты",
|
||||||
plus: "Добавить",
|
plus: "Добавить",
|
||||||
profile: "Профиль",
|
profile: "Профиль",
|
||||||
@@ -262,6 +273,7 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
shield: "Доступ",
|
shield: "Доступ",
|
||||||
sliders: "Параметры",
|
sliders: "Параметры",
|
||||||
|
stop: "Остановить",
|
||||||
trash: "Удалить",
|
trash: "Удалить",
|
||||||
upload: "Загрузить",
|
upload: "Загрузить",
|
||||||
users: "Участники",
|
users: "Участники",
|
||||||
@@ -359,6 +371,7 @@ export function CatalogApp() {
|
|||||||
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||||
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||||
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||||
|
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
|
||||||
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
@@ -1278,7 +1291,7 @@ export function CatalogApp() {
|
|||||||
<>
|
<>
|
||||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
<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 +1337,7 @@ export function CatalogApp() {
|
|||||||
case "controls":
|
case "controls":
|
||||||
return (
|
return (
|
||||||
<div className="catalog-grid">
|
<div className="catalog-grid">
|
||||||
<Preview title="Selection" note="integrated / split">
|
<Preview title="Selection" note="integrated / split / inline">
|
||||||
<div className="catalog-form">
|
<div className="catalog-form">
|
||||||
<FieldFrame label="Hub / integrated select">
|
<FieldFrame label="Hub / integrated select">
|
||||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||||
@@ -1342,6 +1355,19 @@ export function CatalogApp() {
|
|||||||
onChange={setConnectionType}
|
onChange={setConnectionType}
|
||||||
/>
|
/>
|
||||||
</FieldFrame>
|
</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
|
<Dropdown
|
||||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||||||
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
||||||
@@ -1372,6 +1398,34 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
|
||||||
|
<div className="catalog-inline catalog-inline--wrap">
|
||||||
|
<span className="catalog-inline">
|
||||||
|
<ActivityIndicator label="Загружаем данные" />
|
||||||
|
<span>Загружаем данные</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
aria-busy="true"
|
||||||
|
disabled
|
||||||
|
icon={<ActivityIndicator size="compact" />}
|
||||||
|
>Подключаем…</Button>
|
||||||
|
</div>
|
||||||
|
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||||
|
</Preview>
|
||||||
|
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><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="Подключите устройство, чтобы оно появилось в списке." />
|
||||||
|
</Preview>
|
||||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||||
<div className="catalog-window-actions-demo">
|
<div className="catalog-window-actions-demo">
|
||||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||||
@@ -1380,6 +1434,21 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</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">
|
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
|
||||||
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
||||||
{workspaceWindowDemoOpen ? (
|
{workspaceWindowDemoOpen ? (
|
||||||
@@ -1418,6 +1487,20 @@ export function CatalogApp() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
||||||
</Preview>
|
</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">
|
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
|
||||||
<div className="catalog-side-panel-demo">
|
<div className="catalog-side-panel-demo">
|
||||||
{sidePanelDemoOpen ? (
|
{sidePanelDemoOpen ? (
|
||||||
@@ -1621,6 +1704,8 @@ export function CatalogApp() {
|
|||||||
<div className="catalog-icon-catalog">
|
<div className="catalog-icon-catalog">
|
||||||
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
||||||
<div className="catalog-window-actions-demo">
|
<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="plus" /></IconButton>
|
||||||
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
|
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
|
||||||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||||||
@@ -1925,6 +2010,7 @@ export function CatalogApp() {
|
|||||||
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
||||||
header={
|
header={
|
||||||
<AppHeader
|
<AppHeader
|
||||||
|
brandMonochrome
|
||||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||||
brandHref="/"
|
brandHref="/"
|
||||||
center={
|
center={
|
||||||
|
|||||||
@@ -1678,6 +1678,33 @@ textarea {
|
|||||||
transform: translate(-50%, -50%);
|
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 {
|
.catalog-side-panel-preview .catalog-preview__body {
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
}
|
}
|
||||||
@@ -1754,6 +1781,14 @@ textarea {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.catalog-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -44,3 +44,7 @@ Modal остаётся portal-слоем. Его тело прокручивае
|
|||||||
- предметное содержимое content window.
|
- предметное содержимое content window.
|
||||||
|
|
||||||
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
||||||
|
|
||||||
|
На узком экране скрывается именно действие `data-action="expand"`.
|
||||||
|
Порядок utility actions не является признаком типа действия: плюс и обновление
|
||||||
|
остаются доступны, даже когда стоят первыми в шапке. Исправлено при QA Node 0.3.
|
||||||
|
|||||||
+53
-4
@@ -10,6 +10,8 @@
|
|||||||
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
||||||
- `soft` — вложенная или вторичная область.
|
- `soft` — вложенная или вторичная область.
|
||||||
|
|
||||||
|
`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS.
|
||||||
|
|
||||||
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
|
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 материала вручную.
|
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
|
||||||
@@ -25,6 +27,16 @@ Button используется для всех текстовых действ
|
|||||||
|
|
||||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||||
|
|
||||||
|
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
||||||
|
|
||||||
|
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||||
|
|
||||||
|
## ActivityIndicator
|
||||||
|
|
||||||
|
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||||
|
|
||||||
|
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||||
|
|
||||||
## Field
|
## Field
|
||||||
|
|
||||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||||
@@ -39,7 +51,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
|||||||
|
|
||||||
## RangeControl
|
## 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
|
## ColorField
|
||||||
|
|
||||||
@@ -69,10 +83,11 @@ Dropdown владеет floating-layer поведением:
|
|||||||
|
|
||||||
## Select
|
## Select
|
||||||
|
|
||||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
|
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы:
|
||||||
|
|
||||||
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
- `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`.
|
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
||||||
|
|
||||||
@@ -89,6 +104,8 @@ Window — единая механика открытия modal и правой
|
|||||||
|
|
||||||
Содержание, сохранение и запросы принадлежат приложению.
|
Содержание, сохранение и запросы принадлежат приложению.
|
||||||
|
|
||||||
|
Modeless Inspector закрывается независимо от результата автосохранения. Приложение сначала закрывает controlled `Window`, затем завершает сохранение асинхронно; сбой сообщает через компактный `ToastStack` с tone `error`, сохраняя draft текущего просмотра. Нельзя удерживать окно открытым, выводить внутри него browser-default `p`/heading или увеличивать текст ошибки до title-размера.
|
||||||
|
|
||||||
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
||||||
|
|
||||||
## WorkspaceWindow
|
## WorkspaceWindow
|
||||||
@@ -139,6 +156,16 @@ Enter при отсутствии совпадения разрешает оди
|
|||||||
|
|
||||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
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
|
## ShareAccessModal
|
||||||
|
|
||||||
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
||||||
@@ -153,6 +180,8 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
|||||||
|
|
||||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||||
|
|
||||||
|
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
||||||
|
|
||||||
## AppHeader
|
## AppHeader
|
||||||
|
|
||||||
Трёхосевая верхняя панель:
|
Трёхосевая верхняя панель:
|
||||||
@@ -265,7 +294,7 @@ Sortable-строки ограничены вертикальной осью: г
|
|||||||
|
|
||||||
## ToastStack
|
## 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
|
## Environment Controls: Inspector и ControlRow
|
||||||
|
|
||||||
@@ -287,3 +316,23 @@ 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 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 нет.
|
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`; дополнительный текст и значки скрываются.
|
||||||
|
|||||||
+6
-1
@@ -29,6 +29,12 @@
|
|||||||
|
|
||||||
Допустимы density variants, если они имеют устойчивое назначение (`default`, `compact`) и тестируются как часть API.
|
Допустимы 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
|
## Deprecated
|
||||||
|
|
||||||
Перед удалением export:
|
Перед удалением export:
|
||||||
@@ -53,4 +59,3 @@
|
|||||||
## Владение
|
## Владение
|
||||||
|
|
||||||
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -19,12 +19,16 @@
|
|||||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
||||||
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
||||||
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
| Редактирование | `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` |
|
| Сущности | `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`.
|
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
|
||||||
|
|
||||||
## Правило расширения
|
## Правило расширения
|
||||||
|
|
||||||
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
||||||
|
|
||||||
|
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
|
||||||
|
|
||||||
|
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
|
||||||
|
|||||||
@@ -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.
|
||||||
+8
-2
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"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": "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",
|
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:control-density && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
@@ -31,9 +31,15 @@
|
|||||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
"test:map-subject-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-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: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:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-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": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
--nodedc-control-height: 2.875rem;
|
--nodedc-control-height: 2.875rem;
|
||||||
--nodedc-control-height-compact: 2.375rem;
|
--nodedc-control-height-compact: 2.375rem;
|
||||||
|
--nodedc-control-height-dense: 1.875rem;
|
||||||
--nodedc-icon-button-size: 2.875rem;
|
--nodedc-icon-button-size: 2.875rem;
|
||||||
--nodedc-header-row-height: 3rem;
|
--nodedc-header-row-height: 3rem;
|
||||||
--nodedc-header-pill-height: 3.45rem;
|
--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-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--nodedc-font-size-xs: 0.6875rem;
|
--nodedc-font-size-xs: 0.6875rem;
|
||||||
|
--nodedc-font-size-dense: 0.45rem;
|
||||||
--nodedc-font-size-sm: 0.75rem;
|
--nodedc-font-size-sm: 0.75rem;
|
||||||
--nodedc-font-size-md: 0.8125rem;
|
--nodedc-font-size-md: 0.8125rem;
|
||||||
--nodedc-font-size-lg: 1rem;
|
--nodedc-font-size-lg: 1rem;
|
||||||
|
|||||||
+222
-6
@@ -15,13 +15,13 @@
|
|||||||
box-sizing: border-box;
|
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;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
font: inherit;
|
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);
|
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);
|
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);
|
border-radius: var(--nodedc-radius-modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-glass[data-radius="pill"] {
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-glass[data-padding="sm"] {
|
.nodedc-glass[data-padding="sm"] {
|
||||||
padding: var(--nodedc-space-3);
|
padding: var(--nodedc-space-3);
|
||||||
}
|
}
|
||||||
@@ -138,6 +142,13 @@
|
|||||||
font-size: var(--nodedc-font-size-sm);
|
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"] {
|
.nodedc-button[data-width="full"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -211,6 +222,30 @@
|
|||||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
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 {
|
.nodedc-icon-button {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: var(--nodedc-icon-button-size);
|
width: var(--nodedc-icon-button-size);
|
||||||
@@ -231,6 +266,11 @@
|
|||||||
color: var(--nodedc-text-primary);
|
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) {
|
.nodedc-icon-button:active:not(:disabled) {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
@@ -545,13 +585,13 @@ textarea.nodedc-field__control {
|
|||||||
|
|
||||||
.nodedc-settings-card__titles h2 {
|
.nodedc-settings-card__titles h2 {
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: 1rem;
|
font-size: var(--nodedc-font-size-md);
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-settings-card__titles p {
|
.nodedc-settings-card__titles p {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: var(--nodedc-font-size-sm);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +809,10 @@ textarea.nodedc-field__control {
|
|||||||
caret-color: currentColor;
|
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__value,
|
||||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1002,6 +1046,19 @@ textarea.nodedc-field__control {
|
|||||||
color: var(--nodedc-glass-control-active-text);
|
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 {
|
.nodedc-header-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--nodedc-layer-header);
|
z-index: var(--nodedc-layer-header);
|
||||||
@@ -2182,6 +2239,33 @@ textarea.nodedc-field__control {
|
|||||||
transform: translateY(2px) rotate(225deg);
|
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 {
|
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
|
||||||
transform: translateY(2px) rotate(225deg);
|
transform: translateY(2px) rotate(225deg);
|
||||||
}
|
}
|
||||||
@@ -2805,6 +2889,93 @@ textarea.nodedc-field__control {
|
|||||||
color: var(--nodedc-text-primary);
|
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 {
|
.nodedc-toolbar-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||||
@@ -3398,6 +3569,7 @@ textarea.nodedc-field__control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-empty-state {
|
.nodedc-empty-state {
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -3409,7 +3581,7 @@ textarea.nodedc-field__control {
|
|||||||
|
|
||||||
.nodedc-empty-state strong {
|
.nodedc-empty-state strong {
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: var(--nodedc-font-size-lg);
|
font-size: var(--nodedc-font-size-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -3509,7 +3681,7 @@ textarea.nodedc-field__control {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-application-panel__action:first-child {
|
.nodedc-application-panel__action[data-action="expand"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3585,6 +3757,7 @@ textarea.nodedc-field__control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nodedc-activity-indicator,
|
||||||
.nodedc-dropdown-surface,
|
.nodedc-dropdown-surface,
|
||||||
.nodedc-overlay,
|
.nodedc-overlay,
|
||||||
.nodedc-window,
|
.nodedc-window,
|
||||||
@@ -3605,3 +3778,46 @@ textarea.nodedc-field__control {
|
|||||||
transition: none;
|
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)); }
|
||||||
|
|||||||
@@ -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;
|
brand: ReactNode;
|
||||||
brandHref?: string;
|
brandHref?: string;
|
||||||
brandLabel?: string;
|
brandLabel?: string;
|
||||||
|
/** Opt in only for a light, single-color mark, never a full-color image. */
|
||||||
|
brandMonochrome?: boolean;
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
center?: ReactNode;
|
center?: ReactNode;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
@@ -13,14 +15,15 @@ export function AppHeader({
|
|||||||
brand,
|
brand,
|
||||||
brandHref,
|
brandHref,
|
||||||
brandLabel = "NODE.DC",
|
brandLabel = "NODE.DC",
|
||||||
|
brandMonochrome = false,
|
||||||
left,
|
left,
|
||||||
center,
|
center,
|
||||||
right,
|
right,
|
||||||
}: AppHeaderProps) {
|
}: AppHeaderProps) {
|
||||||
const brandNode = brandHref ? (
|
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 (
|
return (
|
||||||
@@ -100,11 +103,12 @@ export interface HeaderWorkspaceProps {
|
|||||||
label: string;
|
label: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
kind?: "mark" | "avatar";
|
kind?: "mark" | "avatar";
|
||||||
|
monochrome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
|
export function HeaderWorkspace({ label, imageUrl, kind = "mark", monochrome = false }: HeaderWorkspaceProps) {
|
||||||
return (
|
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()}
|
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
|||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
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 type ButtonShape = "default" | "pill" | "rounded";
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
|
|||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export type GlassTone = "default" | "strong" | "soft";
|
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 type GlassPadding = "none" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -24,11 +27,13 @@ import {
|
|||||||
LocateFixed,
|
LocateFixed,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MailPlus,
|
MailPlus,
|
||||||
|
Map,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Network,
|
Network,
|
||||||
PanelTop,
|
PanelTop,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
@@ -51,6 +57,7 @@ const icons = {
|
|||||||
alert: AlertTriangle,
|
alert: AlertTriangle,
|
||||||
apps: Boxes,
|
apps: Boxes,
|
||||||
building: Building2,
|
building: Building2,
|
||||||
|
camera: Camera,
|
||||||
check: Check,
|
check: Check,
|
||||||
"chevron-down": ChevronDown,
|
"chevron-down": ChevronDown,
|
||||||
"chevron-left": ChevronLeft,
|
"chevron-left": ChevronLeft,
|
||||||
@@ -62,6 +69,8 @@ const icons = {
|
|||||||
database: Database,
|
database: Database,
|
||||||
download: Download,
|
download: Download,
|
||||||
edit: Pencil,
|
edit: Pencil,
|
||||||
|
eye: Eye,
|
||||||
|
"eye-off": EyeOff,
|
||||||
expand: Maximize2,
|
expand: Maximize2,
|
||||||
external: ExternalLink,
|
external: ExternalLink,
|
||||||
file: FileText,
|
file: FileText,
|
||||||
@@ -78,6 +87,8 @@ const icons = {
|
|||||||
minimize: Minimize2,
|
minimize: Minimize2,
|
||||||
network: Network,
|
network: Network,
|
||||||
panel: PanelTop,
|
panel: PanelTop,
|
||||||
|
plan: Map,
|
||||||
|
play: Play,
|
||||||
plus: Plus,
|
plus: Plus,
|
||||||
profile: UserCircle,
|
profile: UserCircle,
|
||||||
refresh: RefreshCw,
|
refresh: RefreshCw,
|
||||||
@@ -86,6 +97,7 @@ const icons = {
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
shield: ShieldCheck,
|
shield: ShieldCheck,
|
||||||
sliders: SlidersHorizontal,
|
sliders: SlidersHorizontal,
|
||||||
|
stop: Square,
|
||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
upload: UploadCloud,
|
upload: UploadCloud,
|
||||||
users: UsersRound,
|
users: UsersRound,
|
||||||
@@ -99,13 +111,15 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
|
|||||||
label?: string;
|
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 IconComponent = icons[name];
|
||||||
|
const filledTransport = name === "play" || name === "stop";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
size={size}
|
size={size}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={filledTransport ? 0 : strokeWidth}
|
||||||
|
fill={fill ?? (filledTransport ? "currentColor" : "none")}
|
||||||
aria-hidden={label ? undefined : "true"}
|
aria-hidden={label ? undefined : "true"}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
role={label ? "img" : undefined}
|
role={label ? "img" : undefined}
|
||||||
|
|||||||
@@ -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 { cn } from "./cn.js";
|
||||||
import { Dropdown } from "./Dropdown.js";
|
import { Dropdown } from "./Dropdown.js";
|
||||||
|
|
||||||
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
|||||||
value: number;
|
value: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
exactValueBounds?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
formatValue?: (value: number) => string;
|
formatValue?: (value: number) => string;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}
|
}
|
||||||
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
|
|||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
step: RangeControlProps["step"],
|
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;
|
if (step === "any") return clamped;
|
||||||
const numericStep = step === undefined ? 1 : Number(step);
|
const numericStep = step === undefined ? 1 : Number(step);
|
||||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||||
const aligned = min + Math.round((clamped - min) / numericStep) * 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) => (
|
const editableNumber = (value: number) => (
|
||||||
@@ -48,6 +55,7 @@ export function RangeControl({
|
|||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
|
exactValueBounds,
|
||||||
step,
|
step,
|
||||||
formatValue = String,
|
formatValue = String,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -56,11 +64,13 @@ export function RangeControl({
|
|||||||
tabIndex,
|
tabIndex,
|
||||||
...props
|
...props
|
||||||
}: RangeControlProps) {
|
}: RangeControlProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const rangeRef = useRef<HTMLInputElement>(null);
|
const rangeRef = useRef<HTMLInputElement>(null);
|
||||||
const editorRef = useRef<HTMLInputElement>(null);
|
const editorRef = useRef<HTMLInputElement>(null);
|
||||||
const cancelNextBlurRef = useRef(false);
|
const cancelNextBlurRef = useRef(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||||
|
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
|
||||||
const safeMax = max === min ? min + 1 : max;
|
const safeMax = max === min ? min + 1 : max;
|
||||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||||
const displayValue = formatValue(value);
|
const displayValue = formatValue(value);
|
||||||
@@ -77,10 +87,30 @@ export function RangeControl({
|
|||||||
if (!editing) setDraft(editableNumber(value));
|
if (!editing) setDraft(editableNumber(value));
|
||||||
}, [editing, 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) => {
|
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||||
if (commit) {
|
if (commit) {
|
||||||
const parsed = Number(draft.trim().replace(",", "."));
|
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);
|
setEditing(false);
|
||||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||||
@@ -103,7 +133,7 @@ export function RangeControl({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
ref={rangeRef}
|
ref={rangeRef}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -129,22 +159,14 @@ export function RangeControl({
|
|||||||
className="nodedc-range__editor"
|
className="nodedc-range__editor"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={editing ? draft : editableNumber(value)}
|
value={draft}
|
||||||
aria-label={`${label}: точное значение`}
|
aria-label={`${label}: точное значение`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-active={editing || undefined}
|
data-active={editing || undefined}
|
||||||
onPointerDown={() => {
|
data-contrast={editorContrast}
|
||||||
if (!editing) {
|
|
||||||
setDraft(editableNumber(value));
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!editing) {
|
if (!editing) setEditing(true);
|
||||||
setDraft(editableNumber(value));
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={handleEditorKeyDown}
|
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;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SegmentedControlSize = "default" | "dense";
|
||||||
|
|
||||||
export interface SegmentedControlProps<T extends string> {
|
export interface SegmentedControlProps<T extends string> {
|
||||||
value: T;
|
value: T;
|
||||||
items: Array<SegmentedItem<T>>;
|
items: Array<SegmentedItem<T>>;
|
||||||
label: string;
|
label: string;
|
||||||
|
size?: SegmentedControlSize;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
}
|
}
|
||||||
@@ -20,11 +23,17 @@ export function SegmentedControl<T extends string>({
|
|||||||
value,
|
value,
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
|
size = "default",
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
return (
|
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) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -43,4 +52,3 @@ export function SegmentedControl<T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectVariant = "integrated" | "split";
|
export type SelectVariant = "integrated" | "split" | "inline";
|
||||||
|
|
||||||
export interface SelectProps<T extends string> {
|
export interface SelectProps<T extends string> {
|
||||||
value: T;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={setTriggerRef}
|
ref={setTriggerRef}
|
||||||
|
|||||||
@@ -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> {
|
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
tone?: StatusTone;
|
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 (
|
return (
|
||||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
|
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} {...props}>
|
||||||
{children}
|
{variant === "indicator" ? null : children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type HTMLAttributes } from "react";
|
import { useEffect, useRef, type HTMLAttributes } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
import { Icon, type IconName } from "./Icon.js";
|
import { Icon, type IconName } from "./Icon.js";
|
||||||
@@ -21,6 +21,8 @@ const toastIcons: Record<ToastTone, IconName> = {
|
|||||||
loading: "refresh",
|
loading: "refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TOAST_DURATION_MS = 10_000;
|
||||||
|
|
||||||
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
item: ToastItem;
|
item: ToastItem;
|
||||||
onDismiss?: (id: string) => void;
|
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 }) {
|
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
|
||||||
useEffect(() => {
|
const dismissRef = useRef(onDismiss);
|
||||||
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]);
|
|
||||||
|
|
||||||
|
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;
|
if (typeof document === "undefined" || items.length === 0) return null;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
|
<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>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./AppHeader.js";
|
export * from "./AppHeader.js";
|
||||||
|
export * from "./ActivityIndicator.js";
|
||||||
export * from "./AdminNavigationPanel.js";
|
export * from "./AdminNavigationPanel.js";
|
||||||
export * from "./ApplicationShell.js";
|
export * from "./ApplicationShell.js";
|
||||||
export * from "./ApplicationSidePanel.js";
|
export * from "./ApplicationSidePanel.js";
|
||||||
@@ -15,13 +16,17 @@ export * from "./Inspector.js";
|
|||||||
export * from "./Icon.js";
|
export * from "./Icon.js";
|
||||||
export * from "./MediaSourceField.js";
|
export * from "./MediaSourceField.js";
|
||||||
export * from "./RangeControl.js";
|
export * from "./RangeControl.js";
|
||||||
|
export * from "./ResourceRow.js";
|
||||||
export * from "./SegmentedControl.js";
|
export * from "./SegmentedControl.js";
|
||||||
export * from "./Select.js";
|
export * from "./Select.js";
|
||||||
export * from "./StatusBadge.js";
|
export * from "./StatusBadge.js";
|
||||||
export * from "./Settings.js";
|
export * from "./Settings.js";
|
||||||
export * from "./SharingModals.js";
|
export * from "./SharingModals.js";
|
||||||
|
export * from "./SplitPane.js";
|
||||||
export * from "./Toolbar.js";
|
export * from "./Toolbar.js";
|
||||||
export * from "./Toast.js";
|
export * from "./Toast.js";
|
||||||
export * from "./UserProfileMenu.js";
|
export * from "./UserProfileMenu.js";
|
||||||
export * from "./Window.js";
|
export * from "./Window.js";
|
||||||
export * from "./WorkspaceWindow.js";
|
export * from "./WorkspaceWindow.js";
|
||||||
|
|
||||||
|
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
||||||
|
|||||||
+111
-12
@@ -1,6 +1,53 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.0.0",
|
"schemaVersion": "1.0.0",
|
||||||
"components": [
|
"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",
|
"id": "glass-surface",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -10,8 +57,10 @@
|
|||||||
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
|
"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"],
|
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
||||||
"variants": ["default", "strong", "soft", "map"],
|
"variants": ["default", "strong", "soft", "map"],
|
||||||
|
"radiusVariants": ["card", "panel", "modal", "pill"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Geometry is invariant across application themes.",
|
"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.",
|
"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.",
|
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
|
||||||
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
||||||
@@ -26,13 +75,31 @@
|
|||||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||||
|
"sizes": ["default", "compact", "dense"],
|
||||||
"shapes": ["default", "pill", "rounded"],
|
"shapes": ["default", "pill", "rounded"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Icon-only actions are circular by default.",
|
"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.",
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-indicator",
|
||||||
|
"status": "baseline",
|
||||||
|
"package": "@nodedc/ui-react",
|
||||||
|
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
|
||||||
|
"domContract": ["nodedc-activity-indicator"],
|
||||||
|
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
|
||||||
|
"variants": ["default", "compact"],
|
||||||
|
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
|
||||||
|
"rules": [
|
||||||
|
"Use compact inside a Button icon slot and default for standalone inline progress.",
|
||||||
|
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
||||||
|
"The component never owns operation state, timing or completion.",
|
||||||
|
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "field",
|
"id": "field",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -64,17 +131,20 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["RangeControl"],
|
"exports": ["RangeControl"],
|
||||||
"domContract": ["nodedc-range"],
|
"domContract": ["nodedc-range"],
|
||||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||||
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
|
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"The accent fill follows the active application theme.",
|
"The accent fill follows the active application theme.",
|
||||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
"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.",
|
"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.",
|
"Exact editing stays inside the existing value area and never expands the control.",
|
||||||
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
|
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
|
||||||
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
|
||||||
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
"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",
|
"id": "color-field",
|
||||||
@@ -113,11 +183,12 @@
|
|||||||
"domPackage": "@nodedc/ui-dom",
|
"domPackage": "@nodedc/ui-dom",
|
||||||
"domExports": ["createSelectController"],
|
"domExports": ["createSelectController"],
|
||||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
"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"],
|
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Integrated is the single-pill Hub/Launcher form.",
|
"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.",
|
"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.",
|
"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."
|
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
||||||
]
|
]
|
||||||
@@ -136,7 +207,9 @@
|
|||||||
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
||||||
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
|
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
|
||||||
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
|
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
|
||||||
"A draggable inspector moves by its header and remains clamped to the viewport."
|
"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 +236,23 @@
|
|||||||
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
||||||
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
|
"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",
|
"id": "share-access-modal",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -198,7 +288,11 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["SegmentedControl"],
|
"exports": ["SegmentedControl"],
|
||||||
"domContract": ["nodedc-segmented"],
|
"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",
|
"id": "app-header",
|
||||||
@@ -210,6 +304,7 @@
|
|||||||
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
|
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"The logo occupies the same visual position and geometry across applications.",
|
"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.",
|
"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.",
|
"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."
|
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
|
||||||
@@ -350,6 +445,7 @@
|
|||||||
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
||||||
"rules": [
|
"rules": [
|
||||||
"The card owns grouping geometry but not product form schemas.",
|
"The card owns grouping geometry but not product form schemas.",
|
||||||
|
"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."
|
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -358,6 +454,7 @@
|
|||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["StatusBadge"],
|
"exports": ["StatusBadge"],
|
||||||
|
"variants": ["badge", "indicator"],
|
||||||
"domContract": ["nodedc-status"],
|
"domContract": ["nodedc-status"],
|
||||||
"summary": "Semantic status pill whose tone is independent from the application accent."
|
"summary": "Semantic status pill whose tone is independent from the application accent."
|
||||||
},
|
},
|
||||||
@@ -369,9 +466,11 @@
|
|||||||
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
|
"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.",
|
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
|
||||||
"variants": ["success", "error", "warning", "info", "loading"],
|
"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": [
|
"rules": [
|
||||||
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
|
"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.",
|
"Loading notifications remain until the operation updates or dismisses them.",
|
||||||
"Status notifications never replace a blocking confirmation modal."
|
"Status notifications never replace a blocking confirmation modal."
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-3
@@ -6,6 +6,7 @@
|
|||||||
"defaultSize": 18,
|
"defaultSize": 18,
|
||||||
"supportedSizes": [16, 18, 20],
|
"supportedSizes": [16, 18, 20],
|
||||||
"strokeWidth": 1.8,
|
"strokeWidth": 1.8,
|
||||||
|
"filledNames": ["play", "stop"],
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "window-layer",
|
"id": "window-layer",
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
"id": "state-access",
|
"id": "state-access",
|
||||||
"label": "Состояние и доступ",
|
"label": "Состояние и доступ",
|
||||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
"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",
|
"id": "entities",
|
||||||
@@ -41,13 +42,14 @@
|
|||||||
"id": "content",
|
"id": "content",
|
||||||
"label": "Контент",
|
"label": "Контент",
|
||||||
"referenceSources": ["seo", "bim-viewer", "engine"],
|
"referenceSources": ["seo", "bim-viewer", "engine"],
|
||||||
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
|
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
|
"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.",
|
"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.",
|
"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."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"architecture": "../docs/ARCHITECTURE.md",
|
"architecture": "../docs/ARCHITECTURE.md",
|
||||||
"components": "../docs/COMPONENTS.md",
|
"components": "../docs/COMPONENTS.md",
|
||||||
"theming": "../docs/THEMING.md",
|
"theming": "../docs/THEMING.md",
|
||||||
|
"operationalTypography": "../docs/OPERATIONAL_TYPOGRAPHY.md",
|
||||||
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
||||||
"baseline": "../docs/SOURCE_BASELINE.md",
|
"baseline": "../docs/SOURCE_BASELINE.md",
|
||||||
"governance": "../docs/GOVERNANCE.md",
|
"governance": "../docs/GOVERNANCE.md",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
|
||||||
|
|
||||||
|
test("ActivityIndicator separates decorative and announced progress", () => {
|
||||||
|
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
|
||||||
|
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
|
||||||
|
|
||||||
|
assert.match(decorative, /class="nodedc-activity-indicator"/);
|
||||||
|
assert.match(decorative, /data-size="compact"/);
|
||||||
|
assert.match(decorative, /aria-hidden="true"/);
|
||||||
|
assert.doesNotMatch(decorative, /role="status"/);
|
||||||
|
|
||||||
|
assert.match(announced, /role="status"/);
|
||||||
|
assert.match(announced, /aria-label="Подключаем устройство"/);
|
||||||
|
assert.doesNotMatch(announced, /aria-hidden/);
|
||||||
|
assert.doesNotMatch(announced, /data-size=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
|
||||||
|
const [styles, registrySource, docs, catalog] = await Promise.all([
|
||||||
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
const registry = JSON.parse(registrySource);
|
||||||
|
const entry = registry.components.find((component) => component.id === "activity-indicator");
|
||||||
|
|
||||||
|
assert.deepEqual(entry?.variants, ["default", "compact"]);
|
||||||
|
assert.ok(entry?.exports.includes("ActivityIndicator"));
|
||||||
|
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
|
||||||
|
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
|
||||||
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
|
||||||
|
assert.match(docs, /## ActivityIndicator/);
|
||||||
|
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
|
||||||
|
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
|
||||||
|
});
|
||||||
@@ -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,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"),
|
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 === "Enter"/);
|
||||||
assert.match(component, /event\.key === "Escape"/);
|
assert.match(component, /event\.key === "Escape"/);
|
||||||
assert.match(component, /event\.stopPropagation\(\)/);
|
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 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[\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\][\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, /const onCloseRef = useRef\(onClose\)/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
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