From 3fb3a5d42cf83da680507680a203dc7cde7d22cf Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Tue, 28 Jul 2026 04:24:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(room):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раскладка сцены больше не выбирается автоматически — пользователь выбирает один из трёх режимов: - «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку, кого показать крупно, по-прежнему решает pickStageFocus; - «Плитки» — все участники равными плитками, без выделенного крупного; - «Живые плитки» — сетка только из тех, у кого включена камера, остальные в карусели сбоку; если камеру не включил никто, сетка была бы пустой — режим вырождается в «Плитки». Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие участников, а демонстрация неравноправна по определению). Режим при этом живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается к выбранному. Скрытие остальных убирает карусель, основная область занимает сцену целиком. Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене, которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать нечего, переключатель там заблокирован с пояснением. Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в шторке. Видимость решается условным рендерингом в React, а не новым CSS-правилом поверх медиазапроса — в room.css за это уже была битва специфичности. Режим сохраняется между заходами в комнату (localStorage, своим модулем — LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в новую конференцию без половины участников — сюрприз, а не удобство. Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу. Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой: PaginationControl/PaginationIndicator из пакета не экспортируются. --- .../components/room/DeviceSettingsDialog.tsx | 51 +++-- frontend/src/components/room/RoomStage.tsx | 175 +++++++++++++--- frontend/src/components/room/RoomToolbar.tsx | 25 ++- frontend/src/components/room/StageGrid.tsx | 113 ++++++++++ .../src/components/room/StageViewOptions.tsx | 130 ++++++++++++ frontend/src/hooks/useIsCompactViewport.ts | 27 +++ frontend/src/lib/stageLayoutMode.ts | 55 +++++ frontend/src/pages/RoomPage.tsx | 39 +++- frontend/src/styles/room.css | 194 +++++++++++++++++- 9 files changed, 756 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/room/StageGrid.tsx create mode 100644 frontend/src/components/room/StageViewOptions.tsx create mode 100644 frontend/src/hooks/useIsCompactViewport.ts create mode 100644 frontend/src/lib/stageLayoutMode.ts diff --git a/frontend/src/components/room/DeviceSettingsDialog.tsx b/frontend/src/components/room/DeviceSettingsDialog.tsx index bceac76..9760cd4 100644 --- a/frontend/src/components/room/DeviceSettingsDialog.tsx +++ b/frontend/src/components/room/DeviceSettingsDialog.tsx @@ -1,16 +1,16 @@ -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { X } from 'lucide-react' import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react' import { useToast } from '@/components/ui/ToastProvider' +import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useModalDismiss } from '@/hooks/useModalDismiss' +import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions' import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice' -interface DeviceSettingsDialogProps { +interface DeviceSettingsDialogProps extends StageViewProps { onClose: () => void } -/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */ -const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)' /** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */ const SHEET_DISMISS_THRESHOLD_PX = 80 @@ -19,20 +19,6 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): return device.label || `${fallback} ${index + 1}` } -/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */ -function useIsCompactViewport(): boolean { - const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches) - - useEffect(() => { - const media = window.matchMedia(COMPACT_VIEWPORT_QUERY) - const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches) - media.addEventListener('change', handleChange) - return () => media.removeEventListener('change', handleChange) - }, []) - - return isCompact -} - /** * Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`: * список устройств и переключение целиком в `useMediaDeviceSelect` (сама @@ -47,12 +33,23 @@ function useIsCompactViewport(): boolean { * модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку. * Десктоп не меняется. * + * Там же, и только там, первой секцией идёт «Вид» (режим показа участников и + * скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной + * кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на + * мобильном шире по смыслу («Настройки», а не «Настройки устройств»). + * * ДОЛЖЕН рендериться внутри ``: `useMediaDeviceSelect` без явно * переданного `room` берёт активную комнату из `RoomContext` — вне контекста * он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы * устройство «в никуда». */ -export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { +export function DeviceSettingsDialog({ + onClose, + layoutMode, + onLayoutModeChange, + hideOthers, + onHideOthersChange, +}: DeviceSettingsDialogProps) { const toast = useToast() const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices() const mic = useMediaDeviceSelect({ kind: 'audioinput' }) @@ -143,12 +140,26 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { )}
-

Настройки устройств

+

{isCompact ? 'Настройки' : 'Настройки устройств'}

+ {/* Обёртка НЕ `.room-field`: там `label { display: block }` со + специфичностью выше, чем у `.stage-view-mode` — пункты режимов + рассыпались бы в столбик (кружок отдельно, текст под ним). */} + {isCompact && ( +
+ +
+ )} +
onLayoutModeChange(option.mode)} + /> + + {option.title} + {option.hint} + + + ))} + + + + {hideOthersDisabled && ( +

В режиме «Плитки» все участники равноправны — скрывать нечего.

+ )} +
+ ) +} + +/** + * Кнопка «Вид» в тулбаре с поповером над ней — десктопный вход в + * `StageViewOptions`. На мобильном не рендерится вовсе (см. `RoomToolbar`): + * там тулбар и так ужат до пяти кнопок, а те же настройки доступны секцией + * «Вид» в шторке настроек. + * + * Закрытие по клику вне и Escape — тем же паттерном, что меню пользователя в + * `ShellTopbar` (слушатели на document, пока меню открыто). + */ +export function StageViewMenu(props: StageViewProps) { + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + useEffect(() => { + if (!open) return + + function handlePointerDown(event: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) { + setOpen(false) + } + } + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleKeydown) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleKeydown) + } + }, [open]) + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/hooks/useIsCompactViewport.ts b/frontend/src/hooks/useIsCompactViewport.ts new file mode 100644 index 0000000..8ede9fe --- /dev/null +++ b/frontend/src/hooks/useIsCompactViewport.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' + +/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`). */ +const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)' + +/** + * Живое отслеживание мобильной ширины — та же схема, что системная тема в + * `useTheme.ts` (matchMedia + change-листенер). + * + * Нужен там, где мобильный вариант — это ДРУГАЯ разметка, а не другие стили: + * шторка вместо модалки (`DeviceSettingsDialog`) и условный рендеринг кнопок + * тулбара (`RoomToolbar`). Прятать элементы новым CSS-правилом поверх + * медиазапроса в `room.css` намеренно не стали — там уже была битва + * специфичности (см. комментарий в конце `room.css`). + */ +export function useIsCompactViewport(): boolean { + const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches) + + useEffect(() => { + const media = window.matchMedia(COMPACT_VIEWPORT_QUERY) + const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches) + media.addEventListener('change', handleChange) + return () => media.removeEventListener('change', handleChange) + }, []) + + return isCompact +} diff --git a/frontend/src/lib/stageLayoutMode.ts b/frontend/src/lib/stageLayoutMode.ts new file mode 100644 index 0000000..6fce3e2 --- /dev/null +++ b/frontend/src/lib/stageLayoutMode.ts @@ -0,0 +1,55 @@ +const STORAGE_KEY = 'vidconf-stage-layout-mode' + +/** + * Режим показа участников на сцене конференции — выбирается пользователем + * явно (до 0.0.11 раскладка выбиралась автоматически по наличию демонстрации + * экрана и числу участников). + * + * - `standard` — один крупный участник (фокус) + остальные в карусели сбоку; + * кто именно в фокусе, по-прежнему решает `pickStageFocus`. + * - `tiles` — все участники равными плитками, без выделенного крупного. + * - `live-tiles` — равномерная сетка ТОЛЬКО из участников с включённой + * камерой, остальные (камера выключена) — в карусели сбоку. + */ +export type StageLayoutMode = 'standard' | 'tiles' | 'live-tiles' + +/** Порядок и подписи режимов в переключателе (единый источник для тулбара и мобильной шторки). */ +export const STAGE_LAYOUT_MODE_OPTIONS: readonly { + mode: StageLayoutMode + title: string + hint: string +}[] = [ + { mode: 'standard', title: 'Стандарт', hint: 'Крупно — активный участник, остальные сбоку' }, + { mode: 'tiles', title: 'Плитки', hint: 'Все участники равными плитками' }, + { mode: 'live-tiles', title: 'Живые плитки', hint: 'Плитками — только с камерой, остальные сбоку' }, +] + +function isStageLayoutMode(value: string | null): value is StageLayoutMode { + return value === 'standard' || value === 'tiles' || value === 'live-tiles' +} + +/** + * Сохранённый режим показа — читается при входе в комнату. + * + * Персист сделан своим модулем (как `lib/audioOutputDevice.ts`), а не через + * `usePersistentUserChoices` LiveKit: их `LocalUserChoices` — фиксированная + * структура про устройства и имя, поля для нашей раскладки там нет и добавить + * его нельзя. Схема хранения та же: одна строка в localStorage, любые сбои + * доступа к нему гасятся (приватный режим/политики браузера). + */ +export function loadStageLayoutMode(): StageLayoutMode { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return isStageLayoutMode(stored) ? stored : 'standard' + } catch { + return 'standard' + } +} + +export function saveStageLayoutMode(mode: StageLayoutMode): void { + try { + localStorage.setItem(STORAGE_KEY, mode) + } catch { + // Сохранение недоступно — выбор продержится до конца сессии в комнате. + } +} diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index 56b5cd8..3facf6a 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -18,6 +18,7 @@ import { RoomToolbar } from '@/components/room/RoomToolbar' import { ChatPanel } from '@/components/room/ChatPanel' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice' +import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode' interface RoomJoinState { livekitUrl: string @@ -150,6 +151,23 @@ export function RoomPage() { const pip = useRoomPiP(roomRootRef) const [settingsOpen, setSettingsOpen] = useState(false) + // Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в + // шторке настроек, а сцена их только читает (общий предок). + // + // Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`, + // localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их + // и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое + // действие по ходу разговора, и войти в новую конференцию сразу без половины + // участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со + // страницы комнаты. + const [layoutMode, setLayoutMode] = useState(loadStageLayoutMode) + const [hideOthers, setHideOthers] = useState(false) + + const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => { + setLayoutMode(mode) + saveStageLayoutMode(mode) + }, []) + // Сохранённый выбор устройств — читаем через собственный вызов // usePersistentUserChoices (независимый от того, что использует // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО: @@ -218,7 +236,12 @@ export function RoomPage() { ) : ( - + setHideOthers(false)} + onHideOthers={() => setHideOthers(true)} + /> )} {chatVisible && chatOpen && ( - {settingsOpen && setSettingsOpen(false)} />} + {settingsOpen && ( + setSettingsOpen(false)} + layoutMode={layoutMode} + onLayoutModeChange={handleLayoutModeChange} + hideOthers={hideOthers} + onHideOthersChange={setHideOthers} + /> + )} {/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage, рендерящийся в PiP-окне, продолжает читать RoomContext/треки. `variant="pip"` — мини-плеер diff --git a/frontend/src/styles/room.css b/frontend/src/styles/room.css index e7894ae..edbece4 100644 --- a/frontend/src/styles/room.css +++ b/frontend/src/styles/room.css @@ -47,7 +47,9 @@ .room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); } .room-main { display: flex; flex: 1; min-height: 0; } -.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; } +/* `position: relative` — якорь для кнопки «Показать остальных» (`.stage-show-others`), + которая висит оверлеем над сценой, см. её правило ниже. */ +.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; position: relative; } /* * Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для @@ -115,6 +117,10 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } @media (max-width: 600px) { .stage-tiles.lk-focus-layout { grid-template-columns: 1fr; } .stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); } + /* Их `.lk-carousel{order:1}` (ставит миниатюры ПОД спикера) больше не + действует: прямой ребёнок грида теперь обёртка `.stage-side`, а не сама + карусель — переносим порядок на неё, иначе миниатюры уезжают наверх. */ + .stage-side { order: 1; } } /* @@ -130,6 +136,123 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } .room-single-tile { flex: 1; min-height: 0; display: flex; } .room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; } +/* + * ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ---------- + * Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над + * ней стоит кнопка «Скрыть» (см. `RoomStage`). Карусель занимает весь остаток + * колонки: ей важны собственные размеры — `CarouselLayout` по ним и решает, + * вертикальная она или горизонтальная (см. комментарий в `RoomStage`). + * `min-height: 0` обязателен — иначе flex-элемент не даёт себя сжать и + * колонка вылезает за высоту сцены. + */ +.stage-side { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; min-height: 0; } +.stage-side .lk-carousel { flex: 1; min-height: 0; } +.stage-side-hide { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex-shrink: 0; + padding: 6px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--color-room-tile-border); + background: var(--color-room-tile); + color: var(--color-room-text-secondary); + font: var(--text-caption); + text-transform: none; + letter-spacing: normal; + cursor: pointer; +} +.stage-side-hide:hover { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); } +.stage-side-hide svg { width: 14px; height: 14px; flex-shrink: 0; } + +/* + * ---------- Основная область без карусели ---------- + * `.stage-main-only` — сцена, где карусель не рендерится вовсе: плиточные + * режимы («Плитки» всегда, «Живые плитки» когда камеры выключены не у кого), + * «Стандарт» с единственным участником и любой режим при включённом «скрыть + * остальных». Единственный ребёнок (плитка фокуса или сетка `.stage-grid`) + * занимает всё место — тем же приёмом, что `.room-single-tile` выше, но без + * `min-height` мини-плеера: в основном окне высота приходит от `.room-main`. + */ +.stage-main-only { display: flex; } +.stage-main-only > * { flex: 1; min-width: 0; min-height: 0; } + +/* + * ---------- Равномерная сетка плиток (`StageGrid`) ---------- + * Класс `.lk-grid-layout` библиотеки на месте (оттуда вся геометрия сетки: + * `--lk-col-count`/`--lk-row-count`, gap, padding), здесь — только то, чего у + * неё нет: сброс минимальных размеров (сетка может жить внутри grid-ячейки + * `.lk-focus-layout`, где без `min-*: 0` она распирала бы колонку) и якорь для + * собственного переключателя страниц. + */ +.stage-grid { position: relative; min-width: 0; min-height: 0; } +.stage-grid-pages { + position: absolute; + bottom: calc(var(--lk-grid-gap) / 2); + left: 50%; + transform: translateX(-50%); + z-index: 5; + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: var(--radius-full); + background: rgba(30, 30, 30, 0.85); + border: 1px solid var(--color-room-tile-border); + color: var(--color-room-text-primary); + font: var(--text-caption); +} +.stage-grid-pages button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; +} +.stage-grid-pages button:hover:not(:disabled) { background: var(--color-room-tile-hover); } +.stage-grid-pages button:disabled { opacity: 0.35; cursor: default; } +.stage-grid-pages svg { width: 16px; height: 16px; } +.stage-grid-pages span { padding: 0 4px; font-variant-numeric: tabular-nums; } + +/* + * ---------- Кнопка «Показать остальных» ---------- + * Единственный видимый способ вернуть скрытую карусель, поэтому висит прямо на + * сцене, а не только в меню «Вид»: пользователь не должен «терять» участников + * без понятного способа их вернуть. Внизу по центру — там у плиток пусто + * (`.lk-participant-metadata` прижата к левому и правому углам). + */ +.stage-show-others { + position: absolute; + bottom: var(--space-5); + left: 50%; + transform: translateX(-50%); + z-index: 10; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-full); + border: 1px solid var(--color-room-tile-border); + background: rgba(30, 30, 30, 0.9); + color: var(--color-room-text-primary); + font: var(--text-body); + font-weight: 600; + /* Иначе на узком экране подпись ломается на две строки и кнопка вырастает + в высоту вдвое (поймали на 390px). */ + white-space: nowrap; + box-shadow: var(--shadow-md); + cursor: pointer; +} +.stage-show-others:hover { background: var(--color-room-tile-hover); } +.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; } + /* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */ .room-toolbar { display: flex; @@ -200,6 +323,75 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } justify-content: center; } +/* + * ---------- Кнопка «Вид» и её поповер (десктоп) ---------- + * Обёртка вокруг кнопки тулбара — якорь для всплывающей панели над ней + * (`StageViewMenu`). На мобильном ни кнопки, ни поповера нет вовсе: там блок + * настроек вида рендерится секцией внутри шторки (условный рендеринг в React, + * а не скрытие через CSS, — см. `RoomToolbar`). + */ +.tb-menu-wrap { position: relative; display: flex; } +.tb-menu { + position: absolute; + bottom: calc(100% + var(--space-2)); + left: 50%; + transform: translateX(-50%); + z-index: 50; + width: 280px; + padding: var(--space-4); + border-radius: var(--radius-lg); + border: 1px solid var(--color-room-tile-border); + background: var(--color-room-surface-raised); + box-shadow: var(--shadow-room-panel); +} + +/* + * ---------- Блок «Вид сцены» (`StageViewOptions`) ---------- + * Общий для десктопного поповера и мобильной шторки — отсюда нейтральная + * геометрия без привязки к контейнеру (ширину задаёт родитель). + */ +.stage-view-options { display: flex; flex-direction: column; gap: var(--space-3); } +/* Секция «Вид» внутри шторки настроек — отбивка как у `.room-field` (свой + класс, а не `.room-field`, см. комментарий в `DeviceSettingsDialog`). */ +.stage-view-section { margin-bottom: var(--space-5); } +.stage-view-modes { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; border: none; } +.stage-view-modes legend { + padding: 0 0 var(--space-2); + font: var(--text-caption); + color: var(--color-room-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.stage-view-mode { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; +} +.stage-view-mode:hover { background: var(--color-room-tile); } +.stage-view-mode.is-active { background: var(--color-room-tile); border-color: var(--color-room-speaker-ring); } +.stage-view-mode input { margin-top: 3px; accent-color: var(--color-room-mic-on); flex-shrink: 0; } +.stage-view-mode-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.stage-view-mode-title { font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); } +.stage-view-mode-hint { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); } + +.stage-view-toggle { + display: flex; + align-items: center; + gap: 10px; + padding-top: var(--space-3); + border-top: 1px solid var(--color-room-tile-border); + font: var(--text-body); + color: var(--color-room-text-primary); + cursor: pointer; +} +.stage-view-toggle input { accent-color: var(--color-room-mic-on); flex-shrink: 0; } +.stage-view-toggle.is-disabled { color: var(--color-room-text-tertiary); cursor: default; } +.stage-view-note { margin: 0; font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-tertiary); } + /* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */ .chat-panel { width: 320px;