Compare commits
6
Commits
1bdfc6c240
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd9190573 | ||
|
|
67025ee209 | ||
|
|
8c53f73ee5 | ||
|
|
b10fd5d645 | ||
|
|
26a1bf72a2 | ||
|
|
5b882bc3d9 |
@@ -3,6 +3,7 @@ import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassM
|
||||
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
LoadingRegion,
|
||||
StatusBadge,
|
||||
ProgressBar,
|
||||
AdminNavigationPanel,
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
type StoredLayout,
|
||||
} from "./designProfile.js";
|
||||
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
|
||||
import { EnvironmentCatalogDemo } from "./EnvironmentCatalogDemo.js";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
@@ -199,7 +201,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Окно и слой",
|
||||
note: "Launcher / Hub",
|
||||
icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"],
|
||||
icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps", "layers"],
|
||||
},
|
||||
{
|
||||
title: "Навигация",
|
||||
@@ -214,7 +216,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Состояние и доступ",
|
||||
note: "Вся платформа",
|
||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||
icons: ["check", "alert", "info", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||
},
|
||||
{
|
||||
title: "Сущности",
|
||||
@@ -255,7 +257,9 @@ const iconLabels: Record<IconName, string> = {
|
||||
grid: "Обзор",
|
||||
image: "Изображение",
|
||||
inbox: "Уведомления",
|
||||
info: "Информация",
|
||||
key: "Ключ",
|
||||
layers: "Слои",
|
||||
list: "Список задач",
|
||||
lock: "Закрыто",
|
||||
mail: "Приглашение",
|
||||
@@ -1291,6 +1295,8 @@ export function CatalogApp() {
|
||||
<>
|
||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||
<div style={{ height: 180 }} title="Тонкая шкала: границы справа; подпись и значение закреплены в центре, контраст следует заливке"><RangeControl orientation="vertical" label="Срез" value={brightness / 100 * 52.7 - 1.2} min={-1.2} max={51.5} step="any" formatValue={(value) => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={(value) => value.toFixed(1).replace('.', ',')} onChange={(value) => setBrightness((value + 1.2) / 52.7 * 100)} /></div>
|
||||
<div style={{ height: 180, display: 'flex', justifyContent: 'flex-end' }} title="Шкала справа: границы слева; без контура при drag и фокусе, фокус подчёркивает подпись"><RangeControl orientation="vertical" limitSide="left" label="Срез" value={brightness / 100 * 52.7 - 1.2} min={-1.2} max={51.5} step="any" formatValue={(value) => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={(value) => value.toFixed(1).replace('.', ',')} onChange={(value) => setBrightness((value + 1.2) / 52.7 * 100)} /></div>
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
</>
|
||||
),
|
||||
@@ -1342,6 +1348,14 @@ export function CatalogApp() {
|
||||
<FieldFrame label="Hub / integrated select">
|
||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Select / независимое действие строки">
|
||||
<Select label="Записи" value={selectedStatus} searchable onChange={setSelectedStatus}
|
||||
options={selectOptions.map(option => ({ ...option, action: {
|
||||
label: `Удалить ${option.label}`, icon: <Icon name="trash" />, tone: 'danger' as const,
|
||||
disabled: option.value === 'active',
|
||||
onAction: () => setConfirmOpen(true),
|
||||
} }))} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Engine / split select">
|
||||
<Select
|
||||
variant="split"
|
||||
@@ -1404,15 +1418,15 @@ export function CatalogApp() {
|
||||
<ActivityIndicator label="Загружаем данные" />
|
||||
<span>Загружаем данные</span>
|
||||
</span>
|
||||
<Button
|
||||
aria-busy="true"
|
||||
disabled
|
||||
icon={<ActivityIndicator size="compact" />}
|
||||
>Подключаем…</Button>
|
||||
<Button loading>Подключить</Button>
|
||||
<IconButton label="Обновить" loading><Icon name="refresh" /></IconButton>
|
||||
</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="в центре собственной области">
|
||||
<LoadingRegion loading label="Ожидаем содержимое" />
|
||||
</Preview>
|
||||
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /><StatusBadge tone="danger" pulse>Требуется внимание</StatusBadge></Preview>
|
||||
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
||||
<ProgressBar label="Получение сведений" />
|
||||
@@ -1425,6 +1439,10 @@ export function CatalogApp() {
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
|
||||
</ResourceList>
|
||||
<SettingsCard title="Устройства не обнаружены" description="Подключите устройство, чтобы оно появилось в списке." />
|
||||
<SettingsCard align="center" role="status" title="Настройки применены" description="Проверьте подключение устройства." />
|
||||
</Preview>
|
||||
<Preview title="Главная и окружение" note="единые компоненты · сохранение в памяти каталога">
|
||||
<EnvironmentCatalogDemo />
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||
<div className="catalog-window-actions-demo">
|
||||
@@ -1533,6 +1551,10 @@ export function CatalogApp() {
|
||||
description="MP4 или URL становятся частью единого server-side layout вместе с темой, материалом и Toolbar."
|
||||
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
|
||||
>
|
||||
<div style={{"--nodedc-accent-rgb":"247 248 244","--nodedc-on-accent-rgb":"8 8 10"} as CSSProperties}>
|
||||
<Switch checked={mediaVisible} label="Белый акцент · видимость" onChange={setMediaVisible} />
|
||||
<Switch checked={false} label="Выключено" disabled onChange={()=>{}} />
|
||||
</div>
|
||||
<MediaSourceField
|
||||
label="Видео / картинка"
|
||||
source={mediaSource}
|
||||
@@ -1609,7 +1631,7 @@ export function CatalogApp() {
|
||||
<span>CANONICAL GLASS</span>
|
||||
<h2>Модальное окно и Inspector</h2>
|
||||
<p>Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.</p>
|
||||
<div className="catalog-inline"><TextField label="Пример поля" defaultValue="NODE.DC" /><Button variant="primary" shape="pill">Применить</Button></div>
|
||||
<div className="catalog-inline"><TextField label="Пример поля" name="organization" autoComplete="organization" defaultValue="NODE.DC" description="Автозаполнение сохраняет материал поля и клавиатурный фокус текущей темы." /><Button variant="primary" shape="pill">Применить</Button></div>
|
||||
</GlassMaterialSurface>
|
||||
</section>
|
||||
<SettingsCard eyebrow="ENGINE / GLASS V4" title="Параметры материала" description="Общий token-контракт ui-core; изменения сразу видны на preview, модалках и Inspector.">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { EnvironmentSettings } from "@nodedc/ui-core";
|
||||
import { Button, EnvironmentSettingsWindow, LandingStage, UserProfileMenu } from "@nodedc/ui-react";
|
||||
|
||||
const initial: EnvironmentSettings = { revision: 0, pages: { home: {
|
||||
headerLabel: "Продукт", eyebrow: "NODE.DC / ГЛАВНАЯ", title: "Главная продукта",
|
||||
description: "Общая композиция страницы и настроек окружения.", primaryWorkspaceId: null, secondaryWorkspaceId: null,
|
||||
background: { enabled: false, imageDurationSeconds: 10, items: [] },
|
||||
} } };
|
||||
|
||||
export function EnvironmentCatalogDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings, setSettings] = useState(initial);
|
||||
const urls = useRef<string[]>([]);
|
||||
useEffect(() => () => urls.current.forEach(url => URL.revokeObjectURL(url)), []);
|
||||
return <>
|
||||
<div className="catalog-environment-actions">
|
||||
<UserProfileMenu displayName="DC" subtitle="Каталог компонентов" triggerLabel={null}
|
||||
actions={[{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setOpen(true) }]} />
|
||||
<Button variant="primary" tone="neutral" onClick={() => setOpen(true)}>Настройки окружения</Button>
|
||||
<Button variant="primary" tone="neutral" disabled>Неактивное действие</Button>
|
||||
</div>
|
||||
<div className="catalog-environment-stage"><LandingStage page={settings.pages.home} /></div>
|
||||
<EnvironmentSettingsWindow open={open} onClose={() => setOpen(false)} productName="Продукт"
|
||||
surfaces={[{ id: "home", home: true, description: "Главная страница продукта", actions: [] }]}
|
||||
settings={settings} state="ready" error={null}
|
||||
onSave={async draft => { const next = { ...draft, revision: draft.revision + 1 }; setSettings(next); return next; }}
|
||||
onUpload={async (_surface, _item, file) => {
|
||||
const url = URL.createObjectURL(file); urls.current.push(url);
|
||||
return { url, fileName: file.name, mediaKind: file.type.startsWith("video/") ? "video" : "image" };
|
||||
}} />
|
||||
</>;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
.catalog-environment-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.catalog-environment-stage { display: grid; width: 100%; min-height: 480px; }
|
||||
|
||||
:root {
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
@@ -74,3 +74,16 @@
|
||||
- не поддерживать отдельную тему путём fork компонента;
|
||||
- не использовать legacy Engine inspector как промежуточный канон;
|
||||
- не удалять production local component до проверки package replacement.
|
||||
# Mission Core and onboard Home (R17)
|
||||
|
||||
Mission Core and Mission Core Node consume `EnvironmentSettingsWindow`,
|
||||
`EnvironmentMediaPlaylistEditor`, `EnvironmentBackgroundMedia` and `LandingStage`
|
||||
from `@nodedc/ui-react`. The former Control Station files are thin API/product
|
||||
registry adapters or compatibility re-exports. They own no copied form markup or
|
||||
presentation CSS. Node supplies Home only; Core retains all existing pages and
|
||||
its wire/storage schema. Both use the existing `UserProfileMenu` avatar dropdown.
|
||||
The component contract and catalog example are in `ENVIRONMENT_SETTINGS.md`.
|
||||
|
||||
## Mission Core loading (2026-09-08)
|
||||
|
||||
Core и Node используют общий sensor-ui/X4 frontend с Button.loading, IconButton.loading и LoadingRegion из Design Guideline. SDK/network semantics остаются в consumer, геометрия ожидания принадлежит DG. Этот локальный кандидат передаётся через source manifest с hashes вместе с Node installer; public npm release не публиковался, исходники компонентов в приложение не копируются.
|
||||
|
||||
+46
-2
@@ -31,15 +31,21 @@ Icon-only action по умолчанию круглый. Квадратная к
|
||||
|
||||
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||
|
||||
`loading` в `Button` и `IconButton` помещает индикатор по центру нажатой кнопки, сохраняет её размеры и accessible name, выставляет `aria-busy` и блокирует повторное нажатие. Подробный контракт — [состояния загрузки](LOADING_STATES.md).
|
||||
|
||||
## LoadingRegion
|
||||
|
||||
`LoadingRegion loading={pending} label="Получаем данные"` центрирует ожидание внутри области содержимого, сохраняя её детей смонтированными. Приложение резервирует размер визуализатора и снимает loading после первого пригодного содержимого, ошибки или timeout. Командный spinner принадлежит кнопке и сюда не дублируется.
|
||||
|
||||
## ActivityIndicator
|
||||
|
||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется внутри `LoadingRegion` или явного inline-статуса, `compact` — внутри `Button.loading`/`IconButton.loading` либо фиксированного слота строки ресурса. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||
|
||||
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||
|
||||
## Field
|
||||
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля. Автозаполнение браузера сохраняет материал поля, цвет текста и каретки текущей темы; синяя системная заливка не используется. Клавиатурный фокус остаётся видимым.
|
||||
|
||||
Приложение не должно вручную собирать label и input, если подходит Field. Ошибка и validation state будут расширением этого контракта, а не локальным классом приложения.
|
||||
|
||||
@@ -51,10 +57,18 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
||||
|
||||
## RangeControl
|
||||
|
||||
Вертикальная шкала занимает `0.4 × --nodedc-control-height` по ширине (18.4 px при базовой высоте 46 px): на 20% тоньше первоначального варианта 11.09.2026. Высота, диапазон и управление сохраняются.
|
||||
|
||||
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 только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
|
||||
`RangeControl orientation="vertical"` — утверждённый владельцем 11.09.2026 вертикальный вариант для высоты среза. Максимум расположен сверху, заливка растёт снизу, стрелки вверх/вниз меняют значение. Подпись, числовое значение, контраст и focus принадлежат тому же компоненту. Узкий вертикальный вариант использует drag/клавиатуру без inline-редактора; точные границы задаёт домен.
|
||||
|
||||
Последнее уточнение владельца от 19.09.2026 заменяет промежуточный вариант с границами внутри: верхняя и нижняя границы вынесены с внутренней стороны сцены, читаются горизонтально и сохраняют цвет theme text, не меняясь от заливки. `formatLimit` форматирует их отдельно от текущего значения, без единицы измерения по умолчанию. По умолчанию это правая сторона; `limitSide="left"` зеркалит только внешние границы для шкалы, закреплённой справа от сцены. В центре неподвижно расположена строка «подпись · значение»; единица остаётся в `formatValue`. Внутренний и наружный шрифт уменьшены на 10% относительно токенов sm/xs. Два совпадающих центральных текстовых слоя и маска формы заливки меняют контраст ровно по её границе, в том числе посередине символа.
|
||||
|
||||
Уточнение от 20.09.2026: вертикальная шкала не получает border, outline или тень по периметру ни в одном состоянии. Native range может сохранять `:focus-visible` и после pointer-ввода, поэтому этот селектор больше не рисует контур. Доступный фокус обозначается подчёркиванием центральной подписи в обоих контрастных слоях; стрелки и Home/End продолжают работать нативно.
|
||||
|
||||
## ColorField
|
||||
|
||||
Engine/BIM-геометрия color field: круглый swatch и HEX-значение в pill control. Swatch открывает общий portal-picker: saturation/value plane, hue slider и быстрые цвета. Это устраняет зависимость от тёмной системной палитры браузера и делает выбор одинаковым в dark/light.
|
||||
@@ -93,6 +107,10 @@ Native select не используется как видимый runtime UI. М
|
||||
|
||||
Внутри `Inspector` форма `integrated` запрещена. Selection-поле собирается только через `InspectorSelectField`: видимая подпись находится сверху, а полноширинный control всегда использует `split`. Компонент намеренно не принимает `layout` и `variant`, поэтому consumer не может вернуть Inspector к Hub/Launcher pill-форме.
|
||||
|
||||
`SelectOption.action` добавляет независимое действие справа от строки (`label`, `icon`, `disabled`, `onAction`), например корзину. Это соседний канонический `IconButton`, не вложенная кнопка. Действие закрывает меню, не выбирает строку и не вызывает `onChange`; подтверждение и запрос принадлежат приложению. Меню с действиями использует dialog-семантику и отдельные доступные кнопки выбора/действия; обычный Select сохраняет listbox. Состояние `option.disabled` не блокирует независимое действие — для него предусмотрен `action.disabled`.
|
||||
|
||||
Действие строки повторяет принятый образец каталога сессий Mission Core: прозрачная колонка нажатия `2.35rem`, иконка `15 px`, приглушённый цвет, без круглой подложки, скругления или тени. Опциональный `tone="danger"` меняет цвет при наведении и клавиатурном фокусе, но не окрашивает корзину постоянно. Доступный focus-индикатор общего IconButton сохраняется.
|
||||
|
||||
## Window
|
||||
|
||||
Window — единая механика открытия modal и правой panel:
|
||||
@@ -234,6 +252,10 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
Постоянные действия всего рабочего окна передаются через `ApplicationPanel.utilityActions` и всегда находятся в правой части шапки перед expand/close. Сохранение общего layout или настроек приложения — одно такое действие для всего окна; оно не дублируется внутри отдельных карточек и секций.
|
||||
|
||||
Переключение режима выбранного объекта может передать `pressed` в utility action: состояние получает `aria-pressed` и общую активную поверхность. Например, `info` открывает обзор текущей сохранённой сессии Mission Core.
|
||||
|
||||
Если заголовок и действия не помещаются в одну строку, шапка переносит действия на следующую строку с выравниванием вправо. Кнопки не обрезаются, заголовок сохраняет читаемую ширину. Состав действий одинаков в обычном и развёрнутом окне.
|
||||
|
||||
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
|
||||
|
||||
## MediaSourceField
|
||||
@@ -246,6 +268,16 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
`SettingsCard` фиксирует нейтральную структуру админской группы: eyebrow/title/description/actions/body. `Switch` покрывает компактное включение/видимость внутри таких групп. Данные секции, сохранение и права остаются в consumer.
|
||||
|
||||
Бегунок включённого `Switch` использует `--nodedc-on-accent-rgb`, поэтому
|
||||
сохраняет контраст с белым, цветным и тёмным акцентом. Выключенный track
|
||||
наследует тон темы от text-primary. Длина track не сжимается при переносе
|
||||
подписи; положение бегунка остаётся дополнительным признаком состояния.
|
||||
|
||||
Для компактных сообщений о результате и пустых состояний `SettingsCard align="center"`
|
||||
центрирует содержимое по горизонтали и вертикали. Пустой body не создаёт
|
||||
асимметричный нижний отступ. Обычные группы настроек сохраняют `align="start"`;
|
||||
вариант не задаёт высоту и не меняет токены темы.
|
||||
|
||||
## AdminNavigationPanel
|
||||
|
||||
Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity.
|
||||
@@ -292,6 +324,8 @@ Sortable-строки ограничены вертикальной осью: г
|
||||
|
||||
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
|
||||
|
||||
`pulse` добавляет пульсацию только лампы, не текста и не фона. Подходит для потери подтверждения или другого состояния, требующего внимания. При `prefers-reduced-motion: reduce` лампа остаётся постоянной. Текст статуса обязателен рядом с отдельным индикатором или в самом badge.
|
||||
|
||||
## ToastStack
|
||||
|
||||
`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.
|
||||
@@ -336,3 +370,13 @@ Engine продолжает владеть определениями полей
|
||||
|
||||
StatusBadge `variant="indicator"` отображает только одну лампу состояния.
|
||||
Передавайте `aria-label` и `title`; дополнительный текст и значки скрываются.
|
||||
|
||||
## EnvironmentSettingsWindow и EnvironmentMediaPlaylistEditor
|
||||
|
||||
Единый редактор окружения перенесён из принятой композиции Mission Core. Принимает страницы, quick-action варианты, документ, состояние, upload/save adapters и название продукта. Использует тот же FeatureSettingsWindow, поля, переключатели, медиаконтент и перестановку; новое приложение передаёт данные, а не копирует форму. Для бортового приложения передаётся только home. Контракты и пример: [ENVIRONMENT_SETTINGS.md](ENVIRONMENT_SETTINGS.md).
|
||||
|
||||
## LandingStage и EnvironmentBackgroundMedia
|
||||
|
||||
Общая главная: надзаголовок, заголовок, описание, быстрые действия, фон и необязательные status/footer slots. Изображения меняются по таймеру, видео — после завершения; ошибочные источники пропускаются. Пустой фон однотонный, без декоративного круга. Состояние страниц и хранение принадлежат приложению.
|
||||
|
||||
Для primary Button `tone="neutral"` фиксирует белый enabled и серый disabled, независимо от темы и акцента; keyboard focus остаётся видимым. MediaSourceField принимает `disabled` и блокирует все операции выбора источника, пока родитель сохраняет документ или загружает файл.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Shared environment and home
|
||||
|
||||
The owner-approved Mission Core settings anatomy is now canonical, not a screenshot to reproduce. Consumers use UserProfileMenu for the avatar dropdown, EnvironmentSettingsWindow for the settings composition, EnvironmentMediaPlaylistEditor for the list and LandingStage/EnvironmentBackgroundMedia for the home. All geometry and responsive styles live in ui-core.
|
||||
|
||||
The neutral primary Button tone comes from shared tokens, stays white when enabled and gray when disabled across dark/light and accent changes. It does not alter other applications' default primary theme.
|
||||
|
||||
## Consumer contract
|
||||
|
||||
Pass productName, a nonempty stable surfaces list, optional initialSurfaceId, a revision/pages document and save/upload adapters. Each surface references one present page and declares its available quick actions. Workspace IDs are opaque application-owned identifiers. The library owns only draft/form/list/playback behavior; it never imports Mission Core's registry or calls product APIs. Shared data shapes/list functions are exported by ui-core so nonvisual application contracts do not depend on React.
|
||||
|
||||
Settings uses the existing FeatureSettingsWindow/Window focus and Escape behavior. A busy parent disables editing and save; failed saves retain the draft. Each playlist has up to 24 items. Images use the configured delay; a video advances on ended, with one video looping. Failed media is skipped. List display reverses playback order to keep newly added media at the top, as in the accepted source UI.
|
||||
|
||||
The application validates URLs/media bytes, persists uploads and document revisions, handles authentication and rejects stale saves. An upload response provides URL, fileName and mediaKind. A file URL belongs to the storage adapter; an external source must use HTTP(S). No media defaults to an invented graphic. The product may supply status/footer slots without forking the stage.
|
||||
|
||||
## Adoption and validation
|
||||
|
||||
Mission Core passes all existing page definitions and its current operator-environment API. Mission Core Node passes home only and authenticated local presentation storage. Device control never depends on this document. The catalog EnvironmentCatalogDemo exercises the same exported components with an explicitly in-memory save/upload adapter; it is not a persistence service.
|
||||
|
||||
Validate the source migration, TypeScript builds, playlist functions, neutral enabled/disabled DOM contract, dark/light and multiple accents, modal Escape, media order/timing and both consumer persistence adapters. Long-running or physical device acceptance remains application-owned.
|
||||
+8
-1
@@ -1,5 +1,7 @@
|
||||
# Иконки
|
||||
|
||||
`info` — информация о выбранном объекте или записи. Иконка используется в стандартном круглом `IconButton`, с доступной подписью и `aria-pressed` для переключаемого обзора. Продуктовое применение: обзор сохранённой сессии Mission Core; одобрено владельцем 2026-09-11.
|
||||
|
||||
Канонический набор собран по реально используемым действиям Launcher/Hub, SEO, BIM Viewer и разрешённым новым участкам Engine. Полные vendor-наборы не являются частью дизайн-системы: наличие SVG в Font Awesome или export в Lucide не делает иконку канонической.
|
||||
|
||||
## Геометрия
|
||||
@@ -16,7 +18,7 @@
|
||||
|
||||
| Группа | Канонические имена |
|
||||
| --- | --- |
|
||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps`, `layers` |
|
||||
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
||||
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
||||
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` |
|
||||
@@ -31,4 +33,9 @@
|
||||
|
||||
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
|
||||
|
||||
`layers` открывает управление видимостью слоёв карты или визуального источника.
|
||||
Применение подтверждено владельцем Mission Core 10.09.2026: кнопка слоёв в
|
||||
заголовке карты Центра наблюдения. `settings` остаётся общей конфигурацией.
|
||||
Кнопка наследует обычные hover, focus, disabled и active-состояния `IconButton`.
|
||||
|
||||
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Состояния загрузки
|
||||
|
||||
Общий контракт для Mission Core, Mission Core Node и других NODE.DC consumers. Кольцо рисует ActivityIndicator; место и семантику определяют Button.loading, IconButton.loading или LoadingRegion. Локальные absolute-спиннеры и свободные ActivityIndicator под группой действий запрещены.
|
||||
|
||||
| Причина ожидания | Компонент и место | Завершение |
|
||||
|---|---|---|
|
||||
| Нажата команда | loading только у инициирующей Button/IconButton, по центру внутри её прежних границ | Подтверждение результата, ошибка или timeout команды |
|
||||
| Начальная загрузка содержимого | LoadingRegion вокруг конкретной области, по центру её границ | Первое пригодное содержимое, ошибка или timeout |
|
||||
| Подключение видео | LoadingRegion вокруг смонтированного video/canvas, включая расширенный вид | Первый декодированный кадр; одного ICE connected недостаточно |
|
||||
| Фоновое обновление с доступным содержимым | loading у явной кнопки обновления; без overlay при автоматическом polling | Ответ запроса или ошибка |
|
||||
| Измеряемая многошаговая подготовка | Существующий ProgressBar/ResourceRow.progress и статусы этапов | Результат этапа/операции |
|
||||
| Ошибка, offline, отсутствие свежих кадров | Конечный статус и разрешённое действие повторения | Не оставлять бесконечный spinner |
|
||||
|
||||
Приложение хранит pending по device ID, session и конкретному action key. Блокировка соседних несовместимых действий не делает их загружающимися. Для одной команды показывается один индикатор; последующее получение содержимого — отдельный этап. Элемент управления сохраняет размеры, надпись для screen reader и свой порядок в layout; spinner не добавляет строку и не сдвигает соседей. Повторная активация pending-кнопки блокируется native disabled.
|
||||
|
||||
LoadingRegion не размонтирует video/canvas и не присваивает область страницы целиком. Визуализатор задаёт собственную стабильную высоту/aspect ratio; индикатор центрируется относительно этой области и автоматически следует её normal/expanded размеру. Компонент не блокирует управление сам: consumer отключает только конфликтующие действия. Закрытие просмотра остаётся доступным.
|
||||
|
||||
Владелец операции задаёт начало и конец ожидания. Компоненты не запускают запросы, не выдумывают проценты, не повторяют операции и не скрывают ошибки по таймеру. Reduced motion сохраняет статическое кольцо. Button сохраняет accessible name и aria-busy; LoadingRegion имеет один status с label, декоративное кольцо скрыто от accessibility tree.
|
||||
|
||||
```tsx
|
||||
<Button loading={pending === 'save'} onClick={save}>Сохранить</Button>
|
||||
<IconButton label="Обновить" loading={pending === 'refresh'} onClick={refresh}><Icon name="refresh" /></IconButton>
|
||||
<LoadingRegion loading={connecting || awaitingFirstFrame} label="Ожидаем изображение">
|
||||
<video autoPlay muted playsInline />
|
||||
</LoadingRegion>
|
||||
```
|
||||
|
||||
Living catalog показывает action, icon-action и content loading. Изменение согласовано владельцем08.09.2026 для обоих Mission Core consumers. Локальный кандидат включается в hash-bound Ubuntu source artifact; внешний package release в рамках этой задачи не публикуется.
|
||||
|
||||
## Проверка кандидата 08.09.2026
|
||||
|
||||
N09: typecheck, registry validation, четыре loading contract tests и production catalog build прошли. В браузере проверены light/dark и два accent colors: loading не меняет размер Button/IconButton, кольцо сохраняет нейтральный цвет, content status центрируется вместе с подписью. В Mission Core проверены pending read-only action, video first frame, normal/expanded/Escape и terminal error: mounted video сохраняется, после кадра или ошибки лишних индикаторов нет. Тот же shared frontend включён в установленный Node0.8.18; отдельная визуальная проверка native Node shell остаётся задачей consumer acceptance.
|
||||
+3
-2
@@ -11,10 +11,11 @@
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test: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",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:control-density && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile && npm run test:environment-settings",
|
||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
"test:environment-settings": "node --test scripts/environment-settings.test.mjs",
|
||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
||||
"test:data-product-consumer": "node --test server/foundry-data-product-consumer.test.mjs",
|
||||
@@ -37,7 +38,7 @@
|
||||
"test:icon-contract": "node --test scripts/icon-contract.test.mjs",
|
||||
"test:toast-contract": "node --test scripts/toast-contract.test.mjs",
|
||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs scripts/select-actions-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"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
:root {
|
||||
--nodedc-neutral-action-bg: rgba(247, 248, 244, 0.96);
|
||||
--nodedc-neutral-action-hover: #fff;
|
||||
--nodedc-neutral-action-color: rgba(8, 8, 10, 0.96);
|
||||
--nodedc-neutral-action-disabled-bg: #757575;
|
||||
--nodedc-neutral-action-disabled-color: #242424;
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-nodedc-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings { revision: number; pages: Record<string, EnvironmentPage> }
|
||||
export interface UploadedEnvironmentMedia { url: string; fileName: string; mediaKind: EnvironmentMediaKind }
|
||||
export interface EnvironmentAction { id: string; label: string; description?: string }
|
||||
export interface EnvironmentSurface { id: string; home?: boolean; description?: string; actions: readonly EnvironmentAction[] }
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
|
||||
export function cloneEnvironmentSettings(settings: EnvironmentSettings): EnvironmentSettings {
|
||||
return { ...settings, pages: Object.fromEntries(Object.entries(settings.pages).map(([id, page]) => [id, {
|
||||
...page, background: { ...page.background, items: page.background.items.map(item => ({ ...item })) },
|
||||
}])) };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from "./floating.js";
|
||||
export * from "./glass.js";
|
||||
export * from "./theme.js";
|
||||
export * from "./toolbar.js";
|
||||
export * from "./environment.js";
|
||||
|
||||
+482
-1
@@ -108,6 +108,7 @@
|
||||
}
|
||||
|
||||
.nodedc-button {
|
||||
position: relative;
|
||||
--nodedc-button-bg: var(--nodedc-glass-control-bg);
|
||||
--nodedc-button-color: var(--nodedc-text-primary);
|
||||
display: inline-flex;
|
||||
@@ -185,6 +186,26 @@
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 84%, white);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"] {
|
||||
--nodedc-button-bg: var(--nodedc-neutral-action-bg);
|
||||
--nodedc-button-color: var(--nodedc-neutral-action-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:hover:not(:disabled) {
|
||||
background: var(--nodedc-neutral-action-hover);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:disabled {
|
||||
background: var(--nodedc-neutral-action-disabled-bg);
|
||||
color: var(--nodedc-neutral-action-disabled-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--nodedc-neutral-action-color);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="ghost"] {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
@@ -216,6 +237,57 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.nodedc-button__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: inherit;
|
||||
gap: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-button[data-loading="true"] > .nodedc-button__content,
|
||||
.nodedc-icon-button[data-loading="true"] > :not(.nodedc-action-loading) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nodedc-action-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-button[data-loading="true"],
|
||||
.nodedc-icon-button[data-loading="true"] {
|
||||
opacity: 1;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.nodedc-loading-region {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-loading-region[aria-busy="true"] {
|
||||
min-block-size: calc(var(--nodedc-control-height) * 2);
|
||||
}
|
||||
|
||||
.nodedc-loading-region__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--nodedc-space-2);
|
||||
padding: var(--nodedc-space-3);
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-button__icon > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@@ -247,6 +319,7 @@
|
||||
}
|
||||
|
||||
.nodedc-icon-button {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
width: var(--nodedc-icon-button-size);
|
||||
height: var(--nodedc-icon-button-size);
|
||||
@@ -350,6 +423,20 @@
|
||||
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
/* Browsers enforce their autofill background with an internal !important rule.
|
||||
Paint the canonical field material above it; keep text, caret and keyboard
|
||||
focus in the same theme as manually entered values. */
|
||||
.nodedc-field__control:is(:autofill, :-webkit-autofill) {
|
||||
-webkit-text-fill-color: var(--nodedc-text-primary);
|
||||
caret-color: var(--nodedc-text-primary);
|
||||
box-shadow: inset 0 0 0 100vmax var(--nodedc-field-bg);
|
||||
}
|
||||
|
||||
.nodedc-field__control:is(:autofill, :-webkit-autofill):focus-visible {
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22),
|
||||
inset 0 0 0 100vmax var(--nodedc-field-bg);
|
||||
}
|
||||
|
||||
textarea.nodedc-field__control {
|
||||
min-height: 6rem;
|
||||
padding-block: 0.85rem;
|
||||
@@ -608,6 +695,21 @@ textarea.nodedc-field__control {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] {
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] > .nodedc-settings-card__head {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] > .nodedc-settings-card__body {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.nodedc-switch {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
@@ -624,11 +726,12 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-switch__track {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 1.12rem;
|
||||
align-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--nodedc-text-primary) 14%, transparent);
|
||||
padding: 0.14rem;
|
||||
transition: background var(--nodedc-duration-normal) var(--nodedc-ease-standard);
|
||||
}
|
||||
@@ -646,6 +749,7 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-switch[aria-checked="true"] .nodedc-switch__track > span {
|
||||
background: rgb(var(--nodedc-on-accent-rgb));
|
||||
transform: translateX(0.88rem);
|
||||
}
|
||||
|
||||
@@ -738,6 +842,33 @@ textarea.nodedc-field__control {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nodedc-select-option-row { display: flex; align-items: stretch; gap: 0.2rem; }
|
||||
.nodedc-select-option-row > .nodedc-dropdown-option { flex: 1; min-width: 0; }
|
||||
.nodedc-select-option-row > .nodedc-icon-button {
|
||||
width: 2.35rem; height: auto; flex: 0 0 2.35rem;
|
||||
border-radius: 0; background: transparent; box-shadow: none;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
.nodedc-select-option-row > .nodedc-icon-button > svg { width: 15px; height: 15px; transform: none; }
|
||||
.nodedc-select-option-row > .nodedc-icon-button:hover:not(:disabled),
|
||||
.nodedc-select-option-row > .nodedc-icon-button:focus-visible { background: transparent; color: var(--nodedc-text-primary); }
|
||||
.nodedc-select-option-row > .nodedc-icon-button[data-tone="danger"]:hover:not(:disabled),
|
||||
.nodedc-select-option-row > .nodedc-icon-button[data-tone="danger"]:focus-visible { color: rgb(var(--nodedc-danger-rgb)); }
|
||||
.nodedc-select-option-row > .nodedc-icon-button:active:not(:disabled) { transform: none; }
|
||||
.nodedc-range-scale { position: relative; width: calc(var(--nodedc-control-height) * 0.4); height: 100%; min-height: 6rem; }
|
||||
.nodedc-range[data-orientation="vertical"] { width: 100%; height: 100%; border: 0; outline: 0; box-shadow: none; }
|
||||
.nodedc-range[data-orientation="vertical"]::before { inset: auto 0 0; width: 100%; height: var(--nodedc-range-progress); }
|
||||
.nodedc-range[data-orientation="vertical"] input[type="range"] { writing-mode: vertical-lr; direction: rtl; }
|
||||
.nodedc-range[data-orientation="vertical"] .nodedc-range__label { left: 50%; max-width: none; font-size: calc(var(--nodedc-font-size-sm) * 0.9); writing-mode: vertical-rl; transform: translate(-50%, -50%) rotate(180deg); }
|
||||
.nodedc-range-scale__limit { position: absolute; left: calc(100% + 0.4rem); color: var(--nodedc-text-primary); font-size: calc(var(--nodedc-font-size-xs) * 0.9); font-weight: var(--nodedc-font-weight-medium); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; }
|
||||
.nodedc-range-scale[data-limit-side="left"] .nodedc-range-scale__limit { right: calc(100% + 0.4rem); left: auto; text-align: right; }
|
||||
.nodedc-range-scale__limit--max { top: 0.75rem; }
|
||||
.nodedc-range-scale__limit--min { bottom: 0.75rem; }
|
||||
.nodedc-range[data-orientation="vertical"] .nodedc-range__fill-text { clip-path: inset(calc(100% - var(--nodedc-range-progress)) 0 0 round var(--nodedc-radius-circle)); }
|
||||
/* Native ranges may match focus-visible after pointer input too. Keep every
|
||||
state perimeter-free; underline both contrast layers to identify focus. */
|
||||
.nodedc-range[data-orientation="vertical"]:has(input:focus-visible) .nodedc-range__label { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 2px; }
|
||||
|
||||
.nodedc-range__label,
|
||||
.nodedc-range__value {
|
||||
position: absolute;
|
||||
@@ -1523,6 +1654,8 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-application-panel__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
min-height: 3.55rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -1531,6 +1664,7 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-application-panel__titles {
|
||||
display: grid;
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
gap: 0.24rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
@@ -1565,12 +1699,17 @@ textarea.nodedc-field__control {
|
||||
.nodedc-application-panel__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
@@ -3821,3 +3960,345 @@ textarea.nodedc-field__control {
|
||||
.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)); }
|
||||
|
||||
.nodedc-status[data-pulse="true"]::before {
|
||||
content: ""; display: block; width: .42rem; height: .42rem; flex: 0 0 .42rem;
|
||||
border-radius: var(--nodedc-radius-circle); background: currentColor;
|
||||
animation: nodedc-status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.nodedc-status[data-pulse="true"]:not([data-variant="indicator"]) { gap: .42rem; }
|
||||
@keyframes nodedc-status-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .35; transform: scale(.75); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-status[data-pulse="true"]::before { animation: none; }
|
||||
}
|
||||
|
||||
/* Shared product landing and environment settings. */
|
||||
.nodedc-landing-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-canvas-soft);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__media,
|
||||
.nodedc-landing-stage__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__media img,
|
||||
.nodedc-landing-stage__media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__shade {
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage:not([data-has-media="true"]) .nodedc-landing-stage__shade {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: clamp(2rem, 5vw, 6rem);
|
||||
width: min(39rem, 50%);
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
margin: 0.75rem 0 1rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.072em;
|
||||
line-height: 0.87;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2.4rem;
|
||||
right: 2.4rem;
|
||||
display: grid;
|
||||
width: min(24rem, 30vw);
|
||||
gap: 0.6rem;
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(10 10 12 / 0.58);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__footer {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 2.4rem;
|
||||
bottom: 1.8rem;
|
||||
left: 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__copy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__editor {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface > span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface .nodedc-select-anchor,
|
||||
.nodedc-environment-settings__surface .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions .nodedc-select-anchor,
|
||||
.nodedc-environment-settings__quick-actions .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head p,
|
||||
.nodedc-environment-media-playlist__empty,
|
||||
.nodedc-environment-media-playlist__timing > span,
|
||||
.nodedc-environment-media-playlist__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__items {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
padding-top: 1.65rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__timing {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-environment-settings__copy,
|
||||
.nodedc-environment-settings__quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item-actions {
|
||||
justify-self: end;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.nodedc-landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.nodedc-landing-stage__status {
|
||||
width: 20rem;
|
||||
max-width: 36vw;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 7vw, 5.6rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__copy {
|
||||
top: 42%;
|
||||
right: 1.2rem;
|
||||
left: 1.2rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
bottom: 3.3rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__footer {
|
||||
right: 1rem;
|
||||
bottom: 1.1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__footer span:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__copy p {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__status {
|
||||
top: 1.2rem;
|
||||
right: 1.2rem;
|
||||
}
|
||||
}
|
||||
.nodedc-landing-stage__eyebrow { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); font-weight: var(--nodedc-font-weight-strong); letter-spacing: .12em; }
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface ApplicationPanelUtilityAction {
|
||||
icon: IconName;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
pressed?: boolean;
|
||||
}
|
||||
|
||||
export function ApplicationPanel({
|
||||
@@ -102,6 +103,8 @@ export function ApplicationPanel({
|
||||
className="nodedc-application-panel__action"
|
||||
data-action="utility"
|
||||
aria-label={action.label}
|
||||
aria-pressed={action.pressed}
|
||||
data-active={action.pressed ? "true" : undefined}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn.js";
|
||||
import { ActivityIndicator } from "./ActivityIndicator.js";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||
export type ButtonSize = "default" | "compact" | "dense";
|
||||
@@ -8,15 +9,20 @@ export type ButtonShape = "default" | "pill" | "rounded";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
/** Neutral primary actions remain white/gray independently of the product accent. */
|
||||
tone?: "theme" | "neutral";
|
||||
size?: ButtonSize;
|
||||
width?: "auto" | "full";
|
||||
shape?: ButtonShape;
|
||||
accent?: RgbTuple;
|
||||
icon?: ReactNode;
|
||||
/** Pending state belongs to this action; dimensions and accessible name stay unchanged. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
|
||||
variant = "secondary",
|
||||
tone = "theme",
|
||||
size = "default",
|
||||
width = "auto",
|
||||
shape = "default",
|
||||
@@ -26,6 +32,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
children,
|
||||
style,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
const accentStyle = accent ? createAccentVariables(accent) : undefined;
|
||||
@@ -35,14 +43,21 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
type={type}
|
||||
className={cn("nodedc-button", className)}
|
||||
data-variant={variant}
|
||||
data-tone={tone === "theme" ? undefined : tone}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
data-width={width === "auto" ? undefined : width}
|
||||
data-shape={shape === "default" ? undefined : shape}
|
||||
style={accentStyle ? { ...accentStyle, ...style } : style}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || props["aria-busy"]}
|
||||
data-loading={loading || undefined}
|
||||
>
|
||||
<span className="nodedc-button__content">
|
||||
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
|
||||
{children}
|
||||
</span>
|
||||
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -50,6 +65,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string;
|
||||
shape?: "circle" | "rounded";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
|
||||
@@ -58,6 +74,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
return (
|
||||
@@ -69,8 +87,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || props["aria-busy"]}
|
||||
data-loading={loading || undefined}
|
||||
>
|
||||
{children}
|
||||
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "@nodedc/ui-core";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="nodedc-landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "./index.js";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "@nodedc/ui-core";
|
||||
|
||||
export interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: string;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: string,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
if (!mounted.current) return;
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
if (!mounted.current) return;
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
if (mounted.current) setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodedc-environment-media-playlist">
|
||||
<div className="nodedc-environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="nodedc-environment-media-playlist__items"
|
||||
onReorder={(items) => !disabled && onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="nodedc-environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
disabled={disabled}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
hint="Файл сохраняется в приложении. Ссылка должна вести прямо на изображение или видео по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="nodedc-environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="nodedc-environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="nodedc-environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="nodedc-environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "./index.js";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurface,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "@nodedc/ui-core";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor.js";
|
||||
|
||||
export interface EnvironmentSettingsWindowProps {
|
||||
productName: string;
|
||||
surfaces: readonly EnvironmentSurface[];
|
||||
initialSurfaceId?: string;
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: string,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: string,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: string,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
productName,
|
||||
surfaces,
|
||||
initialSurfaceId,
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState(initialSurfaceId ?? surfaces[0]?.id ?? "home");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = surfaces.map(surface => ({ value: surface.id, label: draft.pages[surface.id].headerLabel, description: surface.description }));
|
||||
const selectedSurface = surfaces.find(surface => surface.id === surfaceId) ?? surfaces[0];
|
||||
const quickActionWorkspaces = selectedSurface?.actions ?? [];
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "loading" || state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
const duplicateActions = Object.entries(draft.pages).find(([, page]) => (
|
||||
page.primaryWorkspaceId && page.primaryWorkspaceId === page.secondaryWorkspaceId
|
||||
));
|
||||
if (duplicateActions) {
|
||||
setSurfaceId(duplicateActions[0]);
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as string);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title={`Настройки ${productName}`}
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: productName,
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: productName.toUpperCase(),
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="neutral"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="nodedc-environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
disabled={busy}
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="nodedc-environment-settings__editor">
|
||||
<div className="nodedc-environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-environment-settings__copy">
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label={selectedSurface?.home ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
disabled={busy}
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
key={surfaceId}
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
Globe2,
|
||||
Image,
|
||||
Inbox,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LocateFixed,
|
||||
@@ -79,7 +81,9 @@ const icons = {
|
||||
grid: LayoutDashboard,
|
||||
image: Image,
|
||||
inbox: Inbox,
|
||||
info: Info,
|
||||
key: KeyRound,
|
||||
layers: Layers,
|
||||
list: ListChecks,
|
||||
target: LocateFixed,
|
||||
lock: LockKeyhole,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { EnvironmentPage } from "@nodedc/ui-core";
|
||||
import { Button } from "./Button.js";
|
||||
import { Icon, type IconName } from "./Icon.js";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia.js";
|
||||
export interface LandingStageProps {
|
||||
page: EnvironmentPage;
|
||||
pageId?: string;
|
||||
actions?: readonly { id: string; label: string; icon?: IconName; onSelect: () => void }[];
|
||||
status?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
export function LandingStage({ page, pageId = "home", actions = [], status, footer }: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="nodedc-landing-stage"
|
||||
data-page={pageId}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="nodedc-landing-stage__shade" aria-hidden="true" />
|
||||
<div className="nodedc-landing-stage__copy">
|
||||
<span className="nodedc-landing-stage__eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{actions.length ? (
|
||||
<div className="nodedc-landing-stage__actions">
|
||||
{actions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
tone="neutral"
|
||||
icon={workspace.icon ? <Icon name={workspace.icon} /> : undefined}
|
||||
onClick={() => workspace.onSelect()}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status ? <div className="nodedc-landing-stage__status">{status}</div> : null}
|
||||
{footer ? <footer className="nodedc-landing-stage__footer">{footer}</footer> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { ActivityIndicator } from "./ActivityIndicator.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface LoadingRegionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
loading: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Keeps content mounted and centers pending feedback inside its own bounds. */
|
||||
export function LoadingRegion({ loading, label, children, className, ...props }: LoadingRegionProps) {
|
||||
return (
|
||||
<div {...props} className={cn("nodedc-loading-region", className)} aria-busy={loading}>
|
||||
{children}
|
||||
{loading ? (
|
||||
<div className="nodedc-loading-region__status" role="status" aria-label={label}>
|
||||
<ActivityIndicator />
|
||||
<span aria-hidden="true">{label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface MediaSourceFieldProps {
|
||||
url: string;
|
||||
fileName?: string | null;
|
||||
uploading?: boolean;
|
||||
disabled?: boolean;
|
||||
previewSrc?: string | null;
|
||||
previewKind?: MediaPreviewKind | null;
|
||||
accept?: string;
|
||||
@@ -43,6 +44,7 @@ export function MediaSourceField({
|
||||
url,
|
||||
fileName,
|
||||
uploading = false,
|
||||
disabled = false,
|
||||
previewSrc,
|
||||
previewKind,
|
||||
accept = "image/*,video/*",
|
||||
@@ -73,11 +75,12 @@ export function MediaSourceField({
|
||||
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
|
||||
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
|
||||
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
|
||||
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
|
||||
<input id={inputId} type="file" accept={accept} disabled={disabled || uploading} onChange={handleFileChange} />
|
||||
</div>
|
||||
<input
|
||||
className="nodedc-media-url"
|
||||
type="url"
|
||||
disabled={disabled || uploading}
|
||||
value={url}
|
||||
hidden={source !== "url"}
|
||||
data-nodedc-media-source-panel="url"
|
||||
@@ -89,6 +92,7 @@ export function MediaSourceField({
|
||||
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "file" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="file"
|
||||
@@ -98,6 +102,7 @@ export function MediaSourceField({
|
||||
>HD</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "url" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="url"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dropdown } from "./Dropdown.js";
|
||||
|
||||
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
label: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -12,6 +13,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
||||
max?: number;
|
||||
};
|
||||
formatValue?: (value: number) => string;
|
||||
/** Unit-free endpoint labels beside the vertical scale. */
|
||||
formatLimit?: (value: number) => string;
|
||||
/** Physical side for vertical endpoint labels; use the inward side of a scene rail. */
|
||||
limitSide?: "left" | "right";
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
@@ -52,12 +57,15 @@ const editableNumber = (value: number) => (
|
||||
|
||||
export function RangeControl({
|
||||
label,
|
||||
orientation = "horizontal",
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
exactValueBounds,
|
||||
step,
|
||||
formatValue = String,
|
||||
formatLimit = String,
|
||||
limitSide = "right",
|
||||
onChange,
|
||||
className,
|
||||
disabled = false,
|
||||
@@ -132,6 +140,22 @@ export function RangeControl({
|
||||
}
|
||||
};
|
||||
|
||||
if (orientation === "vertical") return (
|
||||
<div className="nodedc-range-scale" data-limit-side={limitSide}>
|
||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-orientation="vertical">
|
||||
<input ref={rangeRef} {...props} type="range" value={value} min={min} max={safeMax} step={step}
|
||||
disabled={disabled} tabIndex={tabIndex} aria-label={label} aria-orientation="vertical"
|
||||
aria-valuetext={displayValue} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
<span className="nodedc-range__label">{label} · {displayValue}</span>
|
||||
<span className="nodedc-range__fill-text" aria-hidden="true">
|
||||
<span className="nodedc-range__label">{label} · {displayValue}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="nodedc-range-scale__limit nodedc-range-scale__limit--max">{formatLimit(max)}</span>
|
||||
<span className="nodedc-range-scale__limit nodedc-range-scale__limit--min">{formatLimit(min)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useContext, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useContext, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import type { FloatingPlacement } from "@nodedc/ui-core";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
import { IconButton } from "./Button.js";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
@@ -10,6 +11,7 @@ export interface SelectOption<T extends string> {
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
action?: { label: string; icon: ReactNode; disabled?: boolean; tone?: "neutral" | "danger"; onAction: () => void };
|
||||
}
|
||||
|
||||
export type SelectVariant = "integrated" | "split" | "inline";
|
||||
@@ -52,7 +54,11 @@ export function Select<T extends string>({
|
||||
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
||||
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
||||
const [query, setQuery] = useState("");
|
||||
const triggerButton = useRef<HTMLButtonElement | null>(null);
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
// Row actions are siblings, never nested interactive listbox options.
|
||||
const withActions = options.some((option) => option.action);
|
||||
const popupRole = withActions ? "dialog" : "listbox";
|
||||
const visibleOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return options;
|
||||
@@ -66,10 +72,15 @@ export function Select<T extends string>({
|
||||
minWidth={minMenuWidth}
|
||||
width={menuWidth}
|
||||
disabled={disabled}
|
||||
surfaceRole="listbox"
|
||||
surfaceRole={popupRole}
|
||||
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
triggerButton.current = event.currentTarget;
|
||||
toggle();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
triggerButton.current = event.currentTarget;
|
||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!open) toggle();
|
||||
@@ -88,11 +99,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className="nodedc-select__toggle"
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
|
||||
@@ -109,11 +120,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className={cn("nodedc-select-inline", triggerClassName)}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
||||
@@ -128,11 +139,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className={cn("nodedc-select-trigger", triggerClassName)}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : <span className="nodedc-select-trigger__icon" aria-hidden="true" />}
|
||||
@@ -151,12 +162,13 @@ export function Select<T extends string>({
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<div key={option.value} className="nodedc-select-option-row">
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
role={withActions ? undefined : "option"}
|
||||
aria-selected={withActions ? undefined : option.value === value}
|
||||
aria-pressed={withActions ? option.value === value : undefined}
|
||||
data-selected={option.value === value ? "true" : undefined}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
@@ -173,6 +185,20 @@ export function Select<T extends string>({
|
||||
</span>
|
||||
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true">•</span> : null}
|
||||
</button>
|
||||
{option.action ? <IconButton
|
||||
label={option.action.label}
|
||||
data-tone={option.action.tone ?? "neutral"}
|
||||
disabled={option.action.disabled}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
close();
|
||||
// A modal must restore focus to a surviving trigger, not this
|
||||
// row action which disappears when the menu closes.
|
||||
triggerButton.current?.focus();
|
||||
option.action?.onAction();
|
||||
}}
|
||||
>{option.action.icon}</IconButton> : null}
|
||||
</div>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <div className="nodedc-empty-state">{emptyLabel}</div> : null}
|
||||
</>
|
||||
|
||||
@@ -6,11 +6,12 @@ export interface SettingsCardProps extends Omit<HTMLAttributes<HTMLElement>, "ti
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
align?: "start" | "center";
|
||||
}
|
||||
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, className, ...props }: SettingsCardProps) {
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, align = "start", className, ...props }: SettingsCardProps) {
|
||||
return (
|
||||
<section className={cn("nodedc-settings-card", className)} {...props}>
|
||||
<section className={cn("nodedc-settings-card", className)} data-align={align === "center" ? "center" : undefined} {...props}>
|
||||
<header className="nodedc-settings-card__head">
|
||||
<div className="nodedc-settings-card__titles">
|
||||
{eyebrow ? <span>{eyebrow}</span> : null}
|
||||
@@ -19,7 +20,7 @@ export function SettingsCard({ eyebrow, title, description, actions, children, c
|
||||
</div>
|
||||
{actions ? <div className="nodedc-settings-card__actions">{actions}</div> : null}
|
||||
</header>
|
||||
<div className="nodedc-settings-card__body">{children}</div>
|
||||
{align === "center" && (children === undefined || children === null || children === false) ? null : <div className="nodedc-settings-card__body">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: StatusTone;
|
||||
variant?: "badge" | "indicator";
|
||||
/** Attention signal on the lamp only; respects reduced-motion preferences. */
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export function StatusBadge({ tone = "neutral", variant = "badge", className, children, ...props }: StatusBadgeProps) {
|
||||
export function StatusBadge({ tone = "neutral", variant = "badge", pulse = false, className, children, ...props }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} {...props}>
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} data-pulse={pulse ? "true" : undefined} {...props}>
|
||||
{variant === "indicator" ? null : children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./ActivityIndicator.js";
|
||||
export * from "./LoadingRegion.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationSidePanel.js";
|
||||
@@ -30,3 +31,7 @@ export * from "./Window.js";
|
||||
export * from "./WorkspaceWindow.js";
|
||||
|
||||
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
||||
export * from "./EnvironmentSettingsWindow.js";
|
||||
export * from "./EnvironmentMediaPlaylistEditor.js";
|
||||
export * from "./EnvironmentBackgroundMedia.js";
|
||||
export * from "./LandingStage.js";
|
||||
|
||||
+133
-16
@@ -71,17 +71,39 @@
|
||||
"id": "button",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["Button", "IconButton"],
|
||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||
"exports": [
|
||||
"Button",
|
||||
"IconButton"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-button",
|
||||
"nodedc-icon-button"
|
||||
],
|
||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||
"sizes": ["default", "compact", "dense"],
|
||||
"shapes": ["default", "pill", "rounded"],
|
||||
"variants": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"ghost",
|
||||
"danger",
|
||||
"accent"
|
||||
],
|
||||
"sizes": [
|
||||
"default",
|
||||
"compact",
|
||||
"dense"
|
||||
],
|
||||
"shapes": [
|
||||
"default",
|
||||
"pill",
|
||||
"rounded"
|
||||
],
|
||||
"rules": [
|
||||
"Icon-only actions are circular by default.",
|
||||
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
|
||||
"Filled accent actions derive foreground contrast from the actual accent.",
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message.",
|
||||
"loading displays one centered indicator inside the initiating action, disables repeat activation and preserves dimensions and accessible name. Never place its spinner beside or below the action group.",
|
||||
"Primary tone=neutral is white when enabled and gray when disabled in every theme/accent; use it for accent-independent operator actions without local CSS overrides."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -94,12 +116,27 @@
|
||||
"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.",
|
||||
"Use Button.loading or IconButton.loading for actions and LoadingRegion for content. Standalone ActivityIndicator is limited to an explicit inline status or resource-row slot.",
|
||||
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
||||
"The component never owns operation state, timing or completion.",
|
||||
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "loading-region",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["LoadingRegion", "LoadingRegionProps"],
|
||||
"domContract": ["nodedc-loading-region", "nodedc-loading-region__status"],
|
||||
"summary": "Centered pending feedback within the content region that owns the request, without unmounting content.",
|
||||
"rules": [
|
||||
"The consumer owns loading and its terminal result; no timer or implicit network operation exists in this component.",
|
||||
"Keep content mounted and reserve its dimensions while pending. Center the indicator within this region in normal and expanded layouts.",
|
||||
"A command belongs to its initiating Button.loading; do not duplicate that command in a region spinner.",
|
||||
"Remove loading on first usable content, failure or timeout. A live media connection alone is not usable content.",
|
||||
"Use only for blocking initial content or explicit recovery. Background refresh with usable content does not cover it."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field",
|
||||
"status": "baseline",
|
||||
@@ -109,7 +146,8 @@
|
||||
"summary": "Textual form controls with consistent labels, hints, geometry and focus surface.",
|
||||
"rules": [
|
||||
"Labels and validation copy are part of the field contract.",
|
||||
"Keyboard focus changes the surface and remains visible without a browser-blue outline."
|
||||
"Keyboard focus changes the surface and remains visible without a browser-blue outline.",
|
||||
"Browser autofill retains the canonical field material, text and caret; keyboard focus remains visible."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -131,10 +169,13 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["RangeControl"],
|
||||
"domContract": ["nodedc-range"],
|
||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||
"summary": "Filled pill range control with horizontal exact editing or a slim vertical scale with endpoints on the scene-inward side and a fixed centered label/value.",
|
||||
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
||||
"rules": [
|
||||
"The accent fill follows the active application theme.",
|
||||
"Vertical scales place formatLimit(min/max) on the configured scene-inward side (right by default, left with limitSide=left) in theme text, without units by default; label and formatValue(value) stay together at the center of the pill.",
|
||||
"The vertical shaft is 40 percent of control height (20 percent slimmer than the original half-width variant); center and endpoint typography are 90 percent of their sm/xs tokens.",
|
||||
"Central vertical text uses two identical layers clipped to the rounded fill, including partially covered glyphs. Vertical scales never have a perimeter border, outline or shadow, including focus; focus-visible underlines both central text layers with their existing contrast.",
|
||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||
"Exact editing stays inside the existing value area and never expands the control.",
|
||||
@@ -184,8 +225,10 @@
|
||||
"domExports": ["createSelectController"],
|
||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||
"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", "optional independent row action"],
|
||||
"rules": [
|
||||
"An option.action is a sibling canonical IconButton with an independent disabled state; it closes the menu without selecting the option. Action-bearing menus use dialog semantics instead of nesting interactive controls in listbox options. Confirmation and mutation remain consumer-owned.",
|
||||
"Select row actions match the compact Mission Core session-catalog reference: transparent 2.35rem hit column, 15px glyph, muted foreground and no round backing. An optional danger tone changes only hover/focus foreground; canonical keyboard focus remains visible.",
|
||||
"Integrated is the single-pill Hub/Launcher form.",
|
||||
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
||||
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
|
||||
@@ -385,6 +428,7 @@
|
||||
"variants": ["14 px", "16 px", "18 px"],
|
||||
"rules": [
|
||||
"Use semantic names from registry/icons.json.",
|
||||
"Use layers for visibility controls of map or visual source layers; settings remains general configuration.",
|
||||
"The containing component owns surface, hit target and state; the icon owns only the glyph and uses the canonical 16 px default without an extra CSS scale.",
|
||||
"Do not copy complete Lucide or Font Awesome catalogs into an application."
|
||||
]
|
||||
@@ -423,12 +467,28 @@
|
||||
"id": "media-source-field",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["MediaSourceField"],
|
||||
"exports": [
|
||||
"MediaSourceField"
|
||||
],
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createMediaSourceController"],
|
||||
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"],
|
||||
"domExports": [
|
||||
"createMediaSourceController"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-media-field",
|
||||
"nodedc-media-control",
|
||||
"nodedc-media-source-button",
|
||||
"nodedc-media-preview"
|
||||
],
|
||||
"summary": "Launcher/CMS media control for video, image or logo file/URL sources, filename, preview and adapter-provided storage state.",
|
||||
"behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
|
||||
"behavior": [
|
||||
"controlled file/url source",
|
||||
"native accessible file input",
|
||||
"URL input",
|
||||
"image/video preview",
|
||||
"path, hint and error slots",
|
||||
"controlled disabled state locks file, URL and source switching during a parent save/upload"
|
||||
],
|
||||
"rules": [
|
||||
"The component owns geometry, source switching and accessibility.",
|
||||
"The consumer owns media library, storage upload, validation and the persisted URL.",
|
||||
@@ -445,8 +505,10 @@
|
||||
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
||||
"rules": [
|
||||
"The card owns grouping geometry but not product form schemas.",
|
||||
"Use align=center for compact result or empty messages: center the content on both axes and omit an empty body. The default start layout remains unchanged.",
|
||||
"Section and empty-state titles use the compact md token; descriptions use sm. Page/window title sizes and browser heading defaults are forbidden inside cards.",
|
||||
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
|
||||
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment.",
|
||||
"Switch keeps its track width beside wrapped labels. The checked thumb uses on-accent contrast; the unchecked track follows theme text instead of a fixed white tint."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -456,7 +518,8 @@
|
||||
"exports": ["StatusBadge"],
|
||||
"variants": ["badge", "indicator"],
|
||||
"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.",
|
||||
"behavior": ["optional pulse animates only the status lamp", "pulse is static with reduced motion"]
|
||||
},
|
||||
{
|
||||
"id": "toast",
|
||||
@@ -508,6 +571,60 @@
|
||||
"The panel variant reuses AdminNavigationPanel pill, icon and theme tokens while retaining the same controlled accordion state and domain-owned content.",
|
||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "environment-settings",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"EnvironmentSettingsWindow",
|
||||
"EnvironmentMediaPlaylistEditor"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-environment-settings",
|
||||
"nodedc-environment-media-playlist"
|
||||
],
|
||||
"summary": "Shared avatar Settings → Environment composition for product/home copy, quick actions and image/video playlists.",
|
||||
"behavior": [
|
||||
"product-configured page list",
|
||||
"draft/reset/save",
|
||||
"upload adapter",
|
||||
"ordered media playlist",
|
||||
"image timing",
|
||||
"parent save/upload disabled state"
|
||||
],
|
||||
"rules": [
|
||||
"Reuse FeatureSettingsWindow and existing form primitives; never fork an application-specific settings form.",
|
||||
"Consumers own page/action IDs, storage, revision conflicts, authentication and upload URLs.",
|
||||
"A single-home application passes one surface; multi-section applications pass all admitted pages.",
|
||||
"Closing the window preserves host authority and never sends device commands."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "landing-stage",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"LandingStage",
|
||||
"EnvironmentBackgroundMedia"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-landing-stage",
|
||||
"nodedc-landing-stage__media"
|
||||
],
|
||||
"summary": "Shared product landing stage with editable copy, quick actions and ordered image/video background.",
|
||||
"behavior": [
|
||||
"image timer",
|
||||
"video end advance",
|
||||
"skip failed media",
|
||||
"flat empty background",
|
||||
"product status/footer slots"
|
||||
],
|
||||
"rules": [
|
||||
"No decorative radial gradient or invented graphic appears without configured media.",
|
||||
"Applications supply content and actions, not duplicate stage markup.",
|
||||
"Background playback and scene navigation never own device acquisition."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
"id": "window-layer",
|
||||
"label": "Окно и слой",
|
||||
"referenceSources": ["launcher", "engine"],
|
||||
"names": ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"]
|
||||
"names": ["close", "plus", "expand", "minimize", "refresh", "panel", "apps", "layers"]
|
||||
},
|
||||
{
|
||||
"id": "navigation",
|
||||
@@ -30,7 +30,7 @@
|
||||
"id": "state-access",
|
||||
"label": "Состояние и доступ",
|
||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||
"names": ["check", "alert", "info", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||
},
|
||||
{
|
||||
"id": "entities",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": {
|
||||
"architecture": "../docs/ARCHITECTURE.md",
|
||||
"components": "../docs/COMPONENTS.md",
|
||||
"loading": "../docs/LOADING_STATES.md",
|
||||
"theming": "../docs/THEMING.md",
|
||||
"operationalTypography": "../docs/OPERATIONAL_TYPOGRAPHY.md",
|
||||
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
||||
@@ -54,7 +55,8 @@
|
||||
"moduleFoundry": "../docs/MODULE_STUDIO.md",
|
||||
"mapTemplate": "../docs/MAP_TEMPLATE.md",
|
||||
"icons": "../docs/ICONS.md",
|
||||
"consumption": "../docs/CONSUMPTION.md"
|
||||
"consumption": "../docs/CONSUMPTION.md",
|
||||
"environmentSettings": "../docs/ENVIRONMENT_SETTINGS.md"
|
||||
},
|
||||
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
|
||||
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
|
||||
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
import { ActivityIndicator, Button, IconButton, LoadingRegion } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("ActivityIndicator separates decorative and announced progress", () => {
|
||||
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
|
||||
@@ -37,5 +37,29 @@ test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
|
||||
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" \/>\}/);
|
||||
assert.match(catalog, /<Button loading>/);
|
||||
assert.match(catalog, /<LoadingRegion loading/);
|
||||
});
|
||||
|
||||
|
||||
test("pending actions preserve their name and content while disabling only the pending control", () => {
|
||||
for (const [Component, props] of [[Button, {}], [IconButton, {label: "Обновить"}]]) {
|
||||
const busy = renderToStaticMarkup(createElement(Component, {...props, loading: true, disabled: false}, "Обновить"));
|
||||
const idle = renderToStaticMarkup(createElement(Component, props, "Обновить"));
|
||||
assert.match(busy, /disabled=""/);
|
||||
assert.match(busy, /aria-busy="true"/);
|
||||
assert.match(busy, /Обновить/);
|
||||
assert.equal((busy.match(/class="nodedc-activity-indicator"/g) || []).length, 1);
|
||||
assert.doesNotMatch(idle, /nodedc-action-loading|disabled=""/);
|
||||
}
|
||||
});
|
||||
|
||||
test("content remains mounted throughout loading and the status disappears on completion", () => {
|
||||
const view = createElement("video", {"data-source": "synthetic"});
|
||||
const pending = renderToStaticMarkup(createElement(LoadingRegion, {loading:true, label:"Ожидаем изображение"}, view));
|
||||
const ready = renderToStaticMarkup(createElement(LoadingRegion, {loading:false, label:"Ожидаем изображение"}, view));
|
||||
assert.match(pending, /<video data-source="synthetic"/);
|
||||
assert.match(ready, /<video data-source="synthetic"/);
|
||||
assert.equal((pending.match(/role="status"/g) || []).length, 1);
|
||||
assert.doesNotMatch(ready, /role="status"|nodedc-activity-indicator/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { appendEnvironmentMediaItem, removeEnvironmentMediaItem, cloneEnvironmentSettings } from "../packages/ui-core/dist/index.js";
|
||||
import { LandingStage, Button, MediaSourceField } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
const image = { id: "media-first", source: "url", url: "https://example.invalid/background.png", mediaKind: "image", fileName: null };
|
||||
const video = { ...image, id: "media-second", url: "https://example.invalid/background.mp4", mediaKind: "video" };
|
||||
const page = { headerLabel: "Product", eyebrow: "Board", title: "Home", description: "Local operator home", primaryWorkspaceId: null, secondaryWorkspaceId: null, background: { enabled: false, imageDurationSeconds: 10, items: [] } };
|
||||
|
||||
test("media edits preserve playback order and leave saved settings untouched", () => {
|
||||
const settings = { revision: 9, pages: { home: { ...page, background: appendEnvironmentMediaItem(page.background, image) } } };
|
||||
const draft = cloneEnvironmentSettings(settings);
|
||||
draft.pages.home.background = appendEnvironmentMediaItem(draft.pages.home.background, video);
|
||||
draft.pages.home.background.items[0].url = "https://example.invalid/edited.png";
|
||||
assert.equal(settings.pages.home.background.items[0].url, image.url);
|
||||
assert.deepEqual(draft.pages.home.background.items.map(item => item.id), [image.id, video.id]);
|
||||
const removed = removeEnvironmentMediaItem(draft.pages.home.background, image.id);
|
||||
assert.deepEqual(removed.items, [video]);
|
||||
assert.equal(removeEnvironmentMediaItem(removed, video.id).enabled, false);
|
||||
assert.equal(appendEnvironmentMediaItem(removed, video), removed);
|
||||
});
|
||||
|
||||
test("shared home shows real configured media, and an empty home creates no media", () => {
|
||||
const empty = renderToStaticMarkup(createElement(LandingStage, { page }));
|
||||
assert.match(empty, /<h1>Home<\/h1>/);
|
||||
assert.doesNotMatch(empty, /<(?:video|img)\b/);
|
||||
const configured = { ...page, background: { ...page.background, enabled: true, items: [video] } };
|
||||
const html = renderToStaticMarkup(createElement(LandingStage, { page: configured }));
|
||||
assert.match(html, /<video[^>]+src="https:\/\/example.invalid\/background.mp4"/);
|
||||
assert.match(html, /loop=""/);
|
||||
assert.match(html, /muted=""/);
|
||||
});
|
||||
|
||||
test("neutral primary retains native disabled semantics and theme default remains opt-in", () => {
|
||||
const disabled = renderToStaticMarkup(createElement(Button, { variant: "primary", tone: "neutral", disabled: true }, "Start"));
|
||||
assert.match(disabled, /data-tone="neutral"/);
|
||||
assert.match(disabled, /disabled=""/);
|
||||
const themed = renderToStaticMarkup(createElement(Button, { variant: "primary" }, "Start"));
|
||||
assert.doesNotMatch(themed, /data-tone/);
|
||||
const field = renderToStaticMarkup(createElement(MediaSourceField, { source: "url", url: "https://example.invalid/a.png", disabled: true, onSourceChange() {}, onUrlChange() {}, onFileChange() {} }));
|
||||
assert.match(field, /<input[^>]*disabled=""/);
|
||||
});
|
||||
@@ -23,6 +23,49 @@ test("RangeControl keeps native drag semantics and exposes one persistent exact-
|
||||
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
|
||||
});
|
||||
|
||||
test("vertical scale keeps endpoints outside and the label/value centered at every fill level", () => {
|
||||
for (const [value, progress] of [[-1.2, 0], [25.15, 50], [51.5, 100]]) {
|
||||
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||
label: "Срез", orientation: "vertical", value, min: -1.2, max: 51.5, step: "any",
|
||||
formatValue: (n) => `${n.toFixed(1).replace('.', ',')} м`,
|
||||
formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
|
||||
}));
|
||||
const renderedProgress = Number(markup.match(/--nodedc-range-progress:([\d.]+)%/)?.[1]);
|
||||
assert.ok(Math.abs(renderedProgress - progress) < 1e-9);
|
||||
assert.match(markup, /aria-label="Срез" aria-orientation="vertical"/);
|
||||
assert.match(markup, /min="-1.2" max="51.5" step="any"/);
|
||||
assert.equal((markup.match(/nodedc-range-scale__limit--max">51,5<\/span>/g) ?? []).length, 1);
|
||||
assert.equal((markup.match(/nodedc-range-scale__limit--min">-1,2<\/span>/g) ?? []).length, 1);
|
||||
assert.match(markup, /<\/span><\/span><\/div><span class="nodedc-range-scale__limit/);
|
||||
assert.equal((markup.match(/nodedc-range__label">Срез · /g) ?? []).length, 2);
|
||||
assert.match(markup, /nodedc-range__fill-text" aria-hidden="true"/);
|
||||
assert.doesNotMatch(markup, /nodedc-range__value|nodedc-range__editor|formatLimit=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("vertical scale mirrors only endpoint labels for the opposite scene edge", () => {
|
||||
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||
label: "Срез", orientation: "vertical", limitSide: "left", value: 25.15, min: -1.2, max: 51.5,
|
||||
step: "any", formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
|
||||
}));
|
||||
assert.match(markup, /class="nodedc-range-scale" data-limit-side="left"/);
|
||||
assert.match(markup, /nodedc-range-scale__limit--max">51,5<\/span>/);
|
||||
assert.match(markup, /nodedc-range-scale__limit--min">-1,2<\/span>/);
|
||||
});
|
||||
|
||||
test("vertical contrast clips both glyph layers and visible focus has no perimeter", async () => {
|
||||
const styles = await readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8");
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__fill-text \{ clip-path: inset\(calc\(100% - var\(--nodedc-range-progress\)\) 0 0 round var\(--nodedc-radius-circle\)\)/);
|
||||
assert.match(styles, /\.nodedc-range-scale \{[^}]*width: calc\(var\(--nodedc-control-height\) \* 0\.4\)/);
|
||||
assert.match(styles, /\.nodedc-range-scale__limit \{[^}]*left: calc\(100% \+ 0\.4rem\);[^}]*color: var\(--nodedc-text-primary\);[^}]*font-size: calc\(var\(--nodedc-font-size-xs\) \* 0\.9\)/);
|
||||
assert.match(styles, /\.nodedc-range-scale\[data-limit-side="left"\] \.nodedc-range-scale__limit \{[^}]*right: calc\(100% \+ 0\.4rem\);[^}]*left: auto;[^}]*text-align: right/);
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__label \{[^}]*font-size: calc\(var\(--nodedc-font-size-sm\) \* 0\.9\)/);
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \{[^}]*border: 0; outline: 0; box-shadow: none;/);
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \.nodedc-range__label \{ text-decoration: underline; text-decoration-thickness: 2px;/);
|
||||
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \{/);
|
||||
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:focus-within/);
|
||||
});
|
||||
|
||||
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
|
||||
const [component, styles, windowComponent] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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 { Select } from '../packages/ui-react/dist/index.js';
|
||||
|
||||
test('select action menus use dialog semantics in all variants; ordinary select stays a listbox', () => {
|
||||
for (const variant of ['integrated', 'split', 'inline']) {
|
||||
for (const withAction of [false, true]) {
|
||||
const markup = renderToStaticMarkup(createElement(Select, {
|
||||
label: 'Records', variant, value: 'one', onChange: () => {},
|
||||
options: [{value: 'one', label: 'One', ...(withAction ? {action: {label: 'Delete One', icon: 'x', onAction: () => {}}} : {})}],
|
||||
}));
|
||||
assert.match(markup, new RegExp(`aria-haspopup="${withAction ? 'dialog' : 'listbox'}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('row action is independent of selection, closes first and uses canonical IconButton', async () => {
|
||||
const source = await readFile(new URL('../packages/ui-react/src/Select.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /<\/button>\s*\{option\.action \? <IconButton/);
|
||||
assert.match(source, /disabled=\{option\.action\.disabled\}/);
|
||||
assert.match(source, /close\(\);[\s\S]*triggerButton.current\?\.focus\(\);\s*option\.action\?\.onAction\(\)/);
|
||||
assert.match(source, /role=\{withActions \? undefined : "option"\}/);
|
||||
});
|
||||
|
||||
test('row action matches the compact session catalog without a circular backing', async () => {
|
||||
const css = await readFile(new URL('../packages/ui-core/styles.css', import.meta.url), 'utf8');
|
||||
const rule = css.match(/\.nodedc-select-option-row > \.nodedc-icon-button \{([^}]+)\}/)?.[1];
|
||||
assert.match(rule, /width: 2\.35rem/);
|
||||
assert.match(rule, /height: auto/);
|
||||
assert.match(rule, /border-radius: 0/);
|
||||
assert.match(rule, /background: transparent/);
|
||||
assert.match(rule, /box-shadow: none/);
|
||||
assert.match(rule, /color: var\(--nodedc-text-muted\)/);
|
||||
assert.match(css, /\.nodedc-select-option-row > \.nodedc-icon-button > svg \{ width: 15px; height: 15px; transform: none/);
|
||||
assert.match(css, /data-tone="danger"\]\:focus-visible \{ color: rgb\(var\(--nodedc-danger-rgb\)\)/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFileSync} from "node:fs";
|
||||
import test from "node:test";
|
||||
import {createElement} from "react";
|
||||
import {renderToStaticMarkup} from "react-dom/server";
|
||||
import {StatusBadge} from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("status pulse is opt-in and does not replace the status text",()=>{
|
||||
const status=renderToStaticMarkup(createElement(StatusBadge,{tone:"danger",pulse:true},"Привязка потеряна"));
|
||||
assert.match(status,/data-pulse="true"/);
|
||||
assert.match(status,/Привязка потеряна/);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(StatusBadge,{},"Готово")),/data-pulse/);
|
||||
});
|
||||
test("only the lamp animates, with static reduced-motion fallback",()=>{
|
||||
const css=readFileSync(new URL("../packages/ui-core/styles.css",import.meta.url),"utf8");
|
||||
assert.match(css,/\.nodedc-status\[data-pulse="true"\]::before\s*\{[^}]*animation: nodedc-status-pulse/);
|
||||
assert.match(css,/@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-status\[data-pulse="true"\]::before \{ animation: none;/);
|
||||
});
|
||||
Reference in New Issue
Block a user