добавление комментарией к коду

This commit is contained in:
Илья Братских 2026-06-28 14:49:48 +03:00 committed by KBAHTNET
parent c187c66737
commit 37220d7d17
3 changed files with 430 additions and 107 deletions

View File

@ -17,37 +17,49 @@
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
<!-- Декоративные фоновые пятна. Они не участвуют в логике и нужны только для глубины фона. -->
<div class="ambient ambient-one"></div> <div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div> <div class="ambient ambient-two"></div>
<main class="shell"> <main class="shell">
<!--
Основная сетка приложения:
слева sticky-меню с деревом навигации, справа каталог карточек.
Ширина левой колонки управляется CSS-переменной --sidebar-width и ресайзером.
-->
<div class="app-layout"> <div class="app-layout">
<!-- Боковая панель: поиск, настройки и дерево навигации по всей структуре. -->
<aside class="side-panel" aria-label="Навигация по экосистеме"> <aside class="side-panel" aria-label="Навигация по экосистеме">
<div class="hero-content"> <div class="hero-content">
<button <!-- Шестеренка открывает fixed popup с настройками внешнего вида и поведения. -->
class="settings-button" <button
type="button" class="settings-button"
aria-label="Открыть настройки внешнего вида" type="button"
aria-expanded="false" aria-label="Открыть настройки внешнего вида"
data-settings-toggle aria-expanded="false"
> data-settings-toggle
⚙ >
</button> ⚙
</button>
<p class="eyebrow" id="page-title">Панель экосистемы</p> <p class="eyebrow" id="page-title">Панель экосистемы</p>
<label class="search-field"> <!-- Поиск фильтрует каталог и подсвечивает совпадения в дереве и карточках. -->
<input <label class="search-field">
type="search" <input
placeholder="Grafana, S3, журнал, сервер..." type="search"
autocomplete="off" placeholder="Grafana, S3, журнал, сервер..."
data-search-input autocomplete="off"
/> data-search-input
</label> />
</label>
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav> <!-- Дерево генерируется из ecosystemSections в script.js. -->
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div> </div>
</aside> </aside>
<!-- Вертикальная ручка для изменения ширины бокового меню мышью/указателем. -->
<div <div
class="sidebar-resizer" class="sidebar-resizer"
role="separator" role="separator"
@ -56,99 +68,118 @@
data-sidebar-resizer data-sidebar-resizer
></div> ></div>
<div class="settings-overlay" data-settings-overlay></div> <!-- Затемнение страницы за popup-настройками. -->
<div class="control-panel" aria-label="Настройки панели" data-settings-panel> <div class="settings-overlay" data-settings-overlay></div>
<div class="settings-panel-head">
<div> <!--
<p class="eyebrow">View settings</p> Popup настроек:
<h2>Настройки</h2> - тема;
- режим отображения карточек;
- видимость нумерации;
- видимость тегов в дереве;
- авто-раскрытие дерева при поиске;
- цветовой акцент.
-->
<div class="control-panel" aria-label="Настройки панели" data-settings-panel>
<div class="settings-panel-head">
<div>
<p class="eyebrow">View settings</p>
<h2>Настройки</h2>
</div>
<button class="settings-close" type="button" aria-label="Закрыть настройки" data-settings-close>
×
</button>
</div>
<!-- Переключение светлой/темной темы. Значение сохраняется в localStorage. -->
<div class="control-group">
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<!-- Режимы каталога: обычные/компактные плитки и списки. -->
<div class="control-group view-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
<button class="segmented" type="button" data-view="compact-cards">
Компакт плитки
</button>
<button class="segmented" type="button" data-view="compact-list">
Компакт список
</button>
</div>
<!-- Показывать номера разделов, подразделов и элементов. -->
<label class="toggle-row">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
<!-- Показывать поисковые теги рядом с файлами в дереве навигации. -->
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Поисковые теги рядом с элементом</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<!-- Управляет тем, раскрывает ли поиск только ветки с совпадениями. -->
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
<small>Сворачивать ветки без совпадений</small>
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
<!-- Выбор акцентного цвета интерфейса. -->
<div class="control-group accent-group">
<span>Акцент</span>
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
</div> </div>
<button class="settings-close" type="button" aria-label="Закрыть настройки" data-settings-close>
×
</button>
</div> </div>
<div class="control-group"> <!-- Каталог карточек генерируется из ecosystemSections в script.js. -->
<span>Тема</span>
<button class="segmented is-active" type="button" data-theme="dark">
Темная
</button>
<button class="segmented" type="button" data-theme="light">
Светлая
</button>
</div>
<div class="control-group view-group">
<span>Вид</span>
<button class="segmented is-active" type="button" data-view="cards">
Плитки
</button>
<button class="segmented" type="button" data-view="list">
Список
</button>
<button class="segmented" type="button" data-view="compact-cards">
Компакт плитки
</button>
<button class="segmented" type="button" data-view="compact-list">
Компакт список
</button>
</div>
<label class="toggle-row">
<span>
<strong>Показывать нумерацию</strong>
<small>Разделы, подразделы и элементы</small>
</span>
<input type="checkbox" data-show-numbers />
</label>
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Поисковые теги рядом с элементом</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<label class="toggle-row">
<span>
<strong>Авто раскрывать поиск</strong>
<small>Сворачивать ветки без совпадений</small>
</span>
<input type="checkbox" data-auto-collapse-search />
</label>
<div class="control-group accent-group">
<span>Акцент</span>
<button
class="accent-swatch is-active"
type="button"
data-accent="violet"
aria-label="Фиолетовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="cyan"
aria-label="Голубой акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="lime"
aria-label="Лаймовый акцент"
></button>
<button
class="accent-swatch"
type="button"
data-accent="amber"
aria-label="Янтарный акцент"
></button>
</div>
</div>
<section class="catalog cards-view" aria-live="polite" data-catalog-root></section> <section class="catalog cards-view" aria-live="polite" data-catalog-root></section>
<!-- Шаблон состояния, когда поиск не нашел ни одного элемента. -->
<template id="empty-template"> <template id="empty-template">
<div class="empty-state"> <div class="empty-state">
<img src="assets/search.svg" alt="" /> <img src="assets/search.svg" alt="" />

145
script.js
View File

@ -1,8 +1,31 @@
// ============================================================================
// Persistent storage keys
// ============================================================================
// STORAGE_KEY хранит пользовательские настройки интерфейса:
// тему, акцент, режим отображения, ширину сайдбара и toggle-настройки.
const STORAGE_KEY = "ecosystem-panel-preferences"; const STORAGE_KEY = "ecosystem-panel-preferences";
// COLLAPSE_STORAGE_KEY хранит ручные состояния раскрытия details:
// отдельно для дерева навигации и отдельно для контентных секций.
const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state"; const COLLAPSE_STORAGE_KEY = "ecosystem-panel-collapse-state";
// ============================================================================
// Navigation data model
// ============================================================================
// Меняй навигацию здесь: разделы -> подразделы -> элементы. // Меняй навигацию здесь: разделы -> подразделы -> элементы.
// url можно заменить на реальные адреса, tags помогают поиску. // url можно заменить на реальные адреса, tags помогают поиску.
//
// Структура:
// - section: крупный раздел панели;
// - group: подраздел внутри section;
// - item: конечный кликабельный элемент.
//
// Каждый item имеет:
// - number: человекочитаемый номер;
// - title: название карточки;
// - type: ключ из serviceMeta, который задает иконку и тип сервиса;
// - tags: поисковые теги, которые также можно показывать в дереве;
// - url: ссылка перехода. По умолчанию "#", чтобы заглушки не дергали страницу.
const ecosystemSections = [ const ecosystemSections = [
{ {
id: "user", id: "user",
@ -113,6 +136,8 @@ const ecosystemSections = [
}, },
]; ];
// Метаданные типов сервисов.
// item.type ссылается на ключ здесь: так карточки получают label и icon.
const serviceMeta = { const serviceMeta = {
ceph: { label: "Ceph", icon: "assets/ceph.svg" }, ceph: { label: "Ceph", icon: "assets/ceph.svg" },
cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" }, cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" },
@ -133,6 +158,10 @@ const serviceMeta = {
xmpp: { label: "XMPP", icon: "assets/xmpp.svg" }, xmpp: { label: "XMPP", icon: "assets/xmpp.svg" },
}; };
// ============================================================================
// Default UI preferences
// ============================================================================
// Значения используются при первом открытии или если localStorage пуст/битый.
const defaultPreferences = { const defaultPreferences = {
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
view: "cards", view: "cards",
@ -144,8 +173,16 @@ const defaultPreferences = {
}; };
const availableViews = ["cards", "list", "compact-cards", "compact-list"]; const availableViews = ["cards", "list", "compact-cards", "compact-list"];
// ============================================================================
// Runtime state and DOM references
// ============================================================================
// preferences - текущие настройки интерфейса, объединенные с localStorage.
const preferences = { ...defaultPreferences, ...readPreferences() }; const preferences = { ...defaultPreferences, ...readPreferences() };
// collapseState - объект вида { [collapseKey]: boolean } для details.
const collapseState = readCollapseState(); const collapseState = readCollapseState();
// Корневые DOM-узлы, которые нужны для рендера и событий.
const catalog = document.querySelector("[data-catalog-root]"); const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]"); const navTree = document.querySelector("[data-nav-tree]");
const sidebarResizer = document.querySelector("[data-sidebar-resizer]"); const sidebarResizer = document.querySelector("[data-sidebar-resizer]");
@ -162,15 +199,29 @@ const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]"); const autoCollapseSearchInput = document.querySelector("[data-auto-collapse-search]");
const emptyTemplate = document.querySelector("#empty-template"); const emptyTemplate = document.querySelector("#empty-template");
// ============================================================================
// Initial render
// ============================================================================
// Сначала строим дерево, затем применяем настройки, затем рендерим каталог.
// Такой порядок нужен, потому что applyPreferences выставляет data-атрибуты
// и CSS-переменные, от которых зависит внешний вид уже созданного DOM.
renderNavigationTree(); renderNavigationTree();
applyPreferences(preferences); applyPreferences(preferences);
renderCatalog(); renderCatalog();
// ============================================================================
// Event bindings
// ============================================================================
// Поиск перерисовывает и дерево, и каталог:
// - дерево получает подсветку совпадений и авто-раскрытие веток;
// - каталог фильтруется и тоже подсвечивает найденный текст.
searchInput.addEventListener("input", () => { searchInput.addEventListener("input", () => {
renderNavigationTree(); renderNavigationTree();
renderCatalog(); renderCatalog();
}); });
// Клик по файлу/папке в дереве фокусирует соответствующую секцию/карточку.
// Само раскрытие details остается на нативном summary/caret.
navTree.addEventListener("click", (event) => { navTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-focus-target]"); const button = event.target.closest("[data-focus-target]");
@ -183,15 +234,19 @@ navTree.addEventListener("click", (event) => {
focusCatalogTarget(button.dataset.focusTarget); focusCatalogTarget(button.dataset.focusTarget);
}); });
// Сохраняем ручное раскрытие/сворачивание дерева и контента.
// Используем capture=true, потому что toggle не всплывает как обычный click.
navTree.addEventListener("toggle", handleDetailsToggle, true); navTree.addEventListener("toggle", handleDetailsToggle, true);
catalog.addEventListener("toggle", handleDetailsToggle, true); catalog.addEventListener("toggle", handleDetailsToggle, true);
// Открытие popup-настроек по шестеренке.
settingsToggle.addEventListener("click", () => { settingsToggle.addEventListener("click", () => {
const isOpen = settingsPanel.classList.toggle("is-open"); const isOpen = settingsPanel.classList.toggle("is-open");
settingsOverlay.classList.toggle("is-open", isOpen); settingsOverlay.classList.toggle("is-open", isOpen);
settingsToggle.setAttribute("aria-expanded", String(isOpen)); settingsToggle.setAttribute("aria-expanded", String(isOpen));
}); });
// Закрытие popup-настроек всеми привычными способами.
settingsClose.addEventListener("click", closeSettingsPanel); settingsClose.addEventListener("click", closeSettingsPanel);
settingsOverlay.addEventListener("click", closeSettingsPanel); settingsOverlay.addEventListener("click", closeSettingsPanel);
@ -221,6 +276,7 @@ themeButtons.forEach((button) => {
}); });
}); });
// Переключение режима отображения каталога.
viewButtons.forEach((button) => { viewButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
preferences.view = button.dataset.view; preferences.view = button.dataset.view;
@ -229,6 +285,7 @@ viewButtons.forEach((button) => {
}); });
}); });
// Выбор цветового акцента.
accentButtons.forEach((button) => { accentButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
preferences.accent = button.dataset.accent; preferences.accent = button.dataset.accent;
@ -237,18 +294,21 @@ accentButtons.forEach((button) => {
}); });
}); });
// Показывает/скрывает номера в секциях, группах и карточках.
showNumbersInput.addEventListener("change", () => { showNumbersInput.addEventListener("change", () => {
preferences.showNumbers = showNumbersInput.checked; preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences); applyPreferences(preferences);
savePreferences(preferences); savePreferences(preferences);
}); });
// Показывает/скрывает поисковые теги в файловом дереве.
showTreeTagsInput.addEventListener("change", () => { showTreeTagsInput.addEventListener("change", () => {
preferences.showTreeTags = showTreeTagsInput.checked; preferences.showTreeTags = showTreeTagsInput.checked;
applyPreferences(preferences); applyPreferences(preferences);
savePreferences(preferences); savePreferences(preferences);
}); });
// Включает/выключает авто-раскрытие веток дерева при поиске.
autoCollapseSearchInput.addEventListener("change", () => { autoCollapseSearchInput.addEventListener("change", () => {
preferences.autoCollapseSearch = autoCollapseSearchInput.checked; preferences.autoCollapseSearch = autoCollapseSearchInput.checked;
applyPreferences(preferences); applyPreferences(preferences);
@ -256,6 +316,8 @@ autoCollapseSearchInput.addEventListener("change", () => {
savePreferences(preferences); savePreferences(preferences);
}); });
// Drag-resize бокового меню.
// Ширина сохраняется в preferences.sidebarWidth и применяется через CSS-переменную.
sidebarResizer.addEventListener("pointerdown", (event) => { sidebarResizer.addEventListener("pointerdown", (event) => {
event.preventDefault(); event.preventDefault();
document.body.classList.add("is-resizing-sidebar"); document.body.classList.add("is-resizing-sidebar");
@ -280,10 +342,22 @@ sidebarResizer.addEventListener("pointerdown", (event) => {
sidebarResizer.addEventListener("pointercancel", handlePointerUp); sidebarResizer.addEventListener("pointercancel", handlePointerUp);
}); });
// ============================================================================
// Data helpers
// ============================================================================
// Упрощает запись item-объектов в ecosystemSections.
function createItem(number, title, type, tags = [], url = "#") { function createItem(number, title, type, tags = [], url = "#") {
return { number, title, type, tags, url }; return { number, title, type, tags, url };
} }
// ============================================================================
// Catalog rendering
// ============================================================================
// Рендерит правую часть: details-разделы, details-подразделы и карточки.
// При активном поиске:
// - оставляет только группы с найденными элементами;
// - принудительно раскрывает найденные ветки;
// - подсвечивает совпадения через highlightText().
function renderCatalog() { function renderCatalog() {
const query = normalize(searchInput.value); const query = normalize(searchInput.value);
const forceOpen = Boolean(query); const forceOpen = Boolean(query);
@ -325,6 +399,9 @@ function renderCatalog() {
} }
// Создает корневой details для раздела контента.
// forceOpen нужен для поиска: найденные ветки должны быть видимы независимо
// от сохраненного collapsed-состояния.
function createSectionElement(section, forceOpen = false, query = "") { function createSectionElement(section, forceOpen = false, query = "") {
const sectionElement = document.createElement("details"); const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section"; sectionElement.className = "catalog-section";
@ -349,6 +426,8 @@ function createSectionElement(section, forceOpen = false, query = "") {
return sectionElement; return sectionElement;
} }
// Создает details-подраздел внутри раздела.
// items уже отфильтрованы на уровне renderCatalog().
function createGroupElement(group, items, forceOpen = false, query = "") { function createGroupElement(group, items, forceOpen = false, query = "") {
const groupElement = document.createElement("details"); const groupElement = document.createElement("details");
groupElement.className = "catalog-group"; groupElement.className = "catalog-group";
@ -380,6 +459,8 @@ function createGroupElement(group, items, forceOpen = false, query = "") {
return groupElement; return groupElement;
} }
// Создает конечную карточку навигации.
// Если url === "#", карточка считается заглушкой и не меняет текущий hash/scroll.
function createCardElement(item, query = "") { function createCardElement(item, query = "") {
const meta = serviceMeta[item.type] || serviceMeta.config; const meta = serviceMeta[item.type] || serviceMeta.config;
const link = document.createElement("a"); const link = document.createElement("a");
@ -411,6 +492,11 @@ function createCardElement(item, query = "") {
return link; return link;
} }
// ============================================================================
// Search matching and highlighting
// ============================================================================
// Проверяет, подходит ли конкретный item под строку поиска.
// Поиск идет не только по title, но и по номеру, type, label, tags и родителям.
function matchesQuery(item, group, section, query) { function matchesQuery(item, group, section, query) {
if (!query) { if (!query) {
return true; return true;
@ -432,6 +518,8 @@ function matchesQuery(item, group, section, query) {
return haystack.includes(query); return haystack.includes(query);
} }
// Проверяет, есть ли совпадение на уровне группы или ее элементов.
// Используется для авто-раскрытия дерева при поиске.
function groupMatchesQuery(group, section, query) { function groupMatchesQuery(group, section, query) {
if (!query) { if (!query) {
return true; return true;
@ -443,6 +531,7 @@ function groupMatchesQuery(group, section, query) {
); );
} }
// Проверяет произвольный набор строк на наличие нормализованного query.
function matchesText(values, query) { function matchesText(values, query) {
if (!query) { if (!query) {
return true; return true;
@ -451,6 +540,11 @@ function matchesText(values, query) {
return normalize(values.join(" ")).includes(query); return normalize(values.join(" ")).includes(query);
} }
// ============================================================================
// Sidebar tree rendering
// ============================================================================
// Строит левое файловое дерево из той же структуры данных, что и каталог.
// Это важно: дерево и карточки всегда остаются синхронизированными.
function renderNavigationTree() { function renderNavigationTree() {
const query = normalize(searchInput.value); const query = normalize(searchInput.value);
const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch); const shouldAutoCollapse = Boolean(query && preferences.autoCollapseSearch);
@ -511,6 +605,14 @@ function renderNavigationTree() {
navTree.replaceChildren(fragment); navTree.replaceChildren(fragment);
} }
// ============================================================================
// Focusing from tree to content
// ============================================================================
// Переход из дерева к правому контенту:
// - если текущий поиск скрывает цель, поиск очищается;
// - родительские details раскрываются;
// - состояние раскрытия сохраняется;
// - целевой элемент скроллится в центр и подсвечивается.
function focusCatalogTarget(targetId) { function focusCatalogTarget(targetId) {
let target = document.getElementById(targetId); let target = document.getElementById(targetId);
@ -534,6 +636,10 @@ function focusCatalogTarget(targetId) {
window.setTimeout(() => target.classList.remove("is-focused"), 1200); window.setTimeout(() => target.classList.remove("is-focused"), 1200);
} }
// ============================================================================
// Collapse state handling
// ============================================================================
// Сохраняет состояние каждого details, у которого есть data-collapse-key.
function handleDetailsToggle(event) { function handleDetailsToggle(event) {
const details = event.target; const details = event.target;
@ -545,6 +651,8 @@ function handleDetailsToggle(event) {
saveCollapseState(); saveCollapseState();
} }
// Принудительно сохраняет родительский details как раскрытый.
// Используется, когда пользователь прыгает к элементу из дерева.
function persistOpenedAncestor(details) { function persistOpenedAncestor(details) {
if (!details?.dataset.collapseKey) { if (!details?.dataset.collapseKey) {
return; return;
@ -554,6 +662,12 @@ function persistOpenedAncestor(details) {
saveCollapseState(); saveCollapseState();
} }
// ============================================================================
// Card pointer interaction
// ============================================================================
// Добавляет мягкий tilt/highlight эффект для карточек.
// После каждого renderCatalog() обработчики нужно навешивать заново,
// потому что карточки пересоздаются.
function attachCardInteractions() { function attachCardInteractions() {
document.querySelectorAll(".nav-card").forEach((card) => { document.querySelectorAll(".nav-card").forEach((card) => {
let leaveTimer; let leaveTimer;
@ -591,6 +705,14 @@ function attachCardInteractions() {
}); });
} }
// ============================================================================
// Preferences application
// ============================================================================
// Применяет preferences к DOM:
// - data-theme / data-accent для CSS;
// - CSS-переменную ширины сайдбара;
// - класс режима отображения каталога;
// - состояния toggle-контролов в настройках.
function applyPreferences(nextPreferences) { function applyPreferences(nextPreferences) {
if (!availableViews.includes(nextPreferences.view)) { if (!availableViews.includes(nextPreferences.view)) {
nextPreferences.view = defaultPreferences.view; nextPreferences.view = defaultPreferences.view;
@ -614,6 +736,7 @@ function applyPreferences(nextPreferences) {
setActiveButton(accentButtons, "accent", nextPreferences.accent); setActiveButton(accentButtons, "accent", nextPreferences.accent);
} }
// Обновляет визуальное состояние группы кнопок.
function setActiveButton(buttons, key, value) { function setActiveButton(buttons, key, value) {
buttons.forEach((button) => { buttons.forEach((button) => {
const isActive = button.dataset[key] === value; const isActive = button.dataset[key] === value;
@ -622,10 +745,16 @@ function setActiveButton(buttons, key, value) {
}); });
} }
// ============================================================================
// Generic helpers
// ============================================================================
// Нормализует строки для поиска: регистр + ё/е.
function normalize(value) { function normalize(value) {
return value.toLowerCase().replaceAll("ё", "е").trim(); return value.toLowerCase().replaceAll("ё", "е").trim();
} }
// Подсвечивает все совпадения query внутри value.
// Возвращает HTML-строку, поэтому весь обычный текст пропускается через escapeHTML().
function highlightText(value, query) { function highlightText(value, query) {
const text = String(value); const text = String(value);
const normalizedQuery = normalize(query); const normalizedQuery = normalize(query);
@ -649,6 +778,7 @@ function highlightText(value, query) {
return result + escapeHTML(text.slice(cursor)); return result + escapeHTML(text.slice(cursor));
} }
// Минимальная HTML-экранизация для безопасной вставки строк в innerHTML.
function escapeHTML(value) { function escapeHTML(value) {
return String(value) return String(value)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@ -658,6 +788,8 @@ function escapeHTML(value) {
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
} }
// Стабильные id для секций, групп и элементов.
// Они используются одновременно как DOM id и как цель tree focus.
function getSectionId(section) { function getSectionId(section) {
return `section-${section.id}`; return `section-${section.id}`;
} }
@ -666,6 +798,8 @@ function getGroupId(group) {
return `group-${group.id}`; return `group-${group.id}`;
} }
// Отдельные ключи для дерева, чтобы collapse-состояния дерева
// не конфликтовали с collapse-состояниями контента.
function getTreeSectionId(section) { function getTreeSectionId(section) {
return `tree-${getSectionId(section)}`; return `tree-${getSectionId(section)}`;
} }
@ -678,10 +812,15 @@ function getItemId(item) {
return `item-${item.number.replaceAll(".", "-")}`; return `item-${item.number.replaceAll(".", "-")}`;
} }
// Ограничивает число диапазоном.
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
// ============================================================================
// localStorage helpers
// ============================================================================
// Читает настройки интерфейса.
function readPreferences() { function readPreferences() {
try { try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
@ -690,14 +829,18 @@ function readPreferences() {
} }
} }
// Сохраняет настройки интерфейса.
function savePreferences(nextPreferences) { function savePreferences(nextPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences)); localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences));
} }
// Возвращает сохраненное состояние details.
// По умолчанию все ветки открыты, чтобы первый вход был максимально понятным.
function getSavedOpenState(key) { function getSavedOpenState(key) {
return collapseState[key] ?? true; return collapseState[key] ?? true;
} }
// Читает состояния раскрытия дерева/контента.
function readCollapseState() { function readCollapseState() {
try { try {
return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {}; return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {};
@ -706,10 +849,12 @@ function readCollapseState() {
} }
} }
// Сохраняет состояния раскрытия дерева/контента.
function saveCollapseState() { function saveCollapseState() {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapseState)); localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapseState));
} }
// Закрывает popup-настройки и синхронизирует aria-expanded.
function closeSettingsPanel() { function closeSettingsPanel() {
settingsPanel.classList.remove("is-open"); settingsPanel.classList.remove("is-open");
settingsOverlay.classList.remove("is-open"); settingsOverlay.classList.remove("is-open");

View File

@ -1,3 +1,11 @@
/* ==========================================================================
01. Theme Tokens
--------------------------------------------------------------------------
Все ключевые цвета и размеры вынесены в CSS-переменные. JavaScript меняет
data-theme/data-accent и --sidebar-width, а остальная верстка реагирует
без прямого изменения inline-стилей у отдельных элементов.
========================================================================== */
:root { :root {
color-scheme: dark; color-scheme: dark;
--bg: #080a12; --bg: #080a12;
@ -17,6 +25,7 @@
--sidebar-width: 390px; --sidebar-width: 390px;
} }
/* Светлая тема переопределяет только токены, структура стилей остается общей. */
html[data-theme="light"] { html[data-theme="light"] {
color-scheme: light; color-scheme: light;
--bg: #f5f7fb; --bg: #f5f7fb;
@ -30,6 +39,7 @@ html[data-theme="light"] {
--shadow: 0 24px 70px rgba(56, 65, 91, 0.18); --shadow: 0 24px 70px rgba(56, 65, 91, 0.18);
} }
/* Наборы акцентов. --accent-rgb нужен для прозрачных градиентов через rgba(). */
html[data-accent="cyan"] { html[data-accent="cyan"] {
--accent: #06b6d4; --accent: #06b6d4;
--accent-2: #3b82f6; --accent-2: #3b82f6;
@ -48,6 +58,12 @@ html[data-accent="amber"] {
--accent-rgb: 245, 158, 11; --accent-rgb: 245, 158, 11;
} }
/* ==========================================================================
02. Base Reset And Page Background
--------------------------------------------------------------------------
Базовые правила страницы, плавная смена темы и фоновые декоративные слои.
========================================================================== */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -79,6 +95,7 @@ a {
color: inherit; color: inherit;
} }
/* Фоновые "ambient" пятна создают глубину, но не перехватывают клики. */
.ambient { .ambient {
position: fixed; position: fixed;
z-index: -1; z-index: -1;
@ -102,12 +119,20 @@ a {
background: rgba(34, 211, 238, 0.3); background: rgba(34, 211, 238, 0.3);
} }
/* ==========================================================================
03. Application Layout
--------------------------------------------------------------------------
.shell ограничивает общую ширину, .app-layout делит экран на:
sidebar -> resizer -> catalog.
========================================================================== */
.shell { .shell {
width: min(1560px, calc(100% - 28px)); width: min(1560px, calc(100% - 28px));
margin: 0 auto; margin: 0 auto;
padding: 20px 0; padding: 20px 0;
} }
/* Левая колонка получает ширину из --sidebar-width, которую меняет JS. */
.app-layout { .app-layout {
display: grid; display: grid;
grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr); grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr);
@ -115,6 +140,13 @@ a {
align-items: start; align-items: start;
} }
/* ==========================================================================
04. Sidebar Resizer
--------------------------------------------------------------------------
Узкая колонка между меню и каталогом. Pointer Events в script.js двигают
CSS-переменную --sidebar-width и сохраняют значение в localStorage.
========================================================================== */
.sidebar-resizer { .sidebar-resizer {
position: sticky; position: sticky;
top: 20px; top: 20px;
@ -141,22 +173,31 @@ body.is-resizing-sidebar .sidebar-resizer::before {
background: var(--accent); background: var(--accent);
} }
/* Во время drag меняем курсор по всей странице и запрещаем выделение текста. */
body.is-resizing-sidebar { body.is-resizing-sidebar {
cursor: col-resize; cursor: col-resize;
user-select: none; user-select: none;
} }
/* ==========================================================================
05. Sidebar Panel
--------------------------------------------------------------------------
Sticky-панель слева: заголовок, шестеренка, поиск и дерево навигации.
========================================================================== */
.side-panel { .side-panel {
position: sticky; position: sticky;
top: 20px; top: 20px;
min-width: 0; min-width: 0;
} }
/* .hero остался как совместимый контейнерный класс после уплотнения шапки. */
.hero { .hero {
display: block; display: block;
margin-bottom: 12px; margin-bottom: 12px;
} }
/* Общая "стеклянная" поверхность для главных панелей. */
.hero-content, .hero-content,
.control-panel, .control-panel,
.catalog-section, .catalog-section,
@ -185,6 +226,7 @@ body.is-resizing-sidebar {
overflow: auto; overflow: auto;
} }
/* Внутренний фон sidebar: градиент + hexagon pattern. */
.hero-content::before { .hero-content::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -201,6 +243,7 @@ body.is-resizing-sidebar {
position: relative; position: relative;
} }
/* Общий стиль маленьких uppercase-меток и номеров. */
.eyebrow, .eyebrow,
.section-number, .section-number,
.group-number, .group-number,
@ -213,16 +256,19 @@ body.is-resizing-sidebar {
text-transform: uppercase; text-transform: uppercase;
} }
/* Нумерация управляется toggle-настройкой "Показывать нумерацию". */
html:not([data-show-numbers="true"]) .section-number, html:not([data-show-numbers="true"]) .section-number,
html:not([data-show-numbers="true"]) .group-number, html:not([data-show-numbers="true"]) .group-number,
html:not([data-show-numbers="true"]) .item-number { html:not([data-show-numbers="true"]) .item-number {
display: none; display: none;
} }
/* Теги в дереве управляются toggle-настройкой "Показывать теги дерева". */
html[data-show-tree-tags="false"] .tree-node small { html[data-show-tree-tags="false"] .tree-node small {
display: none; display: none;
} }
/* Подсветка совпадений поиска в дереве и в карточках. */
.search-mark { .search-mark {
border-radius: 4px; border-radius: 4px;
padding: 0 2px; padding: 0 2px;
@ -230,6 +276,13 @@ html[data-show-tree-tags="false"] .tree-node small {
background: linear-gradient(135deg, var(--accent), var(--accent-2)); background: linear-gradient(135deg, var(--accent), var(--accent-2));
} }
/* ==========================================================================
06. Settings Popup
--------------------------------------------------------------------------
Настройки вынесены из sidebar в fixed popup, чтобы на мобильных устройствах
они не перекрывались потоком контента и всегда были поверх страницы.
========================================================================== */
.control-panel { .control-panel {
position: fixed; position: fixed;
z-index: 1001; z-index: 1001;
@ -253,6 +306,7 @@ html[data-show-tree-tags="false"] .tree-node small {
transform 180ms ease; transform 180ms ease;
} }
/* Затемнение и blur-подложка за открытыми настройками. */
.settings-overlay { .settings-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -265,6 +319,7 @@ html[data-show-tree-tags="false"] .tree-node small {
transition: opacity 180ms ease, visibility 180ms ease; transition: opacity 180ms ease, visibility 180ms ease;
} }
/* Состояния открытия popup и overlay. */
.settings-overlay.is-open { .settings-overlay.is-open {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
@ -278,6 +333,7 @@ html[data-show-tree-tags="false"] .tree-node small {
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
/* Кнопки шестеренки и закрытия имеют один визуальный язык. */
.settings-button, .settings-button,
.settings-close { .settings-close {
border: 1px solid var(--border); border: 1px solid var(--border);
@ -307,6 +363,7 @@ html[data-show-tree-tags="false"] .tree-node small {
background: rgba(var(--accent-rgb), 0.12); background: rgba(var(--accent-rgb), 0.12);
} }
/* Заголовок popup-настроек. */
.settings-panel-head { .settings-panel-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -329,6 +386,13 @@ html[data-show-tree-tags="false"] .tree-node small {
line-height: 1; line-height: 1;
} }
/* ==========================================================================
07. Search Field And Control Inputs
--------------------------------------------------------------------------
Поиск живет в sidebar, а остальные контролы в popup. Здесь собраны общие
стили input, toggle-row, segmented buttons и accent swatches.
========================================================================== */
.search-field { .search-field {
margin-top: 15px; margin-top: 15px;
@ -344,6 +408,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 800; font-weight: 800;
} }
/* Иконка поиска рисуется CSS-ом, чтобы не добавлять лишний DOM. */
.search-field::after { .search-field::after {
content: "⌕"; content: "⌕";
position: absolute; position: absolute;
@ -374,6 +439,7 @@ html[data-show-tree-tags="false"] .tree-node small {
background: var(--panel-strong); background: var(--panel-strong);
} }
/* Toggle-row используется для checkbox-настроек с описанием. */
.toggle-row { .toggle-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -400,6 +466,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 700; font-weight: 700;
} }
/* Кастомный checkbox выглядит как компактный switch. */
.toggle-row input { .toggle-row input {
appearance: none; appearance: none;
width: 48px; width: 48px;
@ -434,6 +501,7 @@ html[data-show-tree-tags="false"] .tree-node small {
transform: translateX(20px); transform: translateX(20px);
} }
/* Контейнеры групп кнопок настроек. */
.control-group { .control-group {
display: flex; display: flex;
align-items: center; align-items: center;
@ -450,6 +518,7 @@ html[data-show-tree-tags="false"] .tree-node small {
padding-left: 6px; padding-left: 6px;
} }
/* Для четырех режимов отображения кнопки складываются в компактную сетку. */
.view-group { .view-group {
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -462,6 +531,7 @@ html[data-show-tree-tags="false"] .tree-node small {
flex-basis: calc(50% - 3px); flex-basis: calc(50% - 3px);
} }
/* Segmented buttons и color swatches имеют общий focus/hover baseline. */
.segmented, .segmented,
.accent-swatch { .accent-swatch {
border: 0; border: 0;
@ -502,6 +572,7 @@ html[data-show-tree-tags="false"] .tree-node small {
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.18); box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.18);
} }
/* Акцентные кружки используют те же цвета, что и html[data-accent]. */
.accent-swatch[data-accent="violet"] { .accent-swatch[data-accent="violet"] {
background: linear-gradient(135deg, #8b5cf6, #22d3ee); background: linear-gradient(135deg, #8b5cf6, #22d3ee);
} }
@ -523,6 +594,14 @@ html[data-show-tree-tags="false"] .tree-node small {
transform: scale(1.05); transform: scale(1.05);
} }
/* ==========================================================================
08. File-Tree Navigation
--------------------------------------------------------------------------
Левая навигация визуально имитирует дерево файлов:
details/summary дают нативное раскрытие, а CSS добавляет линии, caret,
"folder" для разделов/групп и "file" для конечных элементов.
========================================================================== */
.nav-tree { .nav-tree {
display: grid; display: grid;
gap: 2px; gap: 2px;
@ -531,6 +610,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-size: 0.86rem; font-size: 0.86rem;
} }
/* Details в дереве intentionally выглядят не как карточки, а как вложенный список. */
.tree-section, .tree-section,
.tree-group { .tree-group {
border: 0; border: 0;
@ -546,6 +626,7 @@ html[data-show-tree-tags="false"] .tree-node small {
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
} }
/* Summary получает caret, иконку папки и кликабельный текст. */
.tree-section > summary, .tree-section > summary,
.tree-group > summary { .tree-group > summary {
display: grid; display: grid;
@ -559,11 +640,13 @@ html[data-show-tree-tags="false"] .tree-node small {
border-radius: 9px; border-radius: 9px;
} }
/* Нативный маркер details скрываем, так как рисуем свой caret. */
.tree-section > summary::-webkit-details-marker, .tree-section > summary::-webkit-details-marker,
.tree-group > summary::-webkit-details-marker { .tree-group > summary::-webkit-details-marker {
display: none; display: none;
} }
/* Caret поворачивается через состояние [open] у details. */
.tree-caret { .tree-caret {
width: 16px; width: 16px;
height: 24px; height: 24px;
@ -583,6 +666,7 @@ html[data-show-tree-tags="false"] .tree-node small {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
/* Конечный элемент дерева: иконка файла, название и optional теги. */
.tree-node { .tree-node {
min-width: 0; min-width: 0;
width: 100%; width: 100%;
@ -608,11 +692,13 @@ html[data-show-tree-tags="false"] .tree-node small {
white-space: nowrap; white-space: nowrap;
} }
/* Вертикальная линия вложенности дерева. */
.tree-node::before { .tree-node::before {
color: var(--accent); color: var(--accent);
opacity: 0.92; opacity: 0.92;
} }
/* Папки и файлы нарисованы CSS-иконками, чтобы не тянуть отдельные SVG. */
.tree-folder { .tree-folder {
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: 18px minmax(0, 1fr);
} }
@ -631,6 +717,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-size: 0.86rem; font-size: 0.86rem;
} }
/* Контейнер элементов внутри группы дерева. */
.tree-items { .tree-items {
position: relative; position: relative;
display: grid; display: grid;
@ -646,6 +733,7 @@ html[data-show-tree-tags="false"] .tree-node small {
background: rgba(var(--accent-rgb), 0.14); background: rgba(var(--accent-rgb), 0.14);
} }
/* Теги дерева усечены ellipsis, чтобы длинные списки не ломали sidebar. */
.tree-node small { .tree-node small {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -656,12 +744,20 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 800; font-weight: 800;
} }
/* ==========================================================================
09. Catalog Structure
--------------------------------------------------------------------------
Правая часть страницы. Разделы и подразделы тоже построены на details,
поэтому пользователь может сворачивать не только дерево, но и контент.
========================================================================== */
.catalog { .catalog {
display: grid; display: grid;
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
} }
/* Главные glass-карточки разделов. */
.catalog-section { .catalog-section {
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: hidden;
@ -673,6 +769,7 @@ html[data-show-tree-tags="false"] .tree-node small {
display: block; display: block;
} }
/* Summary у разделов/групп заменяет заголовок и одновременно управляет collapse. */
.catalog-section > summary, .catalog-section > summary,
.catalog-group > summary { .catalog-group > summary {
list-style: none; list-style: none;
@ -684,6 +781,7 @@ html[data-show-tree-tags="false"] .tree-node small {
display: none; display: none;
} }
/* Шапка раздела: номер, название, описание, счетчик и индикатор collapse. */
.section-head { .section-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -726,6 +824,7 @@ html[data-show-tree-tags="false"] .tree-node small {
line-height: 1.5; line-height: 1.5;
} }
/* Счетчики помогают понять размер раздела без чтения всех карточек. */
.section-count { .section-count {
white-space: nowrap; white-space: nowrap;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -737,6 +836,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 800; font-weight: 800;
} }
/* Стрелка сворачивания поворачивается через [open]. */
.collapse-indicator { .collapse-indicator {
width: 34px; width: 34px;
height: 34px; height: 34px;
@ -755,6 +855,7 @@ html[data-show-tree-tags="false"] .tree-node small {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
/* Подразделы внутри секции. */
.section-groups { .section-groups {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -794,6 +895,13 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 900; font-weight: 900;
} }
/* ==========================================================================
10. Navigation Cards
--------------------------------------------------------------------------
Конечные кликабельные элементы каталога. Карточки используют CSS-переменные
--card-x/--card-y, которые JS обновляет при движении мыши для shine-эффекта.
========================================================================== */
.items-grid { .items-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@ -801,6 +909,7 @@ html[data-show-tree-tags="false"] .tree-node small {
padding: 10px; padding: 10px;
} }
/* Базовая карточка с иконкой, контентом, тегами, стрелкой и shine-слоем. */
.nav-card { .nav-card {
--tilt-x: 0deg; --tilt-x: 0deg;
--tilt-y: 0deg; --tilt-y: 0deg;
@ -827,6 +936,7 @@ html[data-show-tree-tags="false"] .tree-node small {
scroll-margin-top: 18px; scroll-margin-top: 18px;
} }
/* Временная подсветка цели после клика в дереве. */
.catalog-section.is-focused, .catalog-section.is-focused,
.catalog-group.is-focused, .catalog-group.is-focused,
.nav-card.is-focused { .nav-card.is-focused {
@ -834,6 +944,7 @@ html[data-show-tree-tags="false"] .tree-node small {
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16), var(--shadow); box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16), var(--shadow);
} }
/* Градиентный бордер/подсветка под курсором. */
.nav-card::before { .nav-card::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -849,6 +960,7 @@ html[data-show-tree-tags="false"] .tree-node small {
pointer-events: none; pointer-events: none;
} }
/* Hover задается классом .is-hovered из JS, чтобы убрать скачки на границах. */
.nav-card.is-hovered, .nav-card.is-hovered,
.nav-card:focus-visible { .nav-card:focus-visible {
border-color: rgba(var(--accent-rgb), 0.52); border-color: rgba(var(--accent-rgb), 0.52);
@ -862,6 +974,7 @@ html[data-show-tree-tags="false"] .tree-node small {
opacity: 1; opacity: 1;
} }
/* Иконка сервиса берется из serviceMeta. */
.nav-card img { .nav-card img {
width: 50px; width: 50px;
height: 50px; height: 50px;
@ -880,6 +993,7 @@ html[data-show-tree-tags="false"] .tree-node small {
line-height: 1.25; line-height: 1.25;
} }
/* Тип сервиса выделен акцентом и помогает быстро считывать назначение. */
.item-type { .item-type {
margin: 0; margin: 0;
color: var(--accent); color: var(--accent);
@ -887,6 +1001,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 750; font-weight: 750;
} }
/* Теги показывают, по каким словам карточку удобно искать. */
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -904,6 +1019,7 @@ html[data-show-tree-tags="false"] .tree-node small {
font-weight: 800; font-weight: 800;
} }
/* Стрелка справа подчеркивает, что карточка ведет по ссылке. */
.arrow { .arrow {
position: absolute; position: absolute;
right: 16px; right: 16px;
@ -926,6 +1042,7 @@ html[data-show-tree-tags="false"] .tree-node small {
color: white; color: white;
} }
/* Декоративный shine слой, положение которого обновляется pointermove. */
.shine { .shine {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -945,10 +1062,18 @@ html[data-show-tree-tags="false"] .tree-node small {
transform: translateX(130%); transform: translateX(130%);
} }
/* ==========================================================================
11. View Modes
--------------------------------------------------------------------------
Режимы отображения переключаются классами на .catalog:
cards-view, list-view, compact-cards-view, compact-list-view.
========================================================================== */
.catalog.list-view .items-grid { .catalog.list-view .items-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* В list-режимах карточка вытягивается в строку. */
.catalog.list-view .nav-card, .catalog.list-view .nav-card,
.catalog.compact-list-view .nav-card { .catalog.compact-list-view .nav-card {
min-height: 96px; min-height: 96px;
@ -964,10 +1089,12 @@ html[data-show-tree-tags="false"] .tree-node small {
justify-self: end; justify-self: end;
} }
/* Compact modes скрывают второстепенную информацию и уменьшают отступы. */
.catalog.compact-cards-view .items-grid { .catalog.compact-cards-view .items-grid {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
/* Compact list на широких экранах использует 3 колонки. */
.catalog.compact-list-view .items-grid { .catalog.compact-list-view .items-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
@ -977,6 +1104,7 @@ html[data-show-tree-tags="false"] .tree-node small {
padding: 14px 16px; padding: 14px 16px;
} }
/* В compact-режимах описания и теги скрыты ради плотности. */
.catalog.compact-cards-view .section-head p:not(.section-number), .catalog.compact-cards-view .section-head p:not(.section-number),
.catalog.compact-list-view .section-head p:not(.section-number), .catalog.compact-list-view .section-head p:not(.section-number),
.catalog.compact-cards-view .group-head p:not(.group-number), .catalog.compact-cards-view .group-head p:not(.group-number),
@ -1047,6 +1175,12 @@ html[data-show-tree-tags="false"] .tree-node small {
font-size: 0.72rem; font-size: 0.72rem;
} }
/* ==========================================================================
12. Empty State
--------------------------------------------------------------------------
Показывается, когда фильтр поиска не оставил ни одной карточки.
========================================================================== */
.empty-state { .empty-state {
min-height: 280px; min-height: 280px;
display: grid; display: grid;
@ -1069,6 +1203,13 @@ html[data-show-tree-tags="false"] .tree-node small {
color: var(--muted); color: var(--muted);
} }
/* ==========================================================================
13. Responsive Layout
--------------------------------------------------------------------------
На планшетах sidebar перестает быть отдельной колонкой, а на телефонах
уменьшаются панели, кнопки и размеры карточек.
========================================================================== */
@media (max-width: 1120px) { @media (max-width: 1120px) {
.app-layout { .app-layout {
grid-template-columns: minmax(240px, min(var(--sidebar-width), 320px)) 8px minmax(0, 1fr); grid-template-columns: minmax(240px, min(var(--sidebar-width), 320px)) 8px minmax(0, 1fr);
@ -1194,6 +1335,12 @@ html[data-show-tree-tags="false"] .tree-node small {
} }
} }
/* ==========================================================================
14. Reduced Motion
--------------------------------------------------------------------------
Уважает системную настройку пользователя и отключает анимации/плавный скролл.
========================================================================== */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,