From ebae662b5295d33d1ee139878e34dabe25b7bf29 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 10 Jul 2026 12:41:12 +0300 Subject: [PATCH] Make application shell components package-native --- CHANGELOG.md | 10 + README.md | 5 +- apps/catalog/package.json | 6 +- apps/catalog/public/nodedc-logo.svg | 2 +- apps/catalog/src/CatalogApp.tsx | 105 +++-- apps/catalog/src/styles.css | 24 +- docs/ADOPTION.md | 5 +- docs/APPLICATION_TEMPLATE.md | 8 +- docs/ARCHITECTURE.md | 4 +- docs/CANDIDATES.md | 1 - docs/COMPONENTS.md | 14 +- docs/CONSUMPTION.md | 48 +++ docs/ICONS.md | 2 +- docs/SOURCE_BASELINE.md | 4 +- package-lock.json | 24 +- package.json | 2 +- packages/tokens/package.json | 2 +- packages/tokens/themes.css | 3 +- packages/tokens/tokens.css | 1 + packages/tokens/tokens.json | 1 + packages/ui-core/package.json | 4 +- packages/ui-core/src/index.ts | 6 +- packages/ui-core/src/theme.ts | 2 +- packages/ui-core/styles.css | 365 +++++++++++++++++- packages/ui-dom/package.json | 4 +- packages/ui-dom/src/applicationWorkspace.ts | 60 +++ packages/ui-dom/src/index.ts | 9 +- packages/ui-dom/src/mediaSource.ts | 55 +++ packages/ui-dom/src/select.ts | 2 +- packages/ui-react/package.json | 4 +- .../ui-react/src/AdminNavigationPanel.tsx | 2 +- packages/ui-react/src/AppHeader.tsx | 79 +++- packages/ui-react/src/ApplicationShell.tsx | 4 +- packages/ui-react/src/ApplicationWorkspace.ts | 67 ++++ packages/ui-react/src/Button.tsx | 2 +- packages/ui-react/src/Checker.tsx | 2 +- packages/ui-react/src/ConfirmationModal.tsx | 4 +- packages/ui-react/src/Dropdown.tsx | 2 +- packages/ui-react/src/Field.tsx | 2 +- packages/ui-react/src/Glass.tsx | 2 +- packages/ui-react/src/Inspector.tsx | 2 +- packages/ui-react/src/MediaSourceField.tsx | 118 ++++++ packages/ui-react/src/RangeControl.tsx | 2 +- packages/ui-react/src/SegmentedControl.tsx | 2 +- packages/ui-react/src/Select.tsx | 4 +- packages/ui-react/src/Settings.tsx | 48 +++ packages/ui-react/src/StatusBadge.tsx | 2 +- packages/ui-react/src/Window.tsx | 2 +- packages/ui-react/src/index.ts | 35 +- registry/candidates.json | 10 - registry/components.json | 43 ++- registry/registry.json | 5 +- 52 files changed, 1036 insertions(+), 185 deletions(-) create mode 100644 docs/CONSUMPTION.md create mode 100644 packages/ui-dom/src/applicationWorkspace.ts create mode 100644 packages/ui-dom/src/mediaSource.ts create mode 100644 packages/ui-react/src/ApplicationWorkspace.ts create mode 100644 packages/ui-react/src/MediaSourceField.tsx create mode 100644 packages/ui-react/src/Settings.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f46e8e..74a95ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.4.0 — package-native application contract + +- Corrected `AppHeader` against the production Launcher two-layer geometry: 68 px outer toolbar and 48 px visible row. +- Made the Launcher header preset strict and added reusable `HeaderNavigation`, workspace and profile primitives. +- Added `useApplicationWorkspace` and `createApplicationWorkspaceController` so navigation/content open, close and expand behavior is shared instead of reimplemented per application. +- Added `MediaSourceField` and a DOM media-source controller from the common Launcher/CMS file/URL/preview pattern. +- Added reusable `SettingsCard` and `Switch` patterns and an executable media/settings catalog section. +- Added the package consumption contract and recorded the boundary between reusable UI and application storage/domain adapters. +- Made all published ESM entrypoints directly importable outside Vite by emitting explicit `.js` module specifiers. + ## 0.3.0 — launcher application template - Rebuilt the living catalog as a Launcher-style windowed application instead of a long documentation page. diff --git a/README.md b/README.md index 48d3351..4a1346f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ - segmented navigation и статусы; - Hub header и левая admin navigation panel; - launcher-style ApplicationShell без обязательной нижней витрины; +- единый state-controller открытия navigation/content окон; +- CMS/Launcher media field: файл, URL, превью и storage-adapter; - аудированный общий набор иконок; - инспектор/панель настроек нового поколения; - поведение portal-слоёв, Escape, outside click и фокуса; @@ -49,7 +51,7 @@ npm run build ## Статус -Версия `0.3.x` — готовый launcher-style шаблон приложения, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются. +Версия `0.4.x` — пакетный Launcher-пресет шапки и оконного workspace, media/settings patterns, точная геометрия Hub/Engine и центральный каталог иконок. Исходные приложения на первом этапе не изменяются. Подробности: @@ -62,4 +64,5 @@ npm run build - [Границы исходного аудита](docs/SOURCE_BASELINE.md) - [Правила развития](docs/GOVERNANCE.md) - [План подключения приложений](docs/ADOPTION.md) +- [Как подключать пакеты в новый проект](docs/CONSUMPTION.md) - [Инвентарь следующих компонентов](docs/CANDIDATES.md) diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 54180d7..20961b2 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/ui-catalog", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "typecheck": "tsc -b --pretty false" }, "dependencies": { - "@nodedc/ui-core": "0.3.0", - "@nodedc/ui-react": "0.3.0", + "@nodedc/ui-core": "0.4.0", + "@nodedc/ui-react": "0.4.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/apps/catalog/public/nodedc-logo.svg b/apps/catalog/public/nodedc-logo.svg index ba1dbe5..92b19d8 100644 --- a/apps/catalog/public/nodedc-logo.svg +++ b/apps/catalog/public/nodedc-logo.svg @@ -1 +1 @@ - + diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index a7f1f61..21288c8 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -14,23 +14,28 @@ import { FieldFrame, GlassSurface, HeaderAvatar, + HeaderNavigation, HeaderProfile, HeaderProfileButton, HeaderWorkspace, Icon, IconButton, Inspector, + MediaSourceField, RangeControl, Select, + SettingsCard, StatusBadge, + Switch, TextAreaField, TextField, + useApplicationWorkspace, Window, WindowFooterActions, type IconName, } from "@nodedc/ui-react"; -type CatalogSection = "foundation" | "controls" | "windows" | "modals" | "inspector" | "icons"; +type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "inspector" | "icons"; const engineAccentStyle = { "--nodedc-accent-rgb": "148 123 219", @@ -63,26 +68,32 @@ const sectionDefinitions: Record("dark"); const [accentHex, setAccentHex] = useState("#c3ff66"); - const [guidelineOpen, setGuidelineOpen] = useState(false); - const [activeSection, setActiveSection] = useState(null); - const [panelExpanded, setPanelExpanded] = useState(true); + const workspace = useApplicationWorkspace(); + const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace; const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active"); const [checker, setChecker] = useState(true); const [brightness, setBrightness] = useState(49); @@ -216,6 +226,10 @@ export function CatalogApp() { const [confirmOpen, setConfirmOpen] = useState(false); const [projectName, setProjectName] = useState("NODE.DC Platform"); const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения."); + const [mediaSource, setMediaSource] = useState<"file" | "url">("file"); + const [mediaUrl, setMediaUrl] = useState(""); + const [mediaFileName, setMediaFileName] = useState("hub_vitrina-mqv7yd7k.mov"); + const [mediaVisible, setMediaVisible] = useState(true); const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]); @@ -224,16 +238,10 @@ export function CatalogApp() { }, [accent, theme]); const openSection = (next: string) => { - const section = next as CatalogSection; - setGuidelineOpen(true); - setActiveSection(section); - setPanelExpanded(true); + workspace.openView(next as CatalogSection); }; - const closeGuideline = () => { - setGuidelineOpen(false); - setActiveSection(null); - }; + const closeGuideline = workspace.closeNavigation; const inspectorSections = [ { @@ -403,6 +411,38 @@ export function CatalogApp() { ); + case "media": + return ( +
+ } + > + { + if (file) setMediaFileName(file.name); + }} + /> + + +

+ `MediaSourceField` владеет пикселями, доступностью и состояниями file/URL. Конкретный проект передаёт upload-обработчик, storage-path и результат валидации — поэтому компонент можно подключить к CMS, Launcher или будущему приложению без копирования его вёрстки. +

+
+
+ ); case "windows": return (
@@ -413,7 +453,7 @@ export function CatalogApp() {
- +
@@ -485,7 +525,7 @@ export function CatalogApp() { className="catalog-app nodedc-ui-root" style={{ "--catalog-accent": accentHex } as CSSProperties} navigationOpen={guidelineOpen} - contentOpen={Boolean(activeSection)} + contentOpen={workspace.contentOpen} contentExpanded={panelExpanded} header={ - - + + guidelineOpen ? closeGuideline() : workspace.openNavigation()} + /> } right={ - - Профиль + + Профиль } @@ -546,7 +581,7 @@ export function CatalogApp() { footer={ <> - Design System 0.3 + Design System 0.4 } /> @@ -558,8 +593,8 @@ export function CatalogApp() { title={activeDefinition.title} description={activeDefinition.description} expanded={panelExpanded} - onExpandedChange={setPanelExpanded} - onClose={() => setActiveSection(null)} + onExpandedChange={workspace.setContentExpanded} + onClose={workspace.closeView} >
{renderSectionContent()}
diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 76e6943..ab76fae 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -28,26 +28,6 @@ textarea { min-height: 100vh; } -.catalog-header-nav { - display: inline-flex; - min-height: var(--nodedc-header-pill-height); - align-items: center; - border-radius: var(--nodedc-radius-circle); - background: var(--nodedc-glass-panel-bg-soft); - padding: 0.32rem; - box-shadow: var(--nodedc-glass-control-shadow); -} - -.catalog-header-nav .nodedc-header__nav-item { - min-width: 8.5rem; -} - -.catalog-header-workspace img { - width: 1.75rem; - height: 1.75rem; - object-fit: contain; -} - [data-nodedc-theme="light"] .catalog-app .nodedc-header__brand img, [data-nodedc-theme="light"] .catalog-app .nodedc-header__workspace img { filter: brightness(0.12); @@ -413,11 +393,11 @@ textarea { display: none; } - .catalog-header-nav { + .catalog-app .nodedc-header-navigation { width: 100%; } - .catalog-header-nav .nodedc-header__nav-item { + .catalog-app .nodedc-header-navigation .nodedc-header__nav-item { width: 100%; } diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md index d7be58f..6ecb640 100644 --- a/docs/ADOPTION.md +++ b/docs/ADOPTION.md @@ -20,13 +20,14 @@ - Window/Confirmation; - AppHeader. - ApplicationShell/ApplicationPanel; +- useApplicationWorkspace; - Icon/IconButton. Локальные реализации удаляются только после функционального и визуального сравнения. ### 3. CMS и SEO -Используют те же компоненты и механику окон. CMS подключает DOM-layer, SEO — React-layer. Цветовые различия оформляются theme overrides. +Используют те же компоненты и механику окон. CMS подключает DOM-layer, SEO — React-layer. Первым общим admin pattern подключается `MediaSourceField`/`createMediaSourceController`; storage API остаётся в приложении. Цветовые различия оформляются theme overrides. ### 4. Engine @@ -47,6 +48,8 @@ - установить опубликованные NODE.DC UI packages; - выбрать theme и accent; - передать фиксированный AppHeader, stage, navigation и content; +- управлять shell через `useApplicationWorkspace`, а не набор локально связанных boolean; +- передавать storage/upload в `MediaSourceField` как adapter callback; - использовать Window/Dropdown и канонический Icon вместо локальных реализаций; - проверять registry перед созданием локального control; - хранить доменные компоненты у себя. diff --git a/docs/APPLICATION_TEMPLATE.md b/docs/APPLICATION_TEMPLATE.md index 2f7488e..60d44d2 100644 --- a/docs/APPLICATION_TEMPLATE.md +++ b/docs/APPLICATION_TEMPLATE.md @@ -4,13 +4,13 @@ ## Состав -1. `AppHeader` — фиксированная трёхосевая шапка `logo / workspace + navigation / profile`. +1. `AppHeader` — фиксированная трёхосевая шапка `logo / workspace + navigation / profile`; внешний toolbar `68 px`, видимая строка `48 px`. 2. `stage` — главное медиа- или предметное окно приложения. 3. `navigation` — левая панель шириной `clamp(332px, 19.5vw, 352px)`. 4. `content` — отдельное рабочее окно справа от навигации. 5. `ApplicationPanel` — заголовок, действия expand/close и независимо прокручиваемое тело контента. -Позиции шапки не пересчитываются под конкретное приложение. Меняются подписи, доступные navigation actions, профиль и содержимое stage. +Позиции шапки не пересчитываются под конкретное приложение. Меняются подписи, доступные navigation actions, профиль и содержимое stage. `HeaderNavigation`, `HeaderWorkspace`, `HeaderProfile`, `HeaderProfileButton` и `HeaderAvatar` должны использоваться как готовые части пресета; consumer не назначает им собственные размеры и offsets. ## Состояния desktop @@ -21,6 +21,8 @@ | Оконный режим | слева | средняя колонка | остаётся видимым справа | | Развёрнутый режим | слева | до правого края | уезжает за viewport | +Эти переходы принадлежат `useApplicationWorkspace` в React и `createApplicationWorkspaceController` в DOM. Оба API реализуют одинаковые команды: `openNavigation`, `closeNavigation`, `toggleNavigation`, `openView`, `closeView`, `setContentExpanded`. + Между панелями используется page gap `20px`. На Guideline-главной нижней rail нет: она является продуктовым элементом витрины Launcher, а не частью общего shell. ## Узкий viewport @@ -37,4 +39,4 @@ Modal остаётся portal-слоем. Его тело прокручивае - пункты navigation; - предметное содержимое content window. -Shell владеет геометрией, слоями, переходами и breakpoint-поведением. +Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8bffa7a..04be8d3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,11 +27,11 @@ Core не зависит от React. `@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке. -`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. +`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. ### DOM -`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`. +`@nodedc/ui-dom` предоставляет контроллеры для CMS, BIM Viewer и других приложений без React, включая те же workspace- и media-source state machines. DOM-слой не создаёт вторую стилизацию: он использует те же классы и токены `@nodedc/ui-core`. ### Catalog diff --git a/docs/CANDIDATES.md b/docs/CANDIDATES.md index 2e790db..5ffa313 100644 --- a/docs/CANDIDATES.md +++ b/docs/CANDIDATES.md @@ -14,7 +14,6 @@ Baseline намеренно отделяет готовые exports от изв - ServiceRailCard — зрелый Launcher pattern, пока связанный с service catalog data. - WorkItemCard — зрелый Ops pattern, который нужно разделить на общий card shell и task-domain content. -- MediaSourceField — общий preview/picker, но storage adapter остаётся в приложении. - Toast. - Tooltip. - Empty/Loading/Error states. diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 6478cdb..0c9bcf0 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -91,7 +91,7 @@ Pill navigation для верхней панели и компактного п - по центру workspace/navigation; - справа actions/profile. -Компонент фиксирует положение логотипа, высоту и выравнивание, но не знает маршруты конкретного приложения. +Компонент фиксирует положение логотипа, высоту и выравнивание, но не знает маршруты конкретного приложения. Launcher-пресет состоит из внешнего toolbar `68 px` и отдельной видимой строки `48 px`; логотип, центральный switcher и profile-group центрируются по строке `48 px`, а не по всей внешней высоте. Эта двухслойная геометрия закрыта внутри компонента и не переопределяется локальным `className/style`. В Hub-форме центр состоит из круглого workspace trigger и segmented navigation. Справа используется единая profile-group поверхность с иконкой, подписью и круглым avatar. Эти элементы нельзя заменять случайными standalone-кнопками, сохраняя только приблизительную позицию. @@ -108,8 +108,20 @@ Pill navigation для верхней панели и компактного п - на мобильном navigation и content используют всю область под шапкой последовательно; - нижняя service rail не входит в шаблон — это элемент витрины Launcher. +`useApplicationWorkspace` является каноническим контроллером этих состояний для React. Приложение не должно заново связывать набор локальных boolean-state для navigation/content/expand. DOM-проекты используют идентичный `createApplicationWorkspaceController`. + Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`. +## MediaSourceField + +Общий компонент Launcher/CMS для медиа-поля: file control, имя файла, URL input, переключатель `HD / URL`, круглое превью, path/hint/error. Публичный API контролируемый. + +Компонент владеет геометрией, доступностью и выбором источника. Приложение владеет file picker/media library, загрузкой в storage, валидацией, разрешениями и сохраняемым URL. Поэтому один и тот же компонент подключается к разным backend без fork. Для Vanilla DOM используется `createMediaSourceController`. + +## SettingsCard и Switch + +`SettingsCard` фиксирует нейтральную структуру админской группы: eyebrow/title/description/actions/body. `Switch` покрывает компактное включение/видимость внутри таких групп. Данные секции, сохранение и права остаются в consumer. + ## AdminNavigationPanel Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity. diff --git a/docs/CONSUMPTION.md b/docs/CONSUMPTION.md new file mode 100644 index 0000000..a3f1c6b --- /dev/null +++ b/docs/CONSUMPTION.md @@ -0,0 +1,48 @@ +# Подключение дизайн-системы + +## Главный принцип + +Новый проект не копирует исходники компонентов из Launcher, CMS или этого репозитория. Он устанавливает версию пакетов NODE.DC, импортирует общий CSS и собирает интерфейс из публичных exports. Обновление пакета должно исправлять геометрию во всех подключённых приложениях без повторной ручной вёрстки. + +`control-c / control-v` допустим только для короткой композиции приложения: какие routes и данные переданы готовым компонентам. CSS, DOM-анатомия header, оконные переходы, media control и accessibility не копируются. + +## React consumer + +Минимальный состав нового оконного приложения: + +1. `@nodedc/tokens`, `@nodedc/ui-core` и `@nodedc/ui-react` фиксируются одной версией. +2. Один раз подключаются `tokens.css`, `themes.css` и `@nodedc/ui-core/styles.css`. +3. Theme/accent задаются через `applyNodedcTheme`. +4. Приложение собирает `ApplicationShell` из `AppHeader`, `AdminNavigationPanel` и `ApplicationPanel`. +5. Все состояния shell берутся из `useApplicationWorkspace`. +6. Routes, profile, stage content и бизнес-данные передаются props. + +Нельзя добавлять local CSS к `AppHeader`, `HeaderNavigation`, `HeaderWorkspace`, `HeaderProfile` и `HeaderAvatar`. Если production Launcher изменяет канон, исправляется preset здесь и выпускается новая версия. + +## DOM consumer + +CMS/BIM подключают те же tokens/core styles и `@nodedc/ui-dom`. `createApplicationWorkspaceController`, `createMediaSourceController`, `createFloatingLayer` и `createModalController` связывают стабильную DOM-анатомию с тем же поведением, не вводя React в приложение. + +## Adapter boundary + +Общие компоненты не импортируют API конкретного проекта. + +| Библиотека владеет | Приложение владеет | +| --- | --- | +| размеры, радиусы, поверхности | маршруты и тексты | +| header/window/media DOM anatomy | API, auth и permissions | +| open/close/expand state machine | получение и сохранение данных | +| file/URL selector и preview | file picker/media library/storage upload | +| focus, Escape, portal, responsive flow | доменная валидация и ошибки backend | + +Например, `MediaSourceField` получает `onFileChange`. Consumer загружает файл, получает URL и возвращает его в контролируемое состояние. Компонент не знает S3, локальный диск, CMS media library или endpoint Launcher. + +## Definition of done для нового общего компонента + +- экспортируется из React или DOM package; +- использует tokens/core styles без screen-local geometry; +- показан в живом каталоге в ключевых состояниях; +- записан в `registry/components.json`; +- имеет документированную adapter boundary; +- проходит desktop/mobile visual QA; +- версия пакета зафиксирована consumer-ом. diff --git a/docs/ICONS.md b/docs/ICONS.md index f7971cc..c7798f2 100644 --- a/docs/ICONS.md +++ b/docs/ICONS.md @@ -7,7 +7,7 @@ | Контекст | Размер glyph | Hit target | | --- | ---: | ---: | | Плотная строка/label | `16px` | задаёт строка | -| Обычная кнопка | `18px` | `46.72px` круг или кнопка с текстом | +| Обычная кнопка | `18px` | `46px` круг или кнопка с текстом | | Крупное оконное действие | `20px` | `46.72px` круг | Базовый stroke — `1.8`. Иконка наследует `currentColor`. Фон и active/disabled/hover состояния принадлежат `IconButton`, `Button`, navigation item или другому компоненту поверхности. diff --git a/docs/SOURCE_BASELINE.md b/docs/SOURCE_BASELINE.md index b267e5c..226563f 100644 --- a/docs/SOURCE_BASELINE.md +++ b/docs/SOURCE_BASELINE.md @@ -17,6 +17,8 @@ `src/app/LauncherApp.tsx`, `src/widgets/service-stage/ServiceStage.tsx` и те же global styles подтверждают общий application shell: stage сдвигается при открытии navigation/content, а content разворачивается независимо. Нижняя ServiceRail исключена из общего шаблона как продуктовая часть витрины. +Точный header состоит из внешнего toolbar `68 px` и внутренней видимой строки `48 px`. Именно внутренняя строка задаёт вертикальный центр logo/workspace/navigation/profile. `AdminOverlay.tsx` также подтверждает media-source pattern: `3.35rem` control, `2.78rem` file/source/preview controls и разделение UI от storage handler. + Существовавший `dc-ui-guideline` использован как исходный инвентарь. Он больше не должен развиваться как независимая копия после подключения центрального репозитория. ## SEO и CMS @@ -25,7 +27,7 @@ SEO содержит большой объём накопленных локальных overrides. Они являются материалом аудита, но не переносятся автоматически. В библиотеку попадает очищенный общий контракт. -CMS подтверждает необходимость DOM-пакета без React. +CMS подтверждает необходимость DOM-пакета без React. `admin/admin.js` и `admin/admin.css` независимо повторяют устойчивый media pattern с file/URL source, круглым preview, path и upload hint; поэтому он перенесён в baseline, а не оставлен product candidate. ## Engine diff --git a/package-lock.json b/package-lock.json index f62c473..35fccfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodedc-design-guideline", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodedc-design-guideline", - "version": "0.3.0", + "version": "0.4.0", "workspaces": [ "packages/*", "apps/*" @@ -25,10 +25,10 @@ }, "apps/catalog": { "name": "@nodedc/ui-catalog", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { - "@nodedc/ui-core": "0.3.0", - "@nodedc/ui-react": "0.3.0", + "@nodedc/ui-core": "0.4.0", + "@nodedc/ui-react": "0.4.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -1888,27 +1888,27 @@ }, "packages/tokens": { "name": "@nodedc/tokens", - "version": "0.3.0" + "version": "0.4.0" }, "packages/ui-core": { "name": "@nodedc/ui-core", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { - "@nodedc/tokens": "0.3.0" + "@nodedc/tokens": "0.4.0" } }, "packages/ui-dom": { "name": "@nodedc/ui-dom", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { - "@nodedc/ui-core": "0.3.0" + "@nodedc/ui-core": "0.4.0" } }, "packages/ui-react": { "name": "@nodedc/ui-react", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { - "@nodedc/ui-core": "0.3.0", + "@nodedc/ui-core": "0.4.0", "lucide-react": "^0.468.0" }, "devDependencies": { diff --git a/package.json b/package.json index c7152dc..5ebe0f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodedc-design-guideline", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.", "type": "module", diff --git a/packages/tokens/package.json b/packages/tokens/package.json index 7e2cb2a..4d89978 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/tokens", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "files": ["tokens.json", "tokens.css", "themes.css"], "exports": { diff --git a/packages/tokens/themes.css b/packages/tokens/themes.css index 294328d..66a5b82 100644 --- a/packages/tokens/themes.css +++ b/packages/tokens/themes.css @@ -17,6 +17,7 @@ --nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72); --nodedc-glass-panel-bg-strong: rgba(18, 18, 21, 0.88); --nodedc-glass-panel-bg-soft: rgba(42, 42, 46, 0.5); + --nodedc-header-pill-bg: rgba(64, 64, 64, 0.48); --nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.014)), var(--nodedc-glass-panel-bg); --nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)), var(--nodedc-glass-panel-bg-strong); --nodedc-glass-control-bg: rgba(110, 110, 110, 0.07); @@ -50,6 +51,7 @@ --nodedc-glass-panel-bg: rgba(255, 255, 255, 0.48); --nodedc-glass-panel-bg-strong: rgba(255, 255, 255, 0.72); --nodedc-glass-panel-bg-soft: rgba(255, 255, 255, 0.34); + --nodedc-header-pill-bg: rgba(255, 255, 255, 0.56); --nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.22)), var(--nodedc-glass-panel-bg); --nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.34)), var(--nodedc-glass-panel-bg-strong); --nodedc-glass-control-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.22)), rgba(255, 255, 255, 0.38); @@ -64,4 +66,3 @@ --nodedc-glass-dropdown-shadow: 0 22px 60px rgba(24, 32, 29, 0.2), 0 6px 18px rgba(24, 32, 29, 0.1); --nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.1); } - diff --git a/packages/tokens/tokens.css b/packages/tokens/tokens.css index f55cf84..e501077 100644 --- a/packages/tokens/tokens.css +++ b/packages/tokens/tokens.css @@ -13,6 +13,7 @@ --nodedc-icon-button-size: 2.875rem; --nodedc-header-row-height: 3rem; --nodedc-header-pill-height: 3.45rem; + --nodedc-header-control-height: 2.78rem; --nodedc-header-logo-width: 7.25rem; --nodedc-header-logo-height: 1.79rem; --nodedc-inspector-width: 24.375rem; diff --git a/packages/tokens/tokens.json b/packages/tokens/tokens.json index 855aefc..42b6abf 100644 --- a/packages/tokens/tokens.json +++ b/packages/tokens/tokens.json @@ -24,6 +24,7 @@ "iconButton": { "$type": "dimension", "$value": "46px" }, "headerRow": { "$type": "dimension", "$value": "48px" }, "headerPill": { "$type": "dimension", "$value": "55.2px" }, + "headerControl": { "$type": "dimension", "$value": "44.48px" }, "logoWidth": { "$type": "dimension", "$value": "116px" }, "logoHeight": { "$type": "dimension", "$value": "28.64px" }, "inspectorWidth": { "$type": "dimension", "$value": "390px" }, diff --git a/packages/ui-core/package.json b/packages/ui-core/package.json index f8ead0e..1f87604 100644 --- a/packages/ui-core/package.json +++ b/packages/ui-core/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/ui-core", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "files": ["dist", "styles.css"], "main": "./dist/index.js", @@ -17,6 +17,6 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@nodedc/tokens": "0.3.0" + "@nodedc/tokens": "0.4.0" } } diff --git a/packages/ui-core/src/index.ts b/packages/ui-core/src/index.ts index e66fe6e..5c46ad5 100644 --- a/packages/ui-core/src/index.ts +++ b/packages/ui-core/src/index.ts @@ -1,4 +1,4 @@ -export * from "./accent"; -export * from "./floating"; -export * from "./theme"; +export * from "./accent.js"; +export * from "./floating.js"; +export * from "./theme.js"; diff --git a/packages/ui-core/src/theme.ts b/packages/ui-core/src/theme.ts index 7874dec..c1bd096 100644 --- a/packages/ui-core/src/theme.ts +++ b/packages/ui-core/src/theme.ts @@ -1,4 +1,4 @@ -import { createAccentVariables, type RgbTuple } from "./accent"; +import { createAccentVariables, type RgbTuple } from "./accent.js"; export type NodedcTheme = "dark" | "light"; diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 3eae4f6..9043eee 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -277,6 +277,299 @@ textarea.nodedc-field__control { resize: vertical; } +.nodedc-media-field { + display: grid; + min-width: 0; + gap: 0.42rem; +} + +.nodedc-media-field__label-row { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: var(--nodedc-space-2); + padding-inline: 0.15rem; +} + +.nodedc-media-field__label, +.nodedc-media-field__kind { + color: var(--nodedc-text-secondary); + font-size: var(--nodedc-font-size-sm); + font-weight: var(--nodedc-font-weight-medium); +} + +.nodedc-media-field__kind { + border-radius: var(--nodedc-radius-circle); + background: rgba(255, 255, 255, 0.07); + padding: 0.16rem 0.56rem; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.05em; + line-height: 1; + text-transform: uppercase; +} + +.nodedc-media-control { + display: grid; + min-height: 3.35rem; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.22rem; + overflow: hidden; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-field-bg); + padding: 0.25rem; + box-shadow: var(--nodedc-glass-control-shadow); +} + +.nodedc-media-file { + display: flex; + min-width: 0; + height: 100%; + align-items: center; + gap: 0.7rem; + padding-left: 0.15rem; +} + +.nodedc-media-file[hidden], +.nodedc-media-url[hidden] { + display: none; +} + +.nodedc-media-file input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.nodedc-media-file__button { + display: inline-flex; + min-height: 2.78rem; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: var(--nodedc-radius-circle); + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); + padding: 0 1rem; + font-size: 0.78rem; + font-weight: 850; + white-space: nowrap; + cursor: pointer; +} + +.nodedc-media-file__button:hover { + background: #fff; +} + +.nodedc-media-file__name { + min-width: 0; + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.78rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodedc-media-url { + min-width: 0; + height: 100%; + border: 0; + border-radius: var(--nodedc-radius-circle); + outline: 0; + background: transparent; + color: var(--nodedc-text-primary); + padding: 0 0.82rem; + font: inherit; + font-size: 0.78rem; +} + +.nodedc-media-url::placeholder { + color: var(--nodedc-text-muted); +} + +.nodedc-media-source-switch { + display: inline-flex; + align-items: center; + gap: 0.12rem; +} + +.nodedc-media-source-button { + display: grid; + width: 2.78rem; + height: 2.78rem; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: var(--nodedc-radius-circle); + background: transparent; + color: var(--nodedc-text-secondary); + font: inherit; + font-size: 0.68rem; + font-weight: 800; + cursor: pointer; +} + +.nodedc-media-source-button:hover { + background: var(--nodedc-glass-control-hover); + color: var(--nodedc-text-primary); +} + +.nodedc-media-source-button[data-active="true"] { + border-color: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); +} + +.nodedc-media-preview { + position: relative; + display: grid; + width: 2.78rem; + min-width: 2.78rem; + height: 2.78rem; + place-items: center; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--nodedc-radius-circle); + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.16) 75%), + linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.16) 75%), + rgba(18, 18, 20, 0.92); + background-position: 0 0, 6px 6px, 0 0; + background-size: 12px 12px, 12px 12px, auto; + color: var(--nodedc-text-secondary); +} + +.nodedc-media-preview img, +.nodedc-media-preview video { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} + +.nodedc-media-field__path, +.nodedc-media-field__hint, +.nodedc-media-field__error { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + line-height: 1.35; +} + +.nodedc-media-field__error { + color: rgb(var(--nodedc-danger-rgb)); +} + +.nodedc-settings-card { + display: grid; + min-width: 0; + gap: 1rem; + border-radius: var(--nodedc-radius-card); + background: var(--nodedc-glass-control-bg); + padding: 1rem; +} + +.nodedc-settings-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.nodedc-settings-card__titles { + display: grid; + min-width: 0; + gap: 0.22rem; +} + +.nodedc-settings-card__titles > span { + color: var(--nodedc-text-muted); + font-size: 0.66rem; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.nodedc-settings-card__titles h2, +.nodedc-settings-card__titles p { + margin: 0; +} + +.nodedc-settings-card__titles h2 { + color: var(--nodedc-text-primary); + font-size: 1rem; + line-height: 1.25; +} + +.nodedc-settings-card__titles p { + color: var(--nodedc-text-muted); + font-size: 0.75rem; + line-height: 1.4; +} + +.nodedc-settings-card__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.4rem; +} + +.nodedc-settings-card__body { + display: grid; + min-width: 0; + gap: 0.8rem; +} + +.nodedc-switch { + display: inline-flex; + width: max-content; + align-items: center; + gap: 0.48rem; + border: 0; + background: transparent; + color: var(--nodedc-text-secondary); + font: inherit; + font-size: var(--nodedc-font-size-sm); + font-weight: var(--nodedc-font-weight-strong); + cursor: pointer; +} + +.nodedc-switch__track { + display: flex; + width: 2rem; + height: 1.12rem; + align-items: center; + border-radius: var(--nodedc-radius-circle); + background: rgba(255, 255, 255, 0.14); + padding: 0.14rem; + transition: background var(--nodedc-duration-normal) var(--nodedc-ease-standard); +} + +.nodedc-switch__track > span { + width: 0.84rem; + height: 0.84rem; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-text-primary); + transition: transform var(--nodedc-duration-normal) var(--nodedc-ease-standard); +} + +.nodedc-switch[aria-checked="true"] .nodedc-switch__track { + background: rgb(var(--nodedc-accent-rgb)); +} + +.nodedc-switch[aria-checked="true"] .nodedc-switch__track > span { + transform: translateX(0.88rem); +} + .nodedc-checker { display: grid; width: 100%; @@ -541,8 +834,16 @@ textarea.nodedc-field__control { } .nodedc-header { - display: grid; + display: flex; min-height: 4.25rem; + width: 100%; + flex-direction: column; +} + +.nodedc-header__row { + display: grid; + min-height: var(--nodedc-header-row-height); + width: 100%; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: var(--nodedc-space-4); @@ -589,26 +890,27 @@ textarea.nodedc-field__control { .nodedc-header__profile { display: inline-flex; + height: var(--nodedc-header-pill-height); min-height: var(--nodedc-header-pill-height); align-items: center; gap: 0.2rem; border-radius: var(--nodedc-radius-circle); - background: var(--nodedc-glass-panel-bg-soft); + background: var(--nodedc-header-pill-bg); padding: 0.32rem; box-shadow: var(--nodedc-glass-control-shadow); } .nodedc-header__profile .nodedc-icon-button { - width: var(--nodedc-header-row-height); - height: var(--nodedc-header-row-height); - flex-basis: var(--nodedc-header-row-height); + width: var(--nodedc-header-control-height); + height: var(--nodedc-header-control-height); + flex-basis: var(--nodedc-header-control-height); background: transparent; box-shadow: none; } .nodedc-header__profile-button { display: inline-flex; - min-height: calc(var(--nodedc-header-pill-height) - 0.64rem); + min-height: var(--nodedc-header-control-height); align-items: center; justify-content: center; gap: 0.45rem; @@ -639,8 +941,8 @@ textarea.nodedc-field__control { overflow: hidden; border-radius: var(--nodedc-radius-circle); background: - radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.72), transparent 24%), - linear-gradient(135deg, rgb(166, 194, 109), rgb(142, 123, 139)); + radial-gradient(circle at 70% 18%, rgba(255, 255, 255, 0.8), transparent 24%), + linear-gradient(135deg, #c8c9cb, #8d8f96 55%, #f1f1f1); color: rgba(8, 8, 10, 0.96); font-size: 0.72rem; font-weight: 800; @@ -660,15 +962,23 @@ textarea.nodedc-field__control { place-items: center; overflow: hidden; border-radius: var(--nodedc-radius-circle); - background: var(--nodedc-canvas); + background: transparent; color: var(--nodedc-text-primary); font-size: var(--nodedc-font-size-sm); font-weight: var(--nodedc-font-weight-strong); } .nodedc-header__workspace img { + display: block; + width: 1.75rem; + height: 1.75rem; + object-fit: contain; +} + +.nodedc-header__workspace[data-kind="avatar"] img { width: 100%; height: 100%; + border-radius: inherit; object-fit: cover; } @@ -676,9 +986,20 @@ textarea.nodedc-field__control { display: contents; } +.nodedc-header-navigation { + display: inline-flex; + min-height: var(--nodedc-header-pill-height); + align-items: center; + gap: 0.18rem; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-header-pill-bg); + padding: 0.32rem; + box-shadow: none; +} + .nodedc-header__nav-item { display: inline-flex; - min-height: calc(var(--nodedc-header-pill-height) - 0.64rem); + min-height: var(--nodedc-header-control-height); align-items: center; justify-content: center; border-radius: var(--nodedc-radius-circle); @@ -1741,6 +2062,10 @@ textarea.nodedc-field__control { .nodedc-header { min-height: 8.6rem; + } + + .nodedc-header__row { + min-height: 8.6rem; grid-template-columns: minmax(0, 1fr) auto; align-content: start; row-gap: 0.65rem; @@ -1753,7 +2078,8 @@ textarea.nodedc-field__control { overflow-x: auto; } - .nodedc-header__center .nodedc-segmented { + .nodedc-header__center .nodedc-segmented, + .nodedc-header__center .nodedc-header-navigation { min-width: max-content; } @@ -1823,6 +2149,21 @@ textarea.nodedc-field__control { align-items: stretch; gap: var(--nodedc-space-2); } + + .nodedc-media-control { + grid-template-columns: minmax(0, 1fr) auto; + border-radius: var(--nodedc-radius-card); + } + + .nodedc-media-file, + .nodedc-media-url { + grid-column: 1 / -1; + min-height: 2.78rem; + } + + .nodedc-media-source-switch { + justify-self: start; + } } @media (prefers-reduced-motion: reduce) { @@ -1837,6 +2178,8 @@ textarea.nodedc-field__control { .nodedc-button, .nodedc-icon-button, .nodedc-checker, + .nodedc-media-source-button, + .nodedc-switch, .nodedc-select-trigger, .nodedc-segmented__item { transition: none; diff --git a/packages/ui-dom/package.json b/packages/ui-dom/package.json index 18e7731..e41685b 100644 --- a/packages/ui-dom/package.json +++ b/packages/ui-dom/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/ui-dom", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "files": ["dist"], "main": "./dist/index.js", @@ -16,6 +16,6 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@nodedc/ui-core": "0.3.0" + "@nodedc/ui-core": "0.4.0" } } diff --git a/packages/ui-dom/src/applicationWorkspace.ts b/packages/ui-dom/src/applicationWorkspace.ts new file mode 100644 index 0000000..60434a6 --- /dev/null +++ b/packages/ui-dom/src/applicationWorkspace.ts @@ -0,0 +1,60 @@ +export interface ApplicationWorkspaceSnapshot { + navigationOpen: boolean; + activeView: ViewId | null; + contentOpen: boolean; + contentExpanded: boolean; +} + +export interface ApplicationWorkspaceController { + getState: () => ApplicationWorkspaceSnapshot; + openNavigation: () => void; + toggleNavigation: () => void; + closeNavigation: () => void; + openView: (view: ViewId) => void; + closeView: () => void; + setContentExpanded: (expanded: boolean) => void; +} + +export interface ApplicationWorkspaceOptions { + root?: HTMLElement; + navigationOpen?: boolean; + activeView?: ViewId | null; + contentExpanded?: boolean; + onChange?: (state: ApplicationWorkspaceSnapshot) => void; +} + +export function createApplicationWorkspaceController({ + root, + navigationOpen = false, + activeView = null, + contentExpanded = true, + onChange, +}: ApplicationWorkspaceOptions = {}): ApplicationWorkspaceController { + let state: ApplicationWorkspaceSnapshot = { + navigationOpen: navigationOpen || activeView !== null, + activeView, + contentOpen: activeView !== null, + contentExpanded, + }; + + const commit = (patch: Partial>) => { + state = { ...state, ...patch }; + state.contentOpen = state.activeView !== null; + root?.toggleAttribute("data-navigation-open", state.navigationOpen); + root?.toggleAttribute("data-content-open", state.contentOpen); + root?.toggleAttribute("data-content-expanded", state.contentOpen && state.contentExpanded); + onChange?.({ ...state }); + }; + + commit({}); + + return { + getState: () => ({ ...state }), + openNavigation: () => commit({ navigationOpen: true }), + toggleNavigation: () => commit({ navigationOpen: !state.navigationOpen, activeView: state.navigationOpen ? null : state.activeView }), + closeNavigation: () => commit({ navigationOpen: false, activeView: null }), + openView: (view) => commit({ navigationOpen: true, activeView: view, contentExpanded: true }), + closeView: () => commit({ activeView: null }), + setContentExpanded: (expanded) => commit({ contentExpanded: expanded }), + }; +} diff --git a/packages/ui-dom/src/index.ts b/packages/ui-dom/src/index.ts index 50bf9f8..72ab495 100644 --- a/packages/ui-dom/src/index.ts +++ b/packages/ui-dom/src/index.ts @@ -1,5 +1,6 @@ export { applyNodedcTheme, clearNodedcAccent, createAccentVariables, readableTextRgb } from "@nodedc/ui-core"; -export * from "./floating"; -export * from "./modal"; -export * from "./select"; - +export * from "./applicationWorkspace.js"; +export * from "./floating.js"; +export * from "./mediaSource.js"; +export * from "./modal.js"; +export * from "./select.js"; diff --git a/packages/ui-dom/src/mediaSource.ts b/packages/ui-dom/src/mediaSource.ts new file mode 100644 index 0000000..cac93ce --- /dev/null +++ b/packages/ui-dom/src/mediaSource.ts @@ -0,0 +1,55 @@ +export type MediaSource = "file" | "url"; + +export interface MediaSourceController { + getSource: () => MediaSource; + setSource: (source: MediaSource) => void; + destroy: () => void; +} + +export interface MediaSourceControllerOptions { + root: HTMLElement; + source?: MediaSource; + onChange?: (source: MediaSource) => void; +} + +export function createMediaSourceController({ root, source = "file", onChange }: MediaSourceControllerOptions): MediaSourceController { + const options = Array.from(root.querySelectorAll("[data-nodedc-media-source-option]")); + const panels = Array.from(root.querySelectorAll("[data-nodedc-media-source-panel]")); + let current = source; + + const render = () => { + root.dataset.source = current; + options.forEach((option) => { + const active = option.dataset.nodedcMediaSourceOption === current; + option.dataset.active = active ? "true" : "false"; + option.setAttribute("aria-pressed", String(active)); + }); + panels.forEach((panel) => { + panel.hidden = panel.dataset.nodedcMediaSourcePanel !== current; + }); + }; + + const setSource = (next: MediaSource) => { + if (next === current) return; + current = next; + render(); + onChange?.(current); + }; + + const handlers = options.map((option) => { + const handler = () => { + const next = option.dataset.nodedcMediaSourceOption; + if (next === "file" || next === "url") setSource(next); + }; + option.addEventListener("click", handler); + return { option, handler }; + }); + + render(); + + return { + getSource: () => current, + setSource, + destroy: () => handlers.forEach(({ option, handler }) => option.removeEventListener("click", handler)), + }; +} diff --git a/packages/ui-dom/src/select.ts b/packages/ui-dom/src/select.ts index fc60fde..638a284 100644 --- a/packages/ui-dom/src/select.ts +++ b/packages/ui-dom/src/select.ts @@ -1,4 +1,4 @@ -import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating"; +import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating.js"; export interface SelectControllerOptions extends Omit { value: T; diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 2c7480a..a6a1d75 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/ui-react", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "files": ["dist"], "main": "./dist/index.js", @@ -16,7 +16,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@nodedc/ui-core": "0.3.0", + "@nodedc/ui-core": "0.4.0", "lucide-react": "^0.468.0" }, "peerDependencies": { diff --git a/packages/ui-react/src/AdminNavigationPanel.tsx b/packages/ui-react/src/AdminNavigationPanel.tsx index 669a7e2..a91aa1d 100644 --- a/packages/ui-react/src/AdminNavigationPanel.tsx +++ b/packages/ui-react/src/AdminNavigationPanel.tsx @@ -1,5 +1,5 @@ import type { HTMLAttributes, ReactNode } from "react"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export interface AdminNavigationContext { id: string; diff --git a/packages/ui-react/src/AppHeader.tsx b/packages/ui-react/src/AppHeader.tsx index c8cf9d8..88c3df8 100644 --- a/packages/ui-react/src/AppHeader.tsx +++ b/packages/ui-react/src/AppHeader.tsx @@ -1,11 +1,9 @@ -import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; -import { cn } from "./cn"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; -export interface AppHeaderProps extends HTMLAttributes { +export interface AppHeaderProps { brand: ReactNode; brandHref?: string; brandLabel?: string; - fixed?: boolean; left?: ReactNode; center?: ReactNode; right?: ReactNode; @@ -15,12 +13,9 @@ export function AppHeader({ brand, brandHref, brandLabel = "NODE.DC", - fixed = true, left, center, right, - className, - ...props }: AppHeaderProps) { const brandNode = brandHref ? ( {brand} @@ -29,45 +24,87 @@ export function AppHeader({ ); return ( -
+
-
{brandNode}{left}
-
{center}
-
{right}
+
+
{brandNode}{left}
+
{center}
+
{right}
+
); } -export function HeaderProfile({ children, className, ...props }: HTMLAttributes) { - return
{children}
; +export interface HeaderNavigationItem { + value: T; + label: ReactNode; + disabled?: boolean; } -export function HeaderProfileButton({ className, children, type = "button", ...props }: ButtonHTMLAttributes) { - return ; +export interface HeaderNavigationProps { + label: string; + value?: T; + items: readonly HeaderNavigationItem[]; + onChange: (value: T) => void; } -export interface HeaderAvatarProps extends HTMLAttributes { +export function HeaderNavigation({ label, value, items, onChange }: HeaderNavigationProps) { + return ( + + ); +} + +export interface HeaderProfileProps { + children: ReactNode; +} + +export function HeaderProfile({ children }: HeaderProfileProps) { + return
{children}
; +} + +export interface HeaderProfileButtonProps extends Omit, "className" | "style"> {} + +export function HeaderProfileButton({ children, type = "button", ...props }: HeaderProfileButtonProps) { + return ; +} + +export interface HeaderAvatarProps { label: string; imageUrl?: string; } -export function HeaderAvatar({ label, imageUrl, className, ...props }: HeaderAvatarProps) { +export function HeaderAvatar({ label, imageUrl }: HeaderAvatarProps) { return ( - + {imageUrl ? : label.slice(0, 2).toUpperCase()} ); } -export interface HeaderWorkspaceProps extends HTMLAttributes { +export interface HeaderWorkspaceProps { label: string; imageUrl?: string; + kind?: "mark" | "avatar"; } -export function HeaderWorkspace({ label, imageUrl, className, ...props }: HeaderWorkspaceProps) { +export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) { return ( - + {imageUrl ? : label.slice(0, 2).toUpperCase()} ); diff --git a/packages/ui-react/src/ApplicationShell.tsx b/packages/ui-react/src/ApplicationShell.tsx index 371cf27..1530671 100644 --- a/packages/ui-react/src/ApplicationShell.tsx +++ b/packages/ui-react/src/ApplicationShell.tsx @@ -1,6 +1,6 @@ import type { HTMLAttributes, ReactNode } from "react"; -import { cn } from "./cn"; -import { Icon } from "./Icon"; +import { cn } from "./cn.js"; +import { Icon } from "./Icon.js"; export interface ApplicationShellProps extends Omit, "content"> { header: ReactNode; diff --git a/packages/ui-react/src/ApplicationWorkspace.ts b/packages/ui-react/src/ApplicationWorkspace.ts new file mode 100644 index 0000000..70e824d --- /dev/null +++ b/packages/ui-react/src/ApplicationWorkspace.ts @@ -0,0 +1,67 @@ +import { useCallback, useState } from "react"; + +export interface ApplicationWorkspaceState { + navigationOpen: boolean; + activeView: ViewId | null; + contentOpen: boolean; + contentExpanded: boolean; +} + +export interface ApplicationWorkspaceController extends ApplicationWorkspaceState { + openNavigation: () => void; + toggleNavigation: () => void; + closeNavigation: () => void; + openView: (view: ViewId) => void; + closeView: () => void; + setContentExpanded: (expanded: boolean) => void; +} + +export interface ApplicationWorkspaceInitialState { + navigationOpen?: boolean; + activeView?: ViewId | null; + contentExpanded?: boolean; +} + +/** Owns the Launcher-derived navigation/content-window state machine. */ +export function useApplicationWorkspace({ + navigationOpen: initialNavigationOpen = false, + activeView: initialActiveView = null, + contentExpanded: initialContentExpanded = true, +}: ApplicationWorkspaceInitialState = {}): ApplicationWorkspaceController { + const [navigationOpen, setNavigationOpen] = useState(initialNavigationOpen || initialActiveView !== null); + const [activeView, setActiveView] = useState(initialActiveView); + const [contentExpanded, setContentExpanded] = useState(initialContentExpanded); + + const openNavigation = useCallback(() => setNavigationOpen(true), []); + const closeView = useCallback(() => setActiveView(null), []); + const closeNavigation = useCallback(() => { + setNavigationOpen(false); + setActiveView(null); + }, []); + const toggleNavigation = useCallback(() => { + if (navigationOpen) { + setNavigationOpen(false); + setActiveView(null); + } else { + setNavigationOpen(true); + } + }, [navigationOpen]); + const openView = useCallback((view: ViewId) => { + setNavigationOpen(true); + setActiveView(view); + setContentExpanded(true); + }, []); + + return { + navigationOpen, + activeView, + contentOpen: activeView !== null, + contentExpanded, + openNavigation, + toggleNavigation, + closeNavigation, + openView, + closeView, + setContentExpanded, + }; +} diff --git a/packages/ui-react/src/Button.tsx b/packages/ui-react/src/Button.tsx index a9447f9..8fd95c2 100644 --- a/packages/ui-react/src/Button.tsx +++ b/packages/ui-react/src/Button.tsx @@ -1,6 +1,6 @@ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"; import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent"; export type ButtonSize = "default" | "compact"; diff --git a/packages/ui-react/src/Checker.tsx b/packages/ui-react/src/Checker.tsx index b788f64..dcfa1d8 100644 --- a/packages/ui-react/src/Checker.tsx +++ b/packages/ui-react/src/Checker.tsx @@ -1,5 +1,5 @@ import type { ButtonHTMLAttributes } from "react"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export interface CheckerProps extends Omit, "onChange"> { checked: boolean; diff --git a/packages/ui-react/src/ConfirmationModal.tsx b/packages/ui-react/src/ConfirmationModal.tsx index 04fe676..1ecc400 100644 --- a/packages/ui-react/src/ConfirmationModal.tsx +++ b/packages/ui-react/src/ConfirmationModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import { Button } from "./Button"; -import { Window, WindowFooterActions } from "./Window"; +import { Button } from "./Button.js"; +import { Window, WindowFooterActions } from "./Window.js"; export interface ConfirmationModalProps { open: boolean; diff --git a/packages/ui-react/src/Dropdown.tsx b/packages/ui-react/src/Dropdown.tsx index 7f319c4..21d2770 100644 --- a/packages/ui-react/src/Dropdown.tsx +++ b/packages/ui-react/src/Dropdown.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export interface DropdownTriggerApi { open: boolean; diff --git a/packages/ui-react/src/Field.tsx b/packages/ui-react/src/Field.tsx index 990f8df..4b36eaa 100644 --- a/packages/ui-react/src/Field.tsx +++ b/packages/ui-react/src/Field.tsx @@ -1,5 +1,5 @@ import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from "react"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export interface FieldFrameProps { label: string; diff --git a/packages/ui-react/src/Glass.tsx b/packages/ui-react/src/Glass.tsx index 8e1a7f2..05f5722 100644 --- a/packages/ui-react/src/Glass.tsx +++ b/packages/ui-react/src/Glass.tsx @@ -1,5 +1,5 @@ import type { HTMLAttributes, ReactNode } from "react"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export type GlassTone = "default" | "strong" | "soft"; export type GlassRadius = "card" | "panel" | "modal"; diff --git a/packages/ui-react/src/Inspector.tsx b/packages/ui-react/src/Inspector.tsx index 1bc0c3e..1089c6b 100644 --- a/packages/ui-react/src/Inspector.tsx +++ b/packages/ui-react/src/Inspector.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, type ReactNode } from "react"; -import { cn } from "./cn"; +import { cn } from "./cn.js"; export interface InspectorSectionSpec { id: string; diff --git a/packages/ui-react/src/MediaSourceField.tsx b/packages/ui-react/src/MediaSourceField.tsx new file mode 100644 index 0000000..cf35cce --- /dev/null +++ b/packages/ui-react/src/MediaSourceField.tsx @@ -0,0 +1,118 @@ +import { useId, type ChangeEvent, type ReactNode } from "react"; +import { Icon } from "./Icon.js"; + +export type MediaSource = "file" | "url"; +export type MediaPreviewKind = "image" | "gif" | "video"; + +export interface MediaSourceFieldProps { + label: string; + kindLabel?: string; + source: MediaSource; + url: string; + fileName?: string | null; + uploading?: boolean; + previewSrc?: string | null; + previewKind?: MediaPreviewKind | null; + accept?: string; + fileButtonLabel?: string; + emptyFileLabel?: string; + path?: ReactNode; + hint?: ReactNode; + error?: ReactNode; + onSourceChange: (source: MediaSource) => void; + onUrlChange: (url: string) => void; + onFileChange: (file?: File) => void | Promise; +} + +function inferredPreviewKind(src: string): MediaPreviewKind { + if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src)) return "video"; + if (/\.gif(\?.*)?$/i.test(src)) return "gif"; + return "image"; +} + +function MediaPreview({ src, kind }: { src: string; kind?: MediaPreviewKind | null }) { + const resolvedKind = kind ?? inferredPreviewKind(src); + if (resolvedKind === "video") return